SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%

fix(analyse IA): photos Facebook Marketplace en HTTP 400 — User-Agent ASCII (un octet accentué faisait rejeter la requête par *.fbcdn.net), Accept navigateur, repli 4xx avec en-têtes Chrome + Referer par hôte, message d'erreur avec l'hôte ; nav desktop : libellés courts + nowrap (8 entrées)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 17 days ago (Sep 8, 2026) parent fa3cb0c

2 changed files +34 −5

modified src/components/HeaderNav.tsx +3 −3
@@ -32,8 +32,8 @@ export default function HeaderNav() {
32 32 { href: "/a-vendre", label: fr ? "À vendre" : "For sale" },
33 33 { href: "/cout", label: fr ? "Coût" : "Cost" },
34 34 { href: "/carte", label: fr ? "Carte" : "Map" },
35 − { href: "/ka", label: fr ? "Ka · agent IA" : "Ka · AI agent" },
36 − { href: "/parc", label: fr ? "Parc immobilier" : "Portfolio" },
35 + { href: "/ka", label: fr ? "Ka · IA" : "Ka · AI" },
36 + { href: "/parc", label: fr ? "Parc" : "Portfolio" },
37 37 { href: "/stats", label: fr ? "Statistiques" : "Statistics" },
38 38 { href: "/methodologie", label: fr ? "Méthodologie" : "Methodology" },
39 39 ];
@@ -48,7 +48,7 @@ export default function HeaderNav() {
48 48 <Link
49 49 key={it.href}
50 50 href={it.href}
51 − className={`vp-mono inline-flex min-h-[38px] items-center border-b-2 pt-[2px] text-[11px] font-medium uppercase tracking-[0.1em] transition-colors ${
51 + className={`vp-mono inline-flex min-h-[38px] items-center whitespace-nowrap border-b-2 pt-[2px] text-[11px] font-medium uppercase tracking-[0.1em] transition-colors ${
52 52 isActive(it.href)
53 53 ? "border-accent text-ink"
54 54 : "border-transparent text-ink-2 hover:text-ink"
modified src/lib/cost/ai/images.ts +31 −2
@@ -83,12 +83,41 @@ export const sha256 = (b: Buffer | string): string => createHash("sha256").updat
83 83
84 84 /* --------------------------------------------------------------- fetch */
85 85
86 +/** En-têtes ASCII seulement : un octet accentué dans le User-Agent fait répondre
87 + * HTTP 400 aux CDN stricts (Facebook Marketplace `*.fbcdn.net`, 2026-09-08). */
88 +const UA_BOT = "Vrai-Prix/1.0 (building analysis; +https://www.vrai-prix.com)";
89 +const UA_BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
90 +const ACCEPT_IMG = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8";
91 +
92 +/** Referer plausible pour les CDN qui l'exigent (photos servies depuis un domaine distinct du site). */
93 +export function refererFor(url: string): string {
94 + try {
95 + const h = new URL(url).hostname;
96 + if (/\.fbcdn\.net$|facebook\.com$/.test(h)) return "https://www.facebook.com/";
97 + if (/ebayimg\.com$|kijiji/.test(h)) return "https://www.kijiji.ca/";
98 + if (/centris\.ca$/.test(h)) return "https://www.centris.ca/";
99 + if (/duproprio\.com$/.test(h)) return "https://duproprio.com/";
100 + const parts = h.split(".");
101 + return `https://www.${parts.slice(-2).join(".")}/`;
102 + } catch {
103 + return "https://www.vrai-prix.com/";
104 + }
105 +}
106 +
86 107 export async function fetchImage(url: string, timeoutMs = FETCH_TIMEOUT_MS): Promise<{ data: Buffer; mediaType: ImageMediaType }> {
87 108 const ctrl = new AbortController();
88 109 const t = setTimeout(() => ctrl.abort(), timeoutMs);
89 110 try {
90 − const res = await fetch(url, { signal: ctrl.signal, headers: { "User-Agent": "Vrai-Prix/1.0 (analyse du bâtiment; +https://www.vrai-prix.com)", Accept: "image/*" }, redirect: "follow" });
91 − if (!res.ok) throw new Error(`HTTP ${res.status}`);
111 + // 1er essai : identité déclarée (bot) ; 2e essai (4xx) : en-têtes de navigateur + Referer.
112 + let res = await fetch(url, { signal: ctrl.signal, headers: { "User-Agent": UA_BOT, Accept: ACCEPT_IMG }, redirect: "follow" });
113 + if (!res.ok && res.status >= 400 && res.status < 500) {
114 + res = await fetch(url, {
115 + signal: ctrl.signal,
116 + redirect: "follow",
117 + headers: { "User-Agent": UA_BROWSER, Accept: ACCEPT_IMG, "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8", Referer: refererFor(url), "Sec-Fetch-Dest": "image", "Sec-Fetch-Mode": "no-cors", "Sec-Fetch-Site": "cross-site" },
118 + });
119 + }
120 + if (!res.ok) throw new Error(`HTTP ${res.status} (${new URL(url).hostname})`);
92 121 const len = Number(res.headers.get("content-length") ?? 0);
93 122 if (len > MAX_IMAGE_BYTES) throw new Error(`trop volumineuse (${len} octets)`);
94 123 const ab = await res.arrayBuffer();
95 124