SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
9.9 KB · 245 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: 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 RentKaImgBot/1.0 "46       "(+https://www.rent-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"[rent-ka] imgcheck: {stats}")244    return stats245