SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
25.3 KB · 576 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# db.py : persistance SQLite — upsert avec détection de changements,5#         cycle de vie avec délai de grâce (2 syncs), détection de dérive,6#         historique de prix, cache des pages détail.7# -----------------------------------------------------------------------------8from __future__ import annotations910import json11import sqlite312import statistics13import time14from pathlib import Path1516from .schema import PropertyListing1718DB_PATH = Path(__file__).resolve().parent.parent / "data" / "immoka.db"1920# Nombre d'exécutions consécutives où une propriété doit être absente de la21# source avant d'être désactivée (délai de grâce contre les ratés ponctuels).22MISS_GRACE = 22324# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique25# (médiane >= DRIFT_MIN_BASE annonces), on alerte et on suspend les retraits.26DRIFT_RATIO = 0.2527DRIFT_MIN_BASE = 828DRIFT_HISTORY = 52930_SCHEMA = """31CREATE TABLE IF NOT EXISTS listings (32    uid           TEXT PRIMARY KEY,33    source        TEXT NOT NULL,34    external_id   TEXT NOT NULL,35    url           TEXT,36    title         TEXT,37    address       TEXT,38    sector        TEXT,39    city          TEXT,40    region        TEXT,41    property_type TEXT,42    price         REAL,43    price_label   TEXT,44    bedrooms      INTEGER,45    bathrooms     INTEGER,46    powder_rooms  INTEGER,47    area_sqft     REAL,48    lot_sqft      REAL,49    year_built    INTEGER,50    mls           TEXT,51    status        TEXT DEFAULT 'a-vendre',52    broker_name   TEXT,53    broker_phone  TEXT,54    agency        TEXT,55    description   TEXT,56    features      TEXT,   -- JSON (liste de textes source)57    details       TEXT,   -- JSON (champs structurés)58    images        TEXT,   -- JSON59    lat           REAL,60    lng           REAL,61    geocode_failed INTEGER DEFAULT 0,62    content_hash  TEXT,63    first_seen    REAL,64    last_seen     REAL,65    updated_at    REAL,66    miss_count    INTEGER DEFAULT 0,67    active        INTEGER DEFAULT 1,68    dup_hidden    INTEGER DEFAULT 0   -- 1 = doublon de sous-agence masqué (dédup Centris)69);70CREATE INDEX IF NOT EXISTS idx_listings_source ON listings(source);71CREATE INDEX IF NOT EXISTS idx_listings_city   ON listings(city);72CREATE INDEX IF NOT EXISTS idx_listings_type   ON listings(property_type);73CREATE INDEX IF NOT EXISTS idx_listings_active ON listings(active);74CREATE INDEX IF NOT EXISTS idx_listings_extid  ON listings(external_id);7576CREATE TABLE IF NOT EXISTS sync_log (77    id        INTEGER PRIMARY KEY AUTOINCREMENT,78    source    TEXT,79    ts        REAL,80    found     INTEGER,81    added     INTEGER,82    updated   INTEGER,83    removed   INTEGER,84    ok        INTEGER,85    message   TEXT,86    stats     TEXT    -- JSON : taux de champs null, missed, alerte…87);8889CREATE TABLE IF NOT EXISTS detail_cache (90    source      TEXT NOT NULL,91    external_id TEXT NOT NULL,92    key         TEXT,            -- hash du contenu « liste » de l'annonce93    payload     TEXT,            -- JSON opaque propre au connecteur94    fetched_at  REAL,95    PRIMARY KEY (source, external_id)96);9798CREATE TABLE IF NOT EXISTS price_log (99    uid   TEXT NOT NULL,100    ts    REAL NOT NULL,101    price REAL              -- prix observé (baisses/hausses de prix demandé)102);103CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid);104105CREATE TABLE IF NOT EXISTS geocode_cache (106    address     TEXT PRIMARY KEY,107    lat         REAL,108    lng         REAL,109    provider    TEXT,110    failed      INTEGER DEFAULT 0,111    ts          REAL,112    muni        TEXT               -- municipalité officielle (clés « ville:… »)113);114115CREATE TABLE IF NOT EXISTS poi_cache (116    coord_key   TEXT PRIMARY KEY,   -- "lat,lng" arrondi à 4 décimales (~11 m)117    lat         REAL,118    lng         REAL,119    pois        TEXT,               -- JSON : [{cat, name, dist_m}] (plus proche/catégorie)120    ts          REAL121);122"""123124125_SCHEMA_READY = False   # le schéma/migration ne s'exécute qu'UNE fois par process126127128def _init_schema(con: sqlite3.Connection) -> None:129    """Création de schéma + migration — coûteuse (write-lock). À faire une seule130    fois par process : l'exécuter à chaque connexion sérialisait les requêtes du131    web sur un verrou d'écriture (deadlock/starvation sous charge)."""132    con.executescript(_SCHEMA)133    cols = {r["name"] for r in con.execute("PRAGMA table_info(listings)")}134    if "agency" not in cols:135        con.execute("ALTER TABLE listings ADD COLUMN agency TEXT")136    if "dup_hidden" not in cols:137        con.execute("ALTER TABLE listings ADD COLUMN dup_hidden INTEGER DEFAULT 0")138    if "dup_of" not in cols:   # uid de la fiche visible au profit de laquelle139        con.execute("ALTER TABLE listings ADD COLUMN dup_of TEXT")  # celle-ci est masquée140    if "dauid" not in cols:   # aire de diffusion 2021 (stats de quartier)141        con.execute("ALTER TABLE listings ADD COLUMN dauid TEXT")142    if "vraiprix" not in cols:   # estimation Vrai-Prix (JSON) + lien analyse143        con.execute("ALTER TABLE listings ADD COLUMN vraiprix TEXT")144    gcols = {r["name"] for r in con.execute("PRAGMA table_info(geocode_cache)")}145    if "muni" not in gcols:   # municipalité officielle (entrées « ville:… »)146        con.execute("ALTER TABLE geocode_cache ADD COLUMN muni TEXT")147    con.execute("CREATE INDEX IF NOT EXISTS idx_listings_duphidden ON listings(dup_hidden)")148    con.execute("CREATE INDEX IF NOT EXISTS idx_listings_dupof ON listings(dup_of)")149    con.execute("CREATE INDEX IF NOT EXISTS idx_listings_geo ON listings(lat, lng)")150    con.commit()151152153def refresh_dedup(con: sqlite3.Connection) -> int:154    """Pré-calcule la déduplication de la famille sous-agences (`*_ag_*`) dans la155    colonne `dup_hidden`, pour que la lecture soit instantanée (« AND dup_hidden=0 »)156    au lieu d'un sous-select corrélé par ligne (300 s sur 75 k lignes).157158    Règle (identique à l'ancienne clause) : une fiche de sous-agence est masquée159    si une fiche de plus haute priorité — même n° Centris (external_id), active —160    existe : le flux central/agence principale d'abord, sinon la sous-agence au161    plus petit uid. Les fiches non sous-agence ne sont jamais masquées par162    cette règle. Une 3e passe (dedup_by_address) masque ensuite les doublons163    INTER-SOURCES sans n° Centris commun (même adresse + type + prix ±1 %).164    Retourne le nombre total de fiches masquées."""165    AG = "source LIKE '%\\_ag\\_%' ESCAPE '\\'"166    NOTAG = "source NOT LIKE '%\\_ag\\_%' ESCAPE '\\'"167    con.execute("UPDATE listings SET dup_hidden=0, dup_of=NULL")168    # 1) masquer les sous-agences dont le n° Centris est porté par une fiche169    #    canonique (non sous-agence) active — semi-jointure, rapide.170    #    dup_of = la fiche canonique (pour la box « Aussi publiée sur… »).171    con.execute(172        f"UPDATE listings SET dup_hidden=1,"173        f" dup_of=(SELECT MIN(d.uid) FROM listings d WHERE d.active=1"174        f"   AND d.external_id=listings.external_id AND d.{NOTAG})"175        f" WHERE active=1 AND {AG}"176        f" AND external_id IN (SELECT external_id FROM listings"177        f"   WHERE active=1 AND {NOTAG})")178    # 2) parmi les sous-agences restantes (sans canonique), ne garder que le plus179    #    petit uid par n° Centris.180    con.execute(181        f"UPDATE listings SET dup_hidden=1,"182        f" dup_of=(SELECT MIN(d.uid) FROM listings d WHERE d.active=1"183        f"   AND d.external_id=listings.external_id AND d.{AG} AND d.dup_hidden=0)"184        f" WHERE active=1 AND {AG} AND dup_hidden=0"185        f" AND uid > (SELECT MIN(d.uid) FROM listings d WHERE d.active=1"186        f"   AND d.external_id=listings.external_id AND d.{AG} AND d.dup_hidden=0)")187    # 3) dédup INTER-SOURCES par adresse : la même propriété publiée sur deux188    #    plateformes (ex. courtier + Kijiji) sans n° Centris commun. Règle189    #    conservatrice : même adresse normalisée (civique + rue + ville) + même190    #    type + prix identique à ±1 % → on garde la fiche la plus autoritaire.191    try:192        n_addr = dedup_by_address(con)193        if n_addr:194            print(f"[immo-ka] dédup adresse: {n_addr} doublon(s) inter-sources masqué(s)")195    except Exception:196        import traceback197        traceback.print_exc()198    n = con.execute("SELECT COUNT(*) c FROM listings WHERE dup_hidden=1").fetchone()["c"]199    con.commit()200    return n201202203# petites annonces généralistes (republication d'annonces d'ailleurs) : moins204# autoritaires que la source primaire (courtier / FSBO première main).205# fb_marketplace : fiches anonymes/republication — jamais préférées à un courtier.206_PETITES_ANNONCES = {"kijiji", "lespac", "fb_marketplace"}207208209def _source_rank(source: str) -> int:210    """Autorité d'une source pour la dédup d'adresse :211    0 = source primaire (bannière/agence/FSBO), 1 = sous-agence (_ag_),212    2 = petites annonces (republication probable)."""213    if source in _PETITES_ANNONCES:214        return 2215    if "_ag_" in source:216        return 1217    return 0218219220def dedup_by_address(con: sqlite3.Connection) -> int:221    """Passe de déduplication conservatrice par ADRESSE (inter-sources).222223    Clé : n° civique(s) + mots significatifs de la rue + ville normalisée +224    type canonique + n° d'app/unité (vide s'il n'y en a pas — deux unités225    différentes d'un même immeuble ne partagent JAMAIS la même clé, et une226    adresse sans unité ne s'apparie pas à une adresse avec unité). Dans une227    clé, seules les fiches au prix identique à ±1 % sont considérées comme228    doublons ; si une même source apparaît deux fois dans le groupe (probables229    unités jumelles d'un projet neuf), le groupe ENTIER est ignoré. On garde230    la fiche la plus autoritaire (source primaire > sous-agence _ag_ >231    petites annonces), puis à autorité égale celle qui a un COURTIER/agence232    (jamais une fiche anonyme devant un courtier), puis le plus petit uid.233    Retourne le nb masqué."""234    import re as _re235    from .vraiprix_local import _addr_parts, _norm, _muni_norm, _APP_RE236    groups: dict[tuple, list] = {}237    for r in con.execute(238            "SELECT uid, source, address, city, price, property_type,"239            " broker_name, agency"240            " FROM listings WHERE active=1 AND dup_hidden=0 AND address<>''"241            " AND city<>'' AND price IS NOT NULL AND property_type<>''"):242        civs, words = _addr_parts(r["address"])243        if not civs or not words:244            continue245        a = _norm(r["address"]).split(",")[0]246        mapt = _re.search(_APP_RE, a)247        apt = ""248        if mapt:249            toks = _re.findall(r"[a-z0-9]+", mapt.group(0))250            # dernier token = le n° d'unité (le 1er est le mot-clé app/unité/#)251            apt = toks[-1] if toks else ""252        key = ("-".join(civs), " ".join(sorted(set(words))),253               _muni_norm(r["city"]), _norm(r["property_type"]), apt)254        anonyme = 0 if (r["broker_name"] or r["agency"]) else 1255        groups.setdefault(key, []).append(256            (r["price"], _source_rank(r["source"]), anonyme, r["uid"], r["source"]))257    hidden = 0258    for rows in groups.values():259        if len(rows) < 2:260            continue261        rows.sort()                       # par prix croissant262        cluster: list = []263        for row in rows:264            if cluster and row[0] > cluster[0][0] * 1.01:265                hidden += _mask_cluster(con, cluster)266                cluster = []267            cluster.append(row)268        hidden += _mask_cluster(con, cluster)269    return hidden270271272def _mask_cluster(con: sqlite3.Connection, cluster: list) -> int:273    """Masque les doublons d'un groupe (même clé d'adresse, prix ±1 %), puis274    FUSIONNE en « golden record » : les champs vides de la fiche conservée sont275    complétés depuis les doublons masqués (description plus longue, galerie plus276    riche, superficies, année, GPS, téléphone) — le meilleur des deux sources."""277    if len(cluster) < 2:278        return 0279    sources = [c[4] for c in cluster]280    if len(set(sources)) != len(sources):281        return 0    # même source en double = probables unités distinctes : prudence282    keep = min(cluster, key=lambda c: (c[1], c[2], c[3]))   # (autorité, anonyme, uid)283    n = 0284    donors = []285    for c in cluster:286        if c[3] != keep[3]:287            con.execute("UPDATE listings SET dup_hidden=1, dup_of=? WHERE uid=?",288                        (keep[3], c[3]))289            donors.append(c[3])290            n += 1291    try:292        _merge_golden(con, keep[3], donors)293    except Exception:294        pass          # la fusion est un bonus — ne jamais casser la dédup295    return n296297298_MERGE_NUM_FIELDS = ("bedrooms", "bathrooms", "powder_rooms", "area_sqft",299                     "lot_sqft", "year_built")300301302def _merge_golden(con: sqlite3.Connection, keep_uid: str, donor_uids: list[str]) -> None:303    """Complète les champs vides de `keep_uid` depuis ses doublons masqués."""304    if not donor_uids:305        return306    cols = ("uid, description, images, bedrooms, bathrooms, powder_rooms,"307            " area_sqft, lot_sqft, year_built, lat, lng, broker_phone")308    keep = con.execute(f"SELECT {cols} FROM listings WHERE uid=?",309                       (keep_uid,)).fetchone()310    if keep is None:311        return312    sets, args = [], []313    kimgs = len(json.loads(keep["images"] or "[]"))314    kdesc = len(keep["description"] or "")315    best_imgs, best_desc = None, None316    donor_rows = con.execute(317        f"SELECT {cols} FROM listings WHERE uid IN "318        f"({','.join('?' * len(donor_uids))})", donor_uids).fetchall()319    merged_num: dict = {}320    for d in donor_rows:321        di = json.loads(d["images"] or "[]")322        if len(di) > max(kimgs, len(json.loads(best_imgs or "[]"))):323            best_imgs = d["images"]324        dd = d["description"] or ""325        if len(dd) > max(kdesc, 80, len(best_desc or "")):326            best_desc = dd327        for f in _MERGE_NUM_FIELDS:328            if keep[f] is None and merged_num.get(f) is None and d[f] is not None:329                merged_num[f] = d[f]330        # GPS : toujours la PAIRE du même donneur (jamais lat et lng mélangés)331        if (keep["lat"] is None and "lat" not in merged_num332                and d["lat"] is not None and d["lng"] is not None):333            merged_num["lat"], merged_num["lng"] = d["lat"], d["lng"]334        if not keep["broker_phone"] and d["broker_phone"] and "broker_phone" not in merged_num:335            merged_num["broker_phone"] = d["broker_phone"]336    if best_imgs is not None:337        sets.append("images=?"); args.append(best_imgs)338    if best_desc is not None and kdesc < 80:339        sets.append("description=?"); args.append(best_desc)340    for f, v in merged_num.items():341        sets.append(f"{f}=?"); args.append(v)342    if sets:343        args.append(keep_uid)344        con.execute(f"UPDATE listings SET {', '.join(sets)} WHERE uid=?", args)345346347def connect() -> sqlite3.Connection:348    global _SCHEMA_READY349    DB_PATH.parent.mkdir(parents=True, exist_ok=True)350    con = sqlite3.connect(DB_PATH, timeout=60)351    con.row_factory = sqlite3.Row352    # WAL + busy_timeout : accès concurrents (watcher + web) sans « db is locked ».353    con.execute("PRAGMA journal_mode=WAL")354    # 120 s : couvre les longues transactions (refresh_dedup ~70 s) sans que les355    # autres écrivains (géocodage, vraiprix) ne lèvent « database is locked ».356    con.execute("PRAGMA busy_timeout=120000")357    con.execute("PRAGMA synchronous=NORMAL")358    if not _SCHEMA_READY:359        _init_schema(con)360        _SCHEMA_READY = True361    return con362363364# ---------------------------------------------------------------------------365# Synchronisation d'une source366# ---------------------------------------------------------------------------367368def _drift_alert(con: sqlite3.Connection, source: str, found: int,369                 null_price_rate: float) -> str | None:370    """Détecte une dérive du connecteur (chute du volume ou des prix extraits)."""371    hist = con.execute(372        "SELECT found, stats FROM sync_log WHERE source=? AND ok=1"373        " ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY)).fetchall()374    if len(hist) < 3:375        return None376    med_found = statistics.median(r["found"] for r in hist)377    if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:378        return (f"dérive: {found} propriété(s) trouvée(s) contre une médiane de "379                f"{med_found:.0f} — retraits suspendus, vérifier le connecteur")380    if found >= DRIFT_MIN_BASE and null_price_rate >= 0.8:381        rates = []382        for r in hist:383            try:384                rates.append(json.loads(r["stats"] or "{}")["null_price_rate"])385            except (KeyError, ValueError, TypeError):386                continue387        if rates and statistics.median(rates) <= 0.3:388            return (f"dérive: {null_price_rate:.0%} des propriétés sans prix "389                    f"(habituellement {statistics.median(rates):.0%}) — "390                    "le format de la source a probablement changé")391    return None392393394def sync_source(con: sqlite3.Connection, source: str,395                listings: list[PropertyListing]) -> dict:396    """Synchronise les propriétés d'une source.397398    - nouvelle propriété -> insertion399    - propriété modifiée -> mise à jour (comparaison de content_hash)400    - propriété disparue -> miss_count += 1, puis active=0 après MISS_GRACE401      exécutions consécutives (vendue ou retirée)402    - dérive détectée    -> alerte consignée, retraits suspendus403    """404    now = time.time()405    added = updated = 0406    seen_uids = set()407408    n = len(listings)409    null_price = sum(1 for l in listings if l.price is None)410    null_addr = sum(1 for l in listings if not l.address)411    null_price_rate = round(null_price / n, 3) if n else 0.0412413    alert = _drift_alert(con, source, n, null_price_rate)414415    for lst in listings:416        seen_uids.add(lst.uid)417        h = lst.content_hash()418        row = con.execute("SELECT content_hash, price FROM listings WHERE uid=?",419                          (lst.uid,)).fetchone()420        params = dict(421            uid=lst.uid, source=lst.source, external_id=lst.external_id,422            url=lst.url, title=lst.title, address=lst.address,423            sector=lst.sector, city=lst.city, region=lst.region,424            property_type=lst.property_type, price=lst.price,425            price_label=lst.price_label, bedrooms=lst.bedrooms,426            bathrooms=lst.bathrooms, powder_rooms=lst.powder_rooms,427            area_sqft=lst.area_sqft, lot_sqft=lst.lot_sqft,428            year_built=lst.year_built, mls=lst.mls, status=lst.status,429            broker_name=lst.broker_name, broker_phone=lst.broker_phone,430            agency=lst.agency,431            description=lst.description,432            features=json.dumps(lst.features, ensure_ascii=False),433            details=json.dumps(lst.details, ensure_ascii=False),434            images=json.dumps(lst.images, ensure_ascii=False),435            lat=lst.lat, lng=lst.lng, content_hash=h, now=now,436        )437        if row is None:438            con.execute(439                """INSERT INTO listings (uid, source, external_id, url, title,440                   address, sector, city, region, property_type, price,441                   price_label, bedrooms, bathrooms, powder_rooms, area_sqft,442                   lot_sqft, year_built, mls, status, broker_name, broker_phone,443                   agency, description, features, details, images, lat, lng,444                   content_hash, first_seen, last_seen, updated_at,445                   miss_count, active)446                   VALUES (:uid,:source,:external_id,:url,:title,:address,447                   :sector,:city,:region,:property_type,:price,:price_label,448                   :bedrooms,:bathrooms,:powder_rooms,:area_sqft,:lot_sqft,449                   :year_built,:mls,:status,:broker_name,:broker_phone,450                   :agency,:description,:features,:details,:images,:lat,:lng,451                   :content_hash,:now,:now,:now,0,1)""", params)452            if lst.price is not None:453                con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",454                            (lst.uid, now, lst.price))455            added += 1456        elif row["content_hash"] != h:457            # COALESCE : ne jamais écraser par null des coordonnées géocodées ni458            # les année/superficies remplies par enrichissement (rôle d'évaluation,459            # fiche détail) quand la source liste ne les fournit pas460            con.execute(461                """UPDATE listings SET url=:url, title=:title,462                   address=:address, sector=:sector, city=:city, region=:region,463                   property_type=:property_type, price=:price,464                   price_label=:price_label, bedrooms=:bedrooms,465                   bathrooms=:bathrooms, powder_rooms=:powder_rooms,466                   area_sqft=COALESCE(:area_sqft, area_sqft),467                   lot_sqft=COALESCE(:lot_sqft, lot_sqft),468                   year_built=COALESCE(:year_built, year_built),469                   mls=:mls, status=:status,470                   broker_name=:broker_name, broker_phone=:broker_phone,471                   agency=:agency, description=:description, features=:features,472                   details=:details, images=:images,473                   lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),474                   content_hash=:content_hash, last_seen=:now,475                   updated_at=:now, miss_count=0, active=1476                   WHERE uid=:uid""", params)477            if lst.price != row["price"]:   # baisse/hausse de prix -> historique478                con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",479                            (lst.uid, now, lst.price))480            updated += 1481        else:482            con.execute(483                "UPDATE listings SET last_seen=?, miss_count=0, active=1 WHERE uid=?",484                (now, lst.uid))485486    # Propriétés de cette source qui n'apparaissent plus : délai de grâce,487    # puis désactivation (vendue/retirée). Suspendu si dérive détectée.488    removed = missed = 0489    if not alert:490        for r in con.execute(491                "SELECT uid, miss_count FROM listings WHERE source=? AND active=1",492                (source,)).fetchall():493            if r["uid"] in seen_uids:494                continue495            missed += 1496            if r["miss_count"] + 1 >= MISS_GRACE:497                con.execute(498                    "UPDATE listings SET active=0, miss_count=?, updated_at=?"499                    " WHERE uid=?", (r["miss_count"] + 1, now, r["uid"]))500                removed += 1501            else:502                con.execute("UPDATE listings SET miss_count=miss_count+1 WHERE uid=?",503                            (r["uid"],))504505    stats = {506        "null_price_rate": null_price_rate,507        "null_address_rate": round(null_addr / n, 3) if n else 0.0,508        "missed": missed,509    }510    if alert:511        stats["alert"] = alert512    con.execute(513        "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,"514        " message, stats) VALUES (?,?,?,?,?,?,1,?,?)",515        (source, now, n, added, updated, removed, alert or "ok",516         json.dumps(stats, ensure_ascii=False)))517    con.commit()518    out = {"source": source, "found": n, "added": added,519           "updated": updated, "removed": removed}520    if alert:521        out["alert"] = alert522    return out523524525def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:526    con.execute(527        "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)"528        " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message))529    con.commit()530531532# ---------------------------------------------------------------------------533# Cache des pages détail (« détail si nouveau/modifié »)534# ---------------------------------------------------------------------------535536def get_cached_detail(con: sqlite3.Connection, source: str,537                      external_id: str, key: str) -> dict | None:538    """Payload détail mis en cache si la clé (hash liste) n'a pas changé."""539    row = con.execute(540        "SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?",541        (source, external_id)).fetchone()542    if row and row["key"] == key and row["payload"]:543        try:544            return json.loads(row["payload"])545        except ValueError:546            return None547    return None548549550def get_stale_detail(con: sqlite3.Connection, source: str,551                     external_id: str) -> dict | None:552    """Payload détail SANS vérifier la clé — repli « périmé plutôt que rien »553    quand le budget de re-fetch d'un cycle est épuisé (ex. bump de version de554    clé) : la fiche garde photos/détails existants en attendant son re-parse."""555    row = con.execute(556        "SELECT payload FROM detail_cache WHERE source=? AND external_id=?",557        (source, external_id)).fetchone()558    if row and row["payload"]:559        try:560            return json.loads(row["payload"])561        except ValueError:562            return None563    return None564565566def put_cached_detail(con: sqlite3.Connection, source: str,567                      external_id: str, key: str, payload: dict) -> None:568    con.execute(569        "INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)"570        " VALUES (?,?,?,?,?)"571        " ON CONFLICT(source, external_id) DO UPDATE SET"572        " key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at",573        (source, external_id, key, json.dumps(payload, ensure_ascii=False),574         time.time()))575    con.commit()576