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%
13.0 KB · 294 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# imgaudit.py : qualité des images d'annonces.5#6#   1) clean_gallery(urls) — nettoyage STATIQUE (sans réseau), appliqué par7#      PropertyListing.finalize() : URLs invalides, placeholders connus des8#      portails (« photo à venir », logos), doublons (y compris la même photo9#      en deux tailles).10#   2) run_batch(...) — audit RÉSEAU budgété des photos de couverture : lien11#      mort (4xx/5xx/timeout), image minuscule/pixellisée (dimensions décodées12#      de l'en-tête JPEG/PNG/WebP/GIF), fichier corrompu. Résultats en cache13#      (table image_audit, TTL 30 jours). Une couverture morte est retirée et14#      la première image VALIDE de la galerie est promue ; une annonce sans15#      aucune image valide est marquée (details.needs_image_review) — le16#      frontend affiche alors l'image de secours par type de bien.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import json21import re22import sqlite323import struct24import time25from concurrent.futures import ThreadPoolExecutor2627import requests2829AUDIT_TTL = 30 * 86400          # re-vérification d'une URL après 30 jours30MIN_WIDTH, MIN_HEIGHT = 250, 160  # sous ces dimensions : miniature inutilisable31MIN_BYTES = 3_000                # fichier suspicieusement petit (icône/placeholder)32TIMEOUT = 1033UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "34      "(KHTML, like Gecko) Chrome/126 Safari/537.36 ImmoKaBot/1.0")3536# motifs de placeholders/logos des portails (jamais une photo de propriété).37# ⚠ default/defaut/logo sont ANCRÉS sur le nom de fichier : un segment de38# chemin comme Cloudinary « t_default_size/ » n'est PAS un placeholder39# (bug payé : toutes les galeries Ubee vidées par « default[-_.] »).40_PLACEHOLDER_RE = re.compile(41    r"placeholder|no[-_]?photo|nophoto|photo[-_]?a[-_]?venir|coming[-_]?soon|"42    r"missing|awaiting|shadow_listing|image[-_]?indisponible|no[-_]?image|"43    r"/(?:default|defaut|logo)[^/]*\.(?:jpe?g|png|webp|gif|svg)(?:\?|$)", re.I)4445_SIZE_VARIANT_RE = re.compile(r"-(?:sm|md|lg|xl|thumb|small|medium|large)(?=\.\w+$)")4647# handlers dynamiques : la photo est identifiée par la QUERYSTRING (propId/seq,48# id…), pas par le chemin — ex. yoamo.immo/ALSPicture.axd?propId=…&seq=N,49# mediaserver.centris.ca/media.ashx?id=… On ne retire que les params de taille.50_DYNAMIC_EXT = (".axd", ".ashx", ".php", ".aspx", ".cfm")51_SIZE_PARAM_RE = re.compile(r"&(?:w|h|width|height|size|sm|scale|quality)=[^&]*", re.I)525354def _canon(url: str) -> str:55    """Clé de déduplication : ignore la variante de taille et la querystring56    de redimensionnement pour attraper la même photo en deux formats."""57    path, _, query = url.partition("?")58    if query and path.lower().endswith(_DYNAMIC_EXT):59        params = _SIZE_PARAM_RE.sub("", "&" + query.replace("&amp;", "&")).lstrip("&")60        return f"{path}?{params}".lower()61    return _SIZE_VARIANT_RE.sub("", path).lower()626364def clean_gallery(urls: list[str]) -> list[str]:65    """Nettoyage statique d'une galerie : URLs http(s) uniquement, placeholders66    retirés, doublons (même photo, autre taille) dédupliqués, ordre préservé."""67    out: list[str] = []68    seen: set[str] = set()69    for u in urls or []:70        if not isinstance(u, str):71            continue72        u = u.strip()73        if not u.lower().startswith(("http://", "https://")):74            continue75        if _PLACEHOLDER_RE.search(u):76            continue77        key = _canon(u)78        if key in seen:79            continue80        seen.add(key)81        out.append(u)82    return out838485# ---------------------------------------------------------------------------86# Décodage des dimensions depuis les premiers octets (sans télécharger tout)87# ---------------------------------------------------------------------------8889def image_size(data: bytes) -> tuple[int, int] | None:90    """(largeur, hauteur) depuis l'en-tête PNG/GIF/WebP/JPEG, None si indécodable."""91    if len(data) < 26:92        return None93    if data[:8] == b"\x89PNG\r\n\x1a\n":94        w, h = struct.unpack(">II", data[16:24])95        return w, h96    if data[:6] in (b"GIF87a", b"GIF89a"):97        w, h = struct.unpack("<HH", data[6:10])98        return w, h99    if data[:4] == b"RIFF" and data[8:12] == b"WEBP":100        if data[12:16] == b"VP8 " and len(data) >= 30:101            w, h = struct.unpack("<HH", data[26:30])102            return w & 0x3FFF, h & 0x3FFF103        if data[12:16] == b"VP8L" and len(data) >= 25:104            bits = struct.unpack("<I", data[21:25])[0]105            return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1106        if data[12:16] == b"VP8X" and len(data) >= 30:107            w = int.from_bytes(data[24:27], "little") + 1108            h = int.from_bytes(data[27:30], "little") + 1109            return w, h110    if data[:2] == b"\xff\xd8":                      # JPEG : chercher le SOF111        i = 2112        while i + 9 < len(data):113            if data[i] != 0xFF:114                i += 1115                continue116            marker = data[i + 1]117            if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,118                          0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):119                h, w = struct.unpack(">HH", data[i + 5:i + 9])120                return w, h121            seg_len = struct.unpack(">H", data[i + 2:i + 4])[0]122            i += 2 + seg_len123    return None124125126def check_url(url: str) -> dict:127    """Vérifie une URL d'image : {ok, status, width, height, bytes, reason}.128129    ⚠ Verdict « morte » UNIQUEMENT sur preuve solide (404/410) : un 403/429 est130    presque toujours du rate-limiting ou de l'anti-hotlink du CDN (bug payé :131    ~6 000 galeries DuProprio retirées à tort). En cas de doute on garde132    l'image — le repli onError du frontend couvre les rares vraies mortes."""133    try:134        r = requests.get(url, headers={"User-Agent": UA, "Range": "bytes=0-65535"},135                         timeout=TIMEOUT, stream=True)136        status = r.status_code137        if status in (404, 410):138            return {"ok": 0, "status": status, "reason": "http"}139        if status >= 400:140            return {"ok": 1, "status": status, "reason": "non_verifiable"}141        data = next(r.iter_content(65536), b"") or b""142        r.close()143        total = int((r.headers.get("Content-Range") or "/0").split("/")[-1] or 0) \144            or int(r.headers.get("Content-Length") or 0) or len(data)145        size = image_size(data)146        if size is None:147            ctype = (r.headers.get("Content-Type") or "").lower()148            if "image" not in ctype:149                return {"ok": 0, "status": status, "bytes": total, "reason": "format"}150            # image valide mais en-tête non décodé (format exotique) : on tolère151            return {"ok": 1, "status": status, "bytes": total}152        w, h = size153        if w < MIN_WIDTH or h < MIN_HEIGHT:154            return {"ok": 0, "status": status, "width": w, "height": h,155                    "bytes": total, "reason": "minuscule"}156        if total and total < MIN_BYTES:157            return {"ok": 0, "status": status, "width": w, "height": h,158                    "bytes": total, "reason": "poids_suspect"}159        return {"ok": 1, "status": status, "width": w, "height": h, "bytes": total}160    except requests.RequestException:161        # réseau/timeout : non concluant — ne jamais retirer sur un doute162        return {"ok": 1, "status": 0, "reason": "non_verifiable"}163164165# ---------------------------------------------------------------------------166# Audit budgété des couvertures (+ galeries courtes) avec cache BD167# ---------------------------------------------------------------------------168169def _ensure_table(con: sqlite3.Connection) -> None:170    con.execute("""CREATE TABLE IF NOT EXISTS image_audit (171        url        TEXT PRIMARY KEY,172        ok         INTEGER,173        status     INTEGER,174        width      INTEGER,175        height     INTEGER,176        bytes      INTEGER,177        reason     TEXT,178        checked_at REAL179    )""")180    con.commit()181182183def _cached(con: sqlite3.Connection, url: str) -> dict | None:184    r = con.execute("SELECT ok, reason, checked_at FROM image_audit WHERE url=?",185                    (url,)).fetchone()186    if r and time.time() - (r["checked_at"] or 0) < AUDIT_TTL:187        return {"ok": r["ok"], "reason": r["reason"]}188    return None189190191def _store(con: sqlite3.Connection, url: str, res: dict) -> None:192    con.execute(193        "INSERT INTO image_audit (url, ok, status, width, height, bytes, reason,"194        " checked_at) VALUES (?,?,?,?,?,?,?,?)"195        " ON CONFLICT(url) DO UPDATE SET ok=excluded.ok, status=excluded.status,"196        " width=excluded.width, height=excluded.height, bytes=excluded.bytes,"197        " reason=excluded.reason, checked_at=excluded.checked_at",198        (url, res.get("ok"), res.get("status"), res.get("width"),199         res.get("height"), res.get("bytes"), res.get("reason"), time.time()))200201202def audit_listing(con: sqlite3.Connection, uid: str, images: list[str],203                  details: dict, pool: ThreadPoolExecutor) -> tuple[list[str], dict, int]:204    """Vérifie la couverture (et remonte la 1re image valide en tête).205206    Vérifie au plus les 4 premières images ; les mortes sont retirées de la207    galerie. Retourne (nouvelle galerie, details, nb de vérifications réseau)."""208    checked = 0209    good_idx = None210    dead: set[int] = set()211    for i, url in enumerate(images[:4]):212        res = _cached(con, url)213        if res is None:214            res = check_url(url)215            _store(con, url, res)216            checked += 1217        if res.get("ok"):218            good_idx = i219            break220        dead.add(i)221    new_images = [u for i, u in enumerate(images) if i not in dead]222    details = dict(details)223    if good_idx is None and images:224        # aucune image valide parmi les premières : re-vérification demandée,225        # le frontend applique l'image de secours par type de bien226        details["needs_image_review"] = True227    else:228        details.pop("needs_image_review", None)229    return new_images, details, checked230231232def run_batch(limit: int = 2000, workers: int = 8) -> dict:233    """Audit réseau budgété : les annonces publiées jamais auditées d'abord.234235    Appelé après chaque synchronisation (ingest.watch) ; relancer avec un gros236    `limit` pour un rattrapage complet. Idempotent grâce au cache par URL."""237    from . import db238    con = db.connect()239    _ensure_table(con)240    rows = con.execute(241        "SELECT uid, images, details FROM listings"242        " WHERE active=1 AND dup_hidden=0 AND images IS NOT NULL AND images!='[]'"243        " AND json_extract(COALESCE(details,'{}'), '$.img_audited') IS NULL"244        " LIMIT ?", (limit,)).fetchall()245    checked = removed = flagged = 0246247    def _work(row):248        images = json.loads(row["images"] or "[]")249        details = json.loads(row["details"] or "{}")250        # les URLs sont vérifiées séquentiellement par annonce ; le parallélisme251        # est au niveau des annonces (une connexion BD par worker serait fragile,252        # donc le réseau seul est parallèle : cache lu/écrit dans le fil principal)253        return row["uid"], images, details254255    with ThreadPoolExecutor(max_workers=workers) as pool:256        futures = []257        for row in rows:258            uid, images, details = _work(row)259            futures.append((uid, images, details))260        # traitement principal (cache BD dans ce fil, réseau via check_url —261        # parallélisé par lots d'URLs de couverture inconnues)262        unknown = []263        for uid, images, details in futures:264            for u in images[:4]:265                if _cached(con, u) is None:266                    unknown.append(u)267        unknown = list(dict.fromkeys(unknown))268        # commits par tranches : ne JAMAIS tenir le verrou d'écriture pendant269        # tout l'audit (des dizaines de minutes) — les syncs/le web écrivent aussi270        for url, res in zip(unknown, pool.map(check_url, unknown)):271            _store(con, url, res)272            checked += 1273            if checked % 400 == 0:274                con.commit()275        con.commit()276        done = 0277        for uid, images, details in futures:278            new_images, new_details, _ = audit_listing(con, uid, images, details,279                                                       pool)280            new_details["img_audited"] = int(time.time())281            if new_details.get("needs_image_review"):282                flagged += 1283            removed += len(images) - len(new_images)284            con.execute("UPDATE listings SET images=?, details=? WHERE uid=?",285                        (json.dumps(new_images, ensure_ascii=False),286                         json.dumps(new_details, ensure_ascii=False), uid))287            done += 1288            if done % 400 == 0:289                con.commit()290    con.commit()291    con.close()292    return {"annonces": len(rows), "urls_verifiees": checked,293            "images_retirees": removed, "sans_image_valide": flagged}294