Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# imgcheck.py : contrôle qualité des images d'annonces5# - liens morts (404, timeout, non-image) -> retirés6# - images minuscules / pixellisées -> retirées7# - placeholders de la source (même image générique8# répétée sur une grande part des annonces) -> retirés9# - doublons dans une même galerie (hash contenu) -> dédupliqués10# Résultat par annonce : listings.images_ok (galerie nettoyée, ordre11# d'origine conservé) + listings.img_audit (JSON de traçabilité).12# Les vérifications par URL sont mises en cache dans image_checks.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import io17import json18import hashlib19import threading20import time21from concurrent.futures import ThreadPoolExecutor22from urllib.parse import urlsplit2324import requests2526# seuils de qualité27MIN_WIDTH = 25028MIN_HEIGHT = 18029MIN_BYTES = 6_000 # en dessous : icône / pixel de tracking30MAX_DOWNLOAD = 6_000_000 # on ne lit jamais plus de 6 Mo par image31TIMEOUT = 1232RECHECK_FAILED_DAYS = 7 # une image en échec est retentée après 7 jours3334# placeholder : même contenu présent sur >= PLACEHOLDER_MIN annonces distinctes,35# >= PLACEHOLDER_RATIO des annonces actives de la source ET réparti sur36# >= PLACEHOLDER_ADDRS adresses différentes. (Les photos d'immeuble ou de37# projet partagées entre les unités d'une MÊME adresse — piscine, gym,38# façade — sont légitimes : c'est la dispersion multi-adresses qui trahit39# le visuel générique « image non disponible ».)40PLACEHOLDER_MIN = 1241PLACEHOLDER_RATIO = 0.3042PLACEHOLDER_ADDRS = 84344_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "45 "(KHTML, like Gecko) Chrome/126 Safari/537.36 LouKaImgBot/1.0 "46 "(+https://www.lou-ka.com/bot; contact@spboucher.ai)")4748# politesse : au plus 3 téléchargements simultanés PAR HÔTE (24 threads sur un49# même CDN = refus/limitation, et des images vivantes classées mortes à tort)50_HOST_SEMS: dict[str, threading.Semaphore] = {}51_HOST_LOCK = threading.Lock()525354def _host_sem(url: str) -> threading.Semaphore:55 host = urlsplit(url).netloc56 with _HOST_LOCK:57 if host not in _HOST_SEMS:58 _HOST_SEMS[host] = threading.Semaphore(3)59 return _HOST_SEMS[host]606162def _check_url(url: str) -> dict:63 """Télécharge et inspecte une image. Retourne {status,width,height,bytes,sha1}."""64 out = {"url": url, "status": "dead", "width": None, "height": None,65 "bytes": None, "sha1": None}66 if not url or not url.startswith(("http://", "https://")):67 return out68 try:69 with _host_sem(url):70 resp = requests.get(url, timeout=TIMEOUT, stream=True,71 headers={"User-Agent": _UA,72 "Accept": "image/*,*/*;q=0.5"})73 if resp.status_code in (404, 410):74 return out # lien mort, définitif75 if resp.status_code >= 400:76 out["status"] = "error" # 403/429/5xx : indéterminé, à retenter77 return out78 buf = io.BytesIO()79 for chunk in resp.iter_content(65536):80 buf.write(chunk)81 if buf.tell() > MAX_DOWNLOAD:82 break83 data = buf.getvalue()84 except requests.RequestException:85 out["status"] = "error" # timeout/refus réseau : indéterminé86 return out87 out["bytes"] = len(data)88 out["sha1"] = hashlib.sha1(data).hexdigest()89 ctype = (resp.headers.get("content-type") or "").lower()90 if "svg" in ctype: # SVG : pas de dimensions bitmap — au moins pas mort91 out["status"] = "ok" if len(data) >= 500 else "small"92 return out93 try:94 from PIL import Image95 img = Image.open(io.BytesIO(data))96 img.load()97 out["width"], out["height"] = img.size98 except Exception:99 out["status"] = "dead" # corrompue / format non décodable100 return out101 if (len(data) < MIN_BYTES or out["width"] < MIN_WIDTH102 or out["height"] < MIN_HEIGHT):103 out["status"] = "small"104 else:105 out["status"] = "ok"106 return out107108109def _placeholder_hashes(con) -> set[str]:110 """Hashes « placeholder » : contenu identique répété massivement dans une111 même source (logo générique, « image non disponible »…)."""112 totals = {r["source"]: r["n"] for r in con.execute(113 "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source")}114 rows = con.execute(115 """SELECT l.source s, c.sha1 h, COUNT(DISTINCT l.uid) n,116 COUNT(DISTINCT COALESCE(NULLIF(l.address,''),117 NULLIF(l.sector,''), l.uid)) addrs118 FROM listings l, json_each(l.images) j119 JOIN image_checks c ON c.url = j.value120 WHERE l.active=1 AND c.sha1 IS NOT NULL AND c.status='ok'121 GROUP BY l.source, c.sha1 HAVING n >= ?""",122 (PLACEHOLDER_MIN,)).fetchall()123 out = set()124 for r in rows:125 if (r["n"] >= max(PLACEHOLDER_MIN, PLACEHOLDER_RATIO * totals.get(r["s"], 0))126 and r["addrs"] >= PLACEHOLDER_ADDRS):127 out.add(r["h"])128 return out129130131def run(limit_listings: int = 1500, workers: int = 24,132 source: str | None = None) -> dict:133 """Audite les images des annonces publiées sans audit (ou invalidées).134135 Incrémental : chaque appel traite au plus `limit_listings` annonces ;136 la boucle `watch` en fait avancer un lot à chaque cycle.137 """138 from . import db139 con = db.connect()140 now = time.time()141 sql = ("SELECT uid, source, images FROM listings"142 " WHERE active=1 AND published=1 AND img_audit IS NULL"143 " AND images IS NOT NULL AND images NOT IN ('[]','null')")144 args: list = []145 if source:146 sql += " AND source=?"147 args.append(source)148 sql += " ORDER BY last_seen DESC LIMIT ?"149 args.append(limit_listings)150 listings = con.execute(sql, args).fetchall()151 if not listings:152 con.close()153 return {"listings": 0}154155 # 1) URLs à vérifier (absentes du cache, ou en échec depuis > 7 jours)156 all_urls: list[str] = []157 per_listing: list[tuple[str, str, list[str]]] = []158 for r in listings:159 try:160 imgs = [u for u in json.loads(r["images"]) if isinstance(u, str)]161 except ValueError:162 imgs = []163 per_listing.append((r["uid"], r["source"], imgs))164 all_urls.extend(imgs)165 uniq = list(dict.fromkeys(all_urls))166 cached: dict[str, dict] = {}167 for i in range(0, len(uniq), 500):168 chunk = uniq[i:i + 500]169 q = ",".join("?" * len(chunk))170 for row in con.execute(171 f"SELECT * FROM image_checks WHERE url IN ({q})", chunk):172 d = dict(row)173 stale = (d["status"] != "ok"174 and now - (d["checked_at"] or 0) > RECHECK_FAILED_DAYS * 86400)175 if not stale:176 cached[d["url"]] = d177 todo = [u for u in uniq if u not in cached]178179 # 2) vérification parallèle PUIS écriture en rafale : jamais de transaction180 # d'écriture ouverte pendant les téléchargements (verrou SQLite sinon181 # tenu de longues secondes → « database is locked » pour les syncs)182 checked = 0183 if todo:184 with ThreadPoolExecutor(max_workers=workers) as pool:185 for i in range(0, len(todo), 400):186 chunk_res = list(pool.map(_check_url, todo[i:i + 400]))187 for res in chunk_res:188 cached[res["url"]] = res189 con.executemany(190 "INSERT OR REPLACE INTO image_checks"191 " (url, status, width, height, bytes, sha1, checked_at)"192 " VALUES (?,?,?,?,?,?,?)",193 [(r["url"], r["status"], r["width"], r["height"],194 r["bytes"], r["sha1"], now) for r in chunk_res])195 con.commit()196 checked += len(chunk_res)197198 placeholders = _placeholder_hashes(con)199200 # 3) galerie nettoyée par annonce201 stats = {"listings": len(per_listing), "urls_verifiees": checked,202 "dead": 0, "small": 0, "dup": 0, "placeholder": 0,203 "galeries_videes": 0}204 for uid, _src, imgs in per_listing:205 kept, seen_hashes = [], set()206 audit = {"n": len(imgs), "dead": 0, "small": 0, "dup": 0,207 "placeholder": 0, "ts": int(now)}208 for u in imgs:209 c = cached.get(u)210 if c is None: # non vérifiée (réseau) : bénéfice du doute211 kept.append(u)212 continue213 if c["status"] == "dead":214 audit["dead"] += 1215 continue216 if c["status"] == "error": # indéterminé : bénéfice du doute217 audit["uncertain"] = audit.get("uncertain", 0) + 1218 kept.append(u)219 continue220 if c["status"] == "small":221 audit["small"] += 1222 continue223 if c["sha1"] and c["sha1"] in placeholders:224 audit["placeholder"] += 1225 continue226 if c["sha1"] and c["sha1"] in seen_hashes:227 audit["dup"] += 1228 continue229 if c["sha1"]:230 seen_hashes.add(c["sha1"])231 kept.append(u)232 audit["kept"] = len(kept)233 for k in ("dead", "small", "dup", "placeholder"):234 stats[k] += audit[k]235 if not kept and imgs:236 stats["galeries_videes"] += 1237 con.execute(238 "UPDATE listings SET images_ok=?, img_audit=? WHERE uid=?",239 (json.dumps(kept, ensure_ascii=False),240 json.dumps(audit, ensure_ascii=False), uid))241 con.commit()242 con.close()243 print(f"[lou-ka] imgcheck: {stats}")244 return stats245