# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # imgaudit.py : qualité des images d'annonces. # # 1) clean_gallery(urls) — nettoyage STATIQUE (sans réseau), appliqué par # PropertyListing.finalize() : URLs invalides, placeholders connus des # portails (« photo à venir », logos), doublons (y compris la même photo # en deux tailles). # 2) run_batch(...) — audit RÉSEAU budgété des photos de couverture : lien # mort (4xx/5xx/timeout), image minuscule/pixellisée (dimensions décodées # de l'en-tête JPEG/PNG/WebP/GIF), fichier corrompu. Résultats en cache # (table image_audit, TTL 30 jours). Une couverture morte est retirée et # la première image VALIDE de la galerie est promue ; une annonce sans # aucune image valide est marquée (details.needs_image_review) — le # frontend affiche alors l'image de secours par type de bien. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import sqlite3 import struct import time from concurrent.futures import ThreadPoolExecutor import requests AUDIT_TTL = 30 * 86400 # re-vérification d'une URL après 30 jours MIN_WIDTH, MIN_HEIGHT = 250, 160 # sous ces dimensions : miniature inutilisable MIN_BYTES = 3_000 # fichier suspicieusement petit (icône/placeholder) TIMEOUT = 10 UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126 Safari/537.36 ImmoKaBot/1.0") # motifs de placeholders/logos des portails (jamais une photo de propriété). # ⚠ default/defaut/logo sont ANCRÉS sur le nom de fichier : un segment de # chemin comme Cloudinary « t_default_size/ » n'est PAS un placeholder # (bug payé : toutes les galeries Ubee vidées par « default[-_.] »). _PLACEHOLDER_RE = re.compile( r"placeholder|no[-_]?photo|nophoto|photo[-_]?a[-_]?venir|coming[-_]?soon|" r"missing|awaiting|shadow_listing|image[-_]?indisponible|no[-_]?image|" r"/(?:default|defaut|logo)[^/]*\.(?:jpe?g|png|webp|gif|svg)(?:\?|$)", re.I) _SIZE_VARIANT_RE = re.compile(r"-(?:sm|md|lg|xl|thumb|small|medium|large)(?=\.\w+$)") # handlers dynamiques : la photo est identifiée par la QUERYSTRING (propId/seq, # id…), pas par le chemin — ex. yoamo.immo/ALSPicture.axd?propId=…&seq=N, # mediaserver.centris.ca/media.ashx?id=… On ne retire que les params de taille. _DYNAMIC_EXT = (".axd", ".ashx", ".php", ".aspx", ".cfm") _SIZE_PARAM_RE = re.compile(r"&(?:w|h|width|height|size|sm|scale|quality)=[^&]*", re.I) def _canon(url: str) -> str: """Clé de déduplication : ignore la variante de taille et la querystring de redimensionnement pour attraper la même photo en deux formats.""" path, _, query = url.partition("?") if query and path.lower().endswith(_DYNAMIC_EXT): params = _SIZE_PARAM_RE.sub("", "&" + query.replace("&", "&")).lstrip("&") return f"{path}?{params}".lower() return _SIZE_VARIANT_RE.sub("", path).lower() def clean_gallery(urls: list[str]) -> list[str]: """Nettoyage statique d'une galerie : URLs http(s) uniquement, placeholders retirés, doublons (même photo, autre taille) dédupliqués, ordre préservé.""" out: list[str] = [] seen: set[str] = set() for u in urls or []: if not isinstance(u, str): continue u = u.strip() if not u.lower().startswith(("http://", "https://")): continue if _PLACEHOLDER_RE.search(u): continue key = _canon(u) if key in seen: continue seen.add(key) out.append(u) return out # --------------------------------------------------------------------------- # Décodage des dimensions depuis les premiers octets (sans télécharger tout) # --------------------------------------------------------------------------- def image_size(data: bytes) -> tuple[int, int] | None: """(largeur, hauteur) depuis l'en-tête PNG/GIF/WebP/JPEG, None si indécodable.""" if len(data) < 26: return None if data[:8] == b"\x89PNG\r\n\x1a\n": w, h = struct.unpack(">II", data[16:24]) return w, h if data[:6] in (b"GIF87a", b"GIF89a"): w, h = struct.unpack("= 30: w, h = struct.unpack("= 25: bits = struct.unpack("> 14) & 0x3FFF) + 1 if data[12:16] == b"VP8X" and len(data) >= 30: w = int.from_bytes(data[24:27], "little") + 1 h = int.from_bytes(data[27:30], "little") + 1 return w, h if data[:2] == b"\xff\xd8": # JPEG : chercher le SOF i = 2 while i + 9 < len(data): if data[i] != 0xFF: i += 1 continue marker = data[i + 1] if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF): h, w = struct.unpack(">HH", data[i + 5:i + 9]) return w, h seg_len = struct.unpack(">H", data[i + 2:i + 4])[0] i += 2 + seg_len return None def check_url(url: str) -> dict: """Vérifie une URL d'image : {ok, status, width, height, bytes, reason}. ⚠ Verdict « morte » UNIQUEMENT sur preuve solide (404/410) : un 403/429 est presque toujours du rate-limiting ou de l'anti-hotlink du CDN (bug payé : ~6 000 galeries DuProprio retirées à tort). En cas de doute on garde l'image — le repli onError du frontend couvre les rares vraies mortes.""" try: r = requests.get(url, headers={"User-Agent": UA, "Range": "bytes=0-65535"}, timeout=TIMEOUT, stream=True) status = r.status_code if status in (404, 410): return {"ok": 0, "status": status, "reason": "http"} if status >= 400: return {"ok": 1, "status": status, "reason": "non_verifiable"} data = next(r.iter_content(65536), b"") or b"" r.close() total = int((r.headers.get("Content-Range") or "/0").split("/")[-1] or 0) \ or int(r.headers.get("Content-Length") or 0) or len(data) size = image_size(data) if size is None: ctype = (r.headers.get("Content-Type") or "").lower() if "image" not in ctype: return {"ok": 0, "status": status, "bytes": total, "reason": "format"} # image valide mais en-tête non décodé (format exotique) : on tolère return {"ok": 1, "status": status, "bytes": total} w, h = size if w < MIN_WIDTH or h < MIN_HEIGHT: return {"ok": 0, "status": status, "width": w, "height": h, "bytes": total, "reason": "minuscule"} if total and total < MIN_BYTES: return {"ok": 0, "status": status, "width": w, "height": h, "bytes": total, "reason": "poids_suspect"} return {"ok": 1, "status": status, "width": w, "height": h, "bytes": total} except requests.RequestException: # réseau/timeout : non concluant — ne jamais retirer sur un doute return {"ok": 1, "status": 0, "reason": "non_verifiable"} # --------------------------------------------------------------------------- # Audit budgété des couvertures (+ galeries courtes) avec cache BD # --------------------------------------------------------------------------- def _ensure_table(con: sqlite3.Connection) -> None: con.execute("""CREATE TABLE IF NOT EXISTS image_audit ( url TEXT PRIMARY KEY, ok INTEGER, status INTEGER, width INTEGER, height INTEGER, bytes INTEGER, reason TEXT, checked_at REAL )""") con.commit() def _cached(con: sqlite3.Connection, url: str) -> dict | None: r = con.execute("SELECT ok, reason, checked_at FROM image_audit WHERE url=?", (url,)).fetchone() if r and time.time() - (r["checked_at"] or 0) < AUDIT_TTL: return {"ok": r["ok"], "reason": r["reason"]} return None def _store(con: sqlite3.Connection, url: str, res: dict) -> None: con.execute( "INSERT INTO image_audit (url, ok, status, width, height, bytes, reason," " checked_at) VALUES (?,?,?,?,?,?,?,?)" " ON CONFLICT(url) DO UPDATE SET ok=excluded.ok, status=excluded.status," " width=excluded.width, height=excluded.height, bytes=excluded.bytes," " reason=excluded.reason, checked_at=excluded.checked_at", (url, res.get("ok"), res.get("status"), res.get("width"), res.get("height"), res.get("bytes"), res.get("reason"), time.time())) def audit_listing(con: sqlite3.Connection, uid: str, images: list[str], details: dict, pool: ThreadPoolExecutor) -> tuple[list[str], dict, int]: """Vérifie la couverture (et remonte la 1re image valide en tête). Vérifie au plus les 4 premières images ; les mortes sont retirées de la galerie. Retourne (nouvelle galerie, details, nb de vérifications réseau).""" checked = 0 good_idx = None dead: set[int] = set() for i, url in enumerate(images[:4]): res = _cached(con, url) if res is None: res = check_url(url) _store(con, url, res) checked += 1 if res.get("ok"): good_idx = i break dead.add(i) new_images = [u for i, u in enumerate(images) if i not in dead] details = dict(details) if good_idx is None and images: # aucune image valide parmi les premières : re-vérification demandée, # le frontend applique l'image de secours par type de bien details["needs_image_review"] = True else: details.pop("needs_image_review", None) return new_images, details, checked def run_batch(limit: int = 2000, workers: int = 8) -> dict: """Audit réseau budgété : les annonces publiées jamais auditées d'abord. Appelé après chaque synchronisation (ingest.watch) ; relancer avec un gros `limit` pour un rattrapage complet. Idempotent grâce au cache par URL.""" from . import db con = db.connect() _ensure_table(con) rows = con.execute( "SELECT uid, images, details FROM listings" " WHERE active=1 AND dup_hidden=0 AND images IS NOT NULL AND images!='[]'" " AND json_extract(COALESCE(details,'{}'), '$.img_audited') IS NULL" " LIMIT ?", (limit,)).fetchall() checked = removed = flagged = 0 def _work(row): images = json.loads(row["images"] or "[]") details = json.loads(row["details"] or "{}") # les URLs sont vérifiées séquentiellement par annonce ; le parallélisme # est au niveau des annonces (une connexion BD par worker serait fragile, # donc le réseau seul est parallèle : cache lu/écrit dans le fil principal) return row["uid"], images, details with ThreadPoolExecutor(max_workers=workers) as pool: futures = [] for row in rows: uid, images, details = _work(row) futures.append((uid, images, details)) # traitement principal (cache BD dans ce fil, réseau via check_url — # parallélisé par lots d'URLs de couverture inconnues) unknown = [] for uid, images, details in futures: for u in images[:4]: if _cached(con, u) is None: unknown.append(u) unknown = list(dict.fromkeys(unknown)) # commits par tranches : ne JAMAIS tenir le verrou d'écriture pendant # tout l'audit (des dizaines de minutes) — les syncs/le web écrivent aussi for url, res in zip(unknown, pool.map(check_url, unknown)): _store(con, url, res) checked += 1 if checked % 400 == 0: con.commit() con.commit() done = 0 for uid, images, details in futures: new_images, new_details, _ = audit_listing(con, uid, images, details, pool) new_details["img_audited"] = int(time.time()) if new_details.get("needs_image_review"): flagged += 1 removed += len(images) - len(new_images) con.execute("UPDATE listings SET images=?, details=? WHERE uid=?", (json.dumps(new_images, ensure_ascii=False), json.dumps(new_details, ensure_ascii=False), uid)) done += 1 if done % 400 == 0: con.commit() con.commit() con.close() return {"annonces": len(rows), "urls_verifiees": checked, "images_retirees": removed, "sans_image_valide": flagged}