Python 67%
TypeScript 18.2%
CSS 14.4%
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("&", "&")).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