TypeScript 90.1%
JavaScript 3.2%
Python 3.2%
CSS 1.8%
HTML 1.8%
1// UQO Éval — laboratoire pédagogique d'évaluation immobilière2/**3 * Pipeline d'images d'annonce (§91-93, 160-163, 186-190) — SERVEUR :4 * téléchargement borné, type par octets magiques, dimensions par lecture des5 * en-têtes (sans dépendance), hash sha256, dédoublonnage, retrait des6 * miniatures, présélection ≤ MAX, manifeste transmis au modèle, cache disque7 * temporaire purgé selon LISTING_IMAGE_RETENTION_DAYS. Une photo qui échoue8 * n'arrête pas l'analyse. Les octets ne sont jamais redistribués.9 */10import { createHash } from "crypto";11import fs from "fs";12import path from "path";1314export const MAX_IMAGE_BYTES = 4.5 * 1024 * 1024;15export const MIN_IMAGE_PX = 400;16export const FETCH_TIMEOUT_MS = 20_000;1718export type ImageMediaType = "image/jpeg" | "image/png" | "image/webp" | "image/gif";1920export interface PreparedImage {21 id: string; // photo_01…22 position: number; // position d'origine dans l'annonce (1-based)23 sourceUrl: string;24 hash: string;25 width: number | null;26 height: number | null;27 bytes: number;28 mediaType: ImageMediaType;29 roomHint: string;30 qualityScore: number;31 data: Buffer;32}3334export interface ImagePipelineResult {35 selected: PreparedImage[];36 all: Omit<PreparedImage, "data">[];37 failed: { url: string; error: string }[];38 duplicatesRemoved: number;39 thumbnailsRemoved: number;40}4142export function cacheDir(uid: string): string {43 return path.join(process.cwd(), "tmp", "listing-images", uid.replace(/[^a-zA-Z0-9_:-]/g, "_"));44}4546/* ------------------------------------------------------------ détection */4748export function sniffMediaType(buf: Buffer): ImageMediaType | null {49 if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";50 if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";51 if (buf.length >= 12 && buf.subarray(0, 4).toString("ascii") === "RIFF" && buf.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";52 if (buf.length >= 6 && buf.subarray(0, 3).toString("ascii") === "GIF") return "image/gif";53 return null;54}5556/** Dimensions lues dans les en-têtes JPEG (SOF), PNG (IHDR), WebP (VP8/VP8L/VP8X), GIF. */57export function readDimensions(buf: Buffer, type: ImageMediaType): { width: number; height: number } | null {58 try {59 if (type === "image/png" && buf.length >= 24) return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };60 if (type === "image/gif" && buf.length >= 10) return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };61 if (type === "image/webp" && buf.length >= 30) {62 const chunk = buf.subarray(12, 16).toString("ascii");63 if (chunk === "VP8 ") return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };64 if (chunk === "VP8L") { const b0 = buf[21], b1 = buf[22], b2 = buf[23], b3 = buf[24]; return { width: 1 + (((b1 & 0x3f) << 8) | b0), height: 1 + (((b3 & 0xf) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)) }; }65 if (chunk === "VP8X") return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };66 }67 if (type === "image/jpeg") {68 let i = 2;69 while (i + 9 < buf.length) {70 if (buf[i] !== 0xff) { i++; continue; }71 const marker = buf[i + 1];72 if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }73 const len = buf.readUInt16BE(i + 2);74 if ((marker >= 0xc0 && marker <= 0xcf) && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };75 i += 2 + len;76 }77 }78 } catch { /* en-tête tronqué */ }79 return null;80}8182export const sha256 = (b: Buffer | string): string => createHash("sha256").update(b).digest("hex");8384/* --------------------------------------------------------------- fetch */8586export async function fetchImage(url: string, timeoutMs = FETCH_TIMEOUT_MS): Promise<{ data: Buffer; mediaType: ImageMediaType }> {87 const ctrl = new AbortController();88 const t = setTimeout(() => ctrl.abort(), timeoutMs);89 try {90 const res = await fetch(url, { signal: ctrl.signal, headers: { "User-Agent": "UQO-Eval/1.0 (analyse pédagogique; +https://www.uqo-eval.app)", Accept: "image/*" }, redirect: "follow" });91 if (!res.ok) throw new Error(`HTTP ${res.status}`);92 const len = Number(res.headers.get("content-length") ?? 0);93 if (len > MAX_IMAGE_BYTES) throw new Error(`trop volumineuse (${len} octets)`);94 const ab = await res.arrayBuffer();95 if (ab.byteLength > MAX_IMAGE_BYTES) throw new Error(`trop volumineuse (${ab.byteLength} octets)`);96 const data = Buffer.from(ab);97 const mediaType = sniffMediaType(data);98 if (!mediaType) throw new Error("format d'image non reconnu");99 return { data, mediaType };100 } finally {101 clearTimeout(t);102 }103}104105/* ----------------------------------------------------------- heuristiques */106107/** Indice de pièce léger à partir de l'URL et de la position (les photos 1-3 sont presque toujours extérieures). */108export function roomHint(url: string, position: number, total: number): string {109 const u = url.toLowerCase();110 const has = (...k: string[]) => k.some((x) => u.includes(x));111 if (has("facade", "exterieur", "exterior", "front", "ext_")) return "exterior";112 if (has("cuisine", "kitchen")) return "kitchen";113 if (has("salle-de-bain", "bathroom", "sdb", "bain")) return "bathroom";114 if (has("chambre", "bedroom")) return "bedroom";115 if (has("salon", "living", "sejour")) return "living";116 if (has("sous-sol", "basement")) return "basement";117 if (has("garage")) return "garage";118 if (has("cour", "backyard", "yard", "terrasse", "deck", "piscine", "pool")) return "exterior_rear";119 if (has("plan", "floorplan")) return "floorplan";120 if (position <= 2) return "exterior";121 if (position === total && total > 8) return "exterior_rear";122 return "unknown";123}124125/** Clé de regroupement : même photo servie en plusieurs tailles (-sm/-md/-lg, w=, /thumb/). */126export function sizeAgnosticKey(url: string): string {127 return url128 .replace(/[?#].*$/, "")129 .replace(/-(xs|sm|md|lg|xl|xxl|thumb|thumbnail|small|medium|large|original)(?=\.[a-z]{3,4}$)/i, "")130 .replace(/\/(thumb|thumbs|thumbnail|small|medium|large)\//i, "/")131 .replace(/_(\d{2,4})x(\d{2,4})(?=\.[a-z]{3,4}$)/i, "")132 .toLowerCase();133}134135/** Présélection : garde les extérieurs et une couverture régulière du reste. */136export function selectImages<T extends { roomHint: string; position: number; qualityScore: number }>(imgs: T[], max: number): T[] {137 if (imgs.length <= max) return imgs;138 const ext = imgs.filter((i) => i.roomHint.startsWith("exterior") || i.roomHint === "floorplan").slice(0, Math.min(6, Math.floor(max / 4)));139 const rest = imgs.filter((i) => !ext.includes(i));140 const slots = max - ext.length;141 const picked: T[] = [];142 for (let k = 0; k < slots; k++) picked.push(rest[Math.floor((k * rest.length) / slots)]);143 return [...ext, ...picked].sort((a, b) => a.position - b.position);144}145146/* ------------------------------------------------------------- pipeline */147148export async function prepareListingImages(uid: string, urls: string[], max: number, concurrency = 6): Promise<ImagePipelineResult> {149 const dir = cacheDir(uid);150 fs.mkdirSync(dir, { recursive: true });151 const failed: { url: string; error: string }[] = [];152 const fetched: (PreparedImage & { key: string })[] = [];153 let idx = 0;154 const worker = async () => {155 while (idx < urls.length) {156 const pos = idx++;157 const url = urls[pos];158 try {159 const { data, mediaType } = await fetchImage(url);160 const hash = sha256(data);161 const dims = readDimensions(data, mediaType);162 const px = dims ? Math.min(dims.width, dims.height) : null;163 const ext = mediaType.split("/")[1];164 const cached = path.join(dir, `${hash}.${ext}`);165 if (!fs.existsSync(cached)) fs.writeFileSync(cached, data);166 fetched.push({ id: "", position: pos + 1, sourceUrl: url, hash, width: dims?.width ?? null, height: dims?.height ?? null, bytes: data.length, mediaType, roomHint: roomHint(url, pos + 1, urls.length), qualityScore: px ? Math.min(1, px / 1200) : 0.5, data, key: sizeAgnosticKey(url) });167 } catch (e) {168 failed.push({ url, error: (e as Error).message });169 }170 }171 };172 await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, urls.length)) }, worker));173 fetched.sort((a, b) => a.position - b.position);174 // dédoublonnage : hash identique, puis même clé d'URL (on garde la plus grande)175 const byHash = new Map<string, PreparedImage & { key: string }>();176 let duplicates = 0;177 for (const f of fetched) {178 if (byHash.has(f.hash)) { duplicates++; continue; }179 byHash.set(f.hash, f);180 }181 const byKey = new Map<string, PreparedImage & { key: string }>();182 for (const f of byHash.values()) {183 const cur = byKey.get(f.key);184 if (!cur) byKey.set(f.key, f);185 else { duplicates++; if ((f.width ?? 0) * (f.height ?? 0) > (cur.width ?? 0) * (cur.height ?? 0)) byKey.set(f.key, f); }186 }187 let unique = [...byKey.values()].sort((a, b) => a.position - b.position);188 const before = unique.length;189 unique = unique.filter((i) => i.width == null || Math.min(i.width, i.height ?? i.width) >= MIN_IMAGE_PX);190 const thumbs = before - unique.length;191 const selected = selectImages(unique, max).map((img, i) => ({ ...img, id: `photo_${String(i + 1).padStart(2, "0")}` }));192 const all = unique.map((img) => { const sel = selected.find((s) => s.hash === img.hash); const { data: _d, key: _k, ...rest } = img; void _d; void _k; return { ...rest, id: sel?.id ?? "" }; });193 return { selected: selected.map(({ key: _k, ...r }) => { void _k; return r; }), all, failed, duplicatesRemoved: duplicates, thumbnailsRemoved: thumbs };194}195196/** Purge du cache disque au-delà de la rétention (défaut 30 jours). */197export function purgeImageCache(retentionDays = Number(process.env.LISTING_IMAGE_RETENTION_DAYS ?? 30)): number {198 const root = path.join(process.cwd(), "tmp", "listing-images");199 if (!fs.existsSync(root)) return 0;200 const cutoff = Date.now() - retentionDays * 86400000;201 let n = 0;202 for (const d of fs.readdirSync(root)) {203 const p = path.join(root, d);204 try {205 const st = fs.statSync(p);206 if (st.isDirectory() && st.mtimeMs < cutoff) { fs.rmSync(p, { recursive: true, force: true }); n++; }207 } catch { /* ignore */ }208 }209 return n;210}211