// Sources (in priority order): // 1. Investing.com Fed Rate Monitor — USD seul (seule CB pour laquelle Investing.com // publie cet outil ; les autres slugs *-rate-monitor n'existent pas → 404 attendus) // 2. InvestingLive fallback — articles Giuseppe Dellamotta, hebdo, toutes CBs // // (CME FedWatch retiré : endpoint derrière le WAF Akamai de cmegroup.com, 403/404 // systématique, et de toute façon redondant avec la source 1 pour l'USD.) import { writeFileSync, mkdirSync, readFileSync } from "fs"; // ── Helpers ─────────────────────────────────────────────────────────────────── const MONTHS = { Jan:"01",Feb:"02",Mar:"03",Apr:"04",May:"05",Jun:"06", Jul:"07",Aug:"08",Sep:"09",Oct:"10",Nov:"11",Dec:"12" }; function normalizeDate(raw) { if (!raw) return null; if (/^\d{8}$/.test(raw)) return `${raw.slice(0,4)}-${raw.slice(4,6)}-${raw.slice(6,8)}`; if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw; const m = raw.match(/([A-Z][a-z]{2})\s+(\d{1,2}),?\s+(\d{4})/); if (m) return `${m[3]}-${MONTHS[m[1]]??'01'}-${m[2].padStart(2,'0')}`; return null; } const CHROME_HEADERS = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Cache-Control": "no-cache", "Pragma": "no-cache", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", "Upgrade-Insecure-Requests": "1", }; // ── 1. Investing.com Rate Monitors (Fed only — voir note en tête de fichier) ── const IC_SLUGS = { USD: "fed-rate-monitor", EUR: "ecb-rate-monitor", GBP: "boe-rate-monitor", JPY: "boj-rate-monitor", CAD: "boc-rate-monitor", AUD: "rba-rate-monitor", NZD: "rbnz-rate-monitor", CHF: "snb-rate-monitor", }; // Taux directeurs actuels — lus depuis data/rate_decisions.json (source unique, // maintenue manuellement "après chaque décision") pour éviter qu'une copie // codée en dur ici ne dérive silencieusement de la réalité au fil des mois. const rateDecisions = JSON.parse(readFileSync("data/rate_decisions.json", "utf8"))[0]?.decisions ?? {}; const FALLBACK_RATES = Object.fromEntries( Object.entries(rateDecisions).map(([ccy, d]) => [ccy, d.current]) ); // ── SNB officiel (CHF) ───────────────────────────────────────────────────────── // snb.ch publie ses décisions sous /press-releases-restricted/pre_YYYYMMDD. // On découvre la plus récente depuis la page listing (1er lien du pattern), // puis on lit le taux dans le titre : "leaves ... unchanged at X%" / "lowers/raises ... to X%". async function fetchSnbRate() { try { const listRes = await fetch("https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions", { headers: CHROME_HEADERS }); if (!listRes.ok) return null; const listHtml = await listRes.text(); const linkMatch = listHtml.match(/href="(\/en\/publications\/communication\/press-releases-restricted\/pre_(\d{8})[^"]*)"/); if (!linkMatch) return null; const dateIso = `${linkMatch[2].slice(0,4)}-${linkMatch[2].slice(4,6)}-${linkMatch[2].slice(6,8)}`; const prRes = await fetch(`https://www.snb.ch${linkMatch[1]}`, { headers: CHROME_HEADERS }); if (!prRes.ok) return null; const prHtml = await prRes.text(); const rateMatch = prHtml.match(/SNB policy rate\s+(?:unchanged at|to)\s+(-?[\d.]+)%/i); if (!rateMatch) return null; console.log(`[SNB] official: ${rateMatch[1]}% (decision ${dateIso})`); return parseFloat(rateMatch[1]); } catch (e) { console.error(`[SNB] error: ${e.message}`); return null; } } // ── Fed officiel (USD) ────────────────────────────────────────────────────────── // federalreserve.gov/monetarypolicy/fomccalendars.htm liste, pour chaque réunion, // un lien de statement /newsevents/pressreleases/monetary(YYYYMMDD)a.htm. On prend // la réunion passée la plus récente, puis on lit la fourchette officielle dans le // texte : "target range for the federal funds rate at/to X to Y percent" (X/Y en // fractions type "3-1/2"). On retourne le haut de fourchette (convention "upper // bound" déjà utilisée ailleurs dans ce repo pour l'USD). function parseFedFraction(s) { const m = s.match(/^(\d+)(?:-(\d+)\/(\d+))?$/); if (!m) return NaN; const whole = parseFloat(m[1]); return m[2] ? whole + parseFloat(m[2]) / parseFloat(m[3]) : whole; } async function fetchFedRate() { try { const calRes = await fetch("https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm", { headers: CHROME_HEADERS }); if (!calRes.ok) return null; const calHtml = await calRes.text(); const todayCompact = new Date().toISOString().slice(0,10).replace(/-/g,""); const dates = new Set(); for (const m of calHtml.matchAll(/\/newsevents\/pressreleases\/monetary(\d{8})a\.htm/g)) dates.add(m[1]); const latestPast = [...dates].filter(d => d <= todayCompact).sort().reverse()[0]; if (!latestPast) return null; const dateIso = `${latestPast.slice(0,4)}-${latestPast.slice(4,6)}-${latestPast.slice(6,8)}`; const stRes = await fetch(`https://www.federalreserve.gov/newsevents/pressreleases/monetary${latestPast}a.htm`, { headers: CHROME_HEADERS }); if (!stRes.ok) return null; const stHtml = await stRes.text(); const rateMatch = stHtml.match(/target range for the federal funds rate[\s\S]{0,60}?(\d+(?:-\d\/\d)?)\s*to\s*(\d+(?:-\d\/\d)?)\s*percent/i); if (!rateMatch) return null; const upper = parseFedFraction(rateMatch[2]); if (isNaN(upper)) return null; console.log(`[FED] official: ${rateMatch[1]}-${rateMatch[2]}% → upper=${upper}% (decision ${dateIso})`); return upper; } catch (e) { console.error(`[FED] error: ${e.message}`); return null; } } async function fetchInvestingCom(ccy) { const slug = IC_SLUGS[ccy]; const url = `https://www.investing.com/central-banks/${slug}`; try { const res = await fetch(url, { headers: { ...CHROME_HEADERS, "Referer": "https://www.investing.com/central-banks/" } }); console.log(`[IC/${ccy}] ${res.status}`); if (!res.ok) return null; const html = await res.text(); console.log(`[IC/${ccy}] html ${html.length} chars`); if (/just a moment|cf-browser-verification|enable javascript/i.test(html)) { console.error(`[IC/${ccy}] Cloudflare challenge`); return null; } return parseInvestingComHtml(ccy, html); } catch (e) { console.error(`[IC/${ccy}] error: ${e.message}`); return null; } } function parseInvestingComHtml(ccy, html) { // ── Attempt A: __NEXT_DATA__ (Next.js SSR) ──────────────────────────────── const ndMatch = html.match(/