#!/usr/bin/env node // ----------------------------------------------------------------------------- // Lou-Ka — kit Figma · shots.mjs // Capture le site live www.lou-ka.com (desktop 1440 + mobile 390) et rapatrie // quelques photos d'annonces réelles pour les maquettes. // · references/fold/*.jpg : au-dessus du pli (embarqué dans le plugin) // · references/full/*.jpg : pleine page (à glisser dans Figma au besoin) // · references/photos/*.jpg : photos d'annonces réduites à 640 px // Usage : node shots.mjs (Playwright de ~/ka-screenshots réutilisé) // ----------------------------------------------------------------------------- import { createRequire } from "node:module"; import { mkdirSync, writeFileSync } from "node:fs"; import { execSync } from "node:child_process"; import path from "node:path"; const require = createRequire(path.join(process.env.HOME, "ka-screenshots/node_modules/")); const { chromium } = require("playwright"); const ROOT = new URL(".", import.meta.url).pathname; const REF = path.join(ROOT, "references"); for (const d of ["fold", "full", "photos"]) mkdirSync(path.join(REF, d), { recursive: true }); const BASE = "https://www.lou-ka.com"; // ---- 1. Choisir une vraie annonce riche (photos, prix, secteur) ------------- async function pickListing() { const r = await fetch(`${BASE}/api/search?city=Montr%C3%A9al&page_size=60&sort=recent`); const d = await r.json(); const ok = d.listings.filter( (l) => (l.images?.length ?? 0) >= 6 && l.price >= 1100 && l.price <= 2600 && l.sector && l.unit_type, ); ok.sort((a, b) => (b.fv_verdict ? 1 : 0) - (a.fv_verdict ? 1 : 0) || b.images.length - a.images.length); return { pick: ok[0] ?? d.listings[0], pool: d.listings }; } // ---- 2. Photos d'annonces (pour les cartes de la maquette) ------------------ async function grabPhotos(pool, pick) { const urls = []; for (const im of (pick.images ?? []).slice(0, 5)) urls.push(im); for (const l of pool) { if (urls.length >= 12) break; if (l.uid === pick.uid) continue; if (l.images?.[0] && l.price >= 900) urls.push(l.images[0]); } let i = 0; const kept = []; for (const u of urls) { try { const res = await fetch(u, { headers: { "User-Agent": "Mozilla/5.0" } }); if (!res.ok) continue; const buf = Buffer.from(await res.arrayBuffer()); if (buf.length < 8000) continue; const raw = path.join(REF, "photos", `raw-${i}.img`); writeFileSync(raw, buf); const out = path.join(REF, "photos", `photo-${String(i + 1).padStart(2, "0")}.jpg`); execSync(`sips -s format jpeg -s formatOptions 78 -Z 640 "${raw}" --out "${out}" >/dev/null 2>&1 && rm -f "${raw}"`); kept.push(out); i++; } catch (e) { /* on passe */ } } return kept; } // ---- 3. Captures d'écran -------------------------------------------------- const HIDE_CSS = ` .cookie-banner, .ka-agent, #ka-agent, .ka-agent-btn, [class*="agent-fab"] { display: none !important; } .ticker-track { animation: none !important; } *, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; } `; async function shoot(browser, { name, url, w, h, mobile }) { const ctx = await browser.newContext({ viewport: { width: w, height: h }, deviceScaleFactor: 2, isMobile: !!mobile, hasTouch: !!mobile, locale: "fr-CA", userAgent: mobile ? "Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1" : undefined, }); const page = await ctx.newPage(); await page.addInitScript(() => { try { localStorage.setItem("louka_consent", JSON.stringify({ analytics: false, ts: Date.now() })); } catch {} }); await page.goto(url, { waitUntil: "networkidle", timeout: 60000 }).catch(() => {}); await page.addStyleTag({ content: HIDE_CSS }); await page.waitForTimeout(1800); // fermer un éventuel bandeau de consentement resté visible for (const sel of ["button:has-text('Tout accepter')", "button:has-text('Accepter')", "button:has-text('Refuser')"]) { const b = page.locator(sel).first(); if (await b.count()) { await b.click({ timeout: 1000 }).catch(() => {}); break; } } await page.evaluate(() => window.scrollTo(0, 0)); await page.waitForTimeout(400); const fold = path.join(REF, "fold", `${name}.jpg`); await page.screenshot({ path: fold, type: "jpeg", quality: 80, fullPage: false }); const full = path.join(REF, "full", `${name}.jpg`); await page.screenshot({ path: full, type: "jpeg", quality: 80, fullPage: true }).catch(() => {}); await ctx.close(); return fold; } const { pick, pool } = await pickListing(); console.log("Annonce de référence :", pick.uid, "—", pick.title, pick.price, pick.city); const photos = await grabPhotos(pool, pick); console.log("Photos récupérées :", photos.length); const pages = [ ["accueil", "/"], ["logement", `/logement/${encodeURIComponent(pick.uid)}`], ["stats", "/stats"], ["villes", "/villes"], ["court-terme", "/court-terme"], ]; const browser = await chromium.launch(); const done = []; for (const [name, p] of pages) { done.push(await shoot(browser, { name: `desktop-${name}`, url: BASE + p, w: 1440, h: 900 })); done.push(await shoot(browser, { name: `mobile-${name}`, url: BASE + p, w: 390, h: 844, mobile: true })); console.log("✓", name); } await browser.close(); writeFileSync( path.join(REF, "manifest.json"), JSON.stringify( { capturedAt: new Date().toISOString(), listing: { uid: pick.uid, title: pick.title, address: pick.address, sector: pick.sector, city: pick.city, unit_type: pick.unit_type, price: pick.price, price_label: pick.price_label, source: pick.source, availability: pick.availability, amenities: pick.amenities, bedrooms: pick.bedrooms, bathrooms: pick.bathrooms, fv_verdict: pick.fv_verdict, fv_deviation: pick.fv_deviation, ks_global: pick.ks_global, nImages: pick.images?.length }, photos: photos.map((p) => path.basename(p)), shots: done.map((p) => path.basename(p)), }, null, 2, ), ); console.log("Terminé →", REF);