2026-07-08 16:46:05 +02:00
// lib/centralBankGovernance.ts
// Données de gouvernance des banques centrales : vote de la dernière réunion,
// lien vers le dernier rapport de politique monétaire (PDF), lien vers le site
// officiel, et pour la Fed le dot plot (Summary of Economic Projections).
//
// Sources (HTML public, sans clé API) :
// Fed : federalreserve.gov — statement (vote) + SEP "accessible version" (dot plot)
// BoE : bankofengland.co.uk — Monetary Policy Summary and Minutes (vote)
// Les autres banques (ECB, BoJ, SNB, BoC, RBA, RBNZ) exposent au minimum les
// liens statiques (site officiel + dernier rapport connu) ; scraping vote/PDF
// ajouté au fur et à mesure de ce qui est effectivement accessible sans
2026-07-08 23:46:31 +02:00
// contournement de WAF (RBA bloque tout fetch() non-navigateur sans header
// custom ; RBNZ bloque ses pages génériques mais pas son sitemap.xml ni les
// pages/PDF individuels qu'il référence — voir la section RBNZ plus bas).
2026-07-08 16:46:05 +02:00
import type { Currency } from "./types" ;
import rateDecisionsData from "@/data/rate_decisions.json" ;
2026-07-08 23:46:31 +02:00
import rbnzMpsData from "@/data/rbnz-mps.json" ;
2026-07-08 16:46:05 +02:00
import { fetchTECalendarForCountry } from "./tradingeconomics" ;
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" ;
async function fetchText ( url : string ) : Promise < string | null > {
try {
const res = await fetch ( url , {
headers : { "User-Agent" : UA , "Accept-Language" : "en-US,en;q=0.9" },
next : { revalidate : 6 * 3600 },
});
if ( ! res . ok ) return null ;
return await res . text ();
} catch { return null ; }
}
function htmlToText ( html : string ) : string {
return html
. replace ( /<script[\s\S]*?<\/script>/g , "" )
. replace ( /<style[\s\S]*?<\/style>/g , "" )
. replace ( /<[^>]+>/g , " " )
. replace ( / /g , " " )
. replace ( /–|–/g , "– " )
. replace ( /−|−/g , "-" )
. replace ( /‑/g , "-" )
. replace ( /&/g , "&" )
. replace ( /\s+/g , " " )
. trim ();
}
// ── Types publics ─────────────────────────────────────────────────────────────
export interface FedDotYear {
year : string ; // "2026" | "2027" | "2028" | "Longer run"
median : number | null ;
}
export interface FedDot {
year : string ;
rate : number ;
count : number ;
}
export interface FedSepHistoryPoint {
date : string ; // ISO de la réunion SEP
medianByYear : Record < string , number >;
}
export interface FedDotPlot {
asOfDate : string ;
years : string [];
medianByYear : Record < string , number >;
prevMedianByYear : Record < string , number > | null ;
prevLabel : string | null ; // ex. "March projection"
dots : FedDot [];
history : FedSepHistoryPoint []; // dernières réunions SEP, la plus récente en dernier
gdpMedian : Record < string , number > | null ;
pceMedian : Record < string , number > | null ;
sepHtmlUrl : string ;
sepPdfUrl : string ;
}
// Prévisions macro publiées par la BC elle-même (comment elle perçoit sa propre
// économie) : croissance PIB + inflation, par année. gdp/inflation sont indexés
// par année ("2026", "2027"…) → valeur en %, null si non disponible pour cette
// année précise. isProxy = valeur de substitution (ex. consensus Trading
// Economics) quand la BC elle-même n'est pas accessible en scraping (RBNZ).
export interface CBForecast {
asOf : string ; // date de publication de la prévision
years : string []; // ordre d'affichage
gdp : Record < string , number | null >;
inflation : Record < string , number | null >;
label : string ; // ex. "Eurosystem staff projections — juin 2026"
sourceUrl : string | null ;
isProxy? : boolean ;
proxyLabel? : string ;
}
export interface CBGovernance {
currency : Currency ;
bankName : string ;
countryLabel : string ;
officialSiteUrl : string ;
policyPageUrl : string ;
meetingDate : string | null ;
rateLevel : string | null ;
voteSummary : string | null ; // "12 – 0", "7 – 2", ou "Consensus (pas de vote publié)"
voteDetail : string | null ;
statementUrl : string | null ;
reportPdfUrl : string | null ;
reportLabel : string | null ;
dotPlot? : FedDotPlot ;
forecast? : CBForecast | null ;
fetchError? : string ;
}
// ── Métadonnées statiques (toujours disponibles, même si le scraping échoue) ──
export const CB_STATIC_INFO : Record < Currency , { bankName : string ; countryLabel : string ; officialSiteUrl : string ; policyPageUrl : string }> = {
USD : { bankName : "Federal Reserve (Fed)" , countryLabel : "États-Unis" , officialSiteUrl : "https://www.federalreserve.gov" , policyPageUrl : "https://www.federalreserve.gov/monetarypolicy.htm" },
EUR : { bankName : "Banque Centrale Européenne" , countryLabel : "Zone Euro" , officialSiteUrl : "https://www.ecb.europa.eu" , policyPageUrl : "https://www.ecb.europa.eu/press/govcdec/mopo/html/index.en.html" },
GBP : { bankName : "Bank of England (BoE)" , countryLabel : "Royaume-Uni" , officialSiteUrl : "https://www.bankofengland.co.uk" , policyPageUrl : "https://www.bankofengland.co.uk/monetary-policy" },
JPY : { bankName : "Bank of Japan (BoJ)" , countryLabel : "Japon" , officialSiteUrl : "https://www.boj.or.jp/en" , policyPageUrl : "https://www.boj.or.jp/en/mopo/index.htm" },
CHF : { bankName : "Swiss National Bank (SNB)" , countryLabel : "Suisse" , officialSiteUrl : "https://www.snb.ch" , policyPageUrl : "https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy" },
CAD : { bankName : "Bank of Canada (BoC)" , countryLabel : "Canada" , officialSiteUrl : "https://www.bankofcanada.ca" , policyPageUrl : "https://www.bankofcanada.ca/core-functions/monetary-policy/" },
AUD : { bankName : "Reserve Bank of Australia (RBA)" , countryLabel : "Australie" , officialSiteUrl : "https://www.rba.gov.au" , policyPageUrl : "https://www.rba.gov.au/monetary-policy/" },
NZD : { bankName : "Reserve Bank of New Zealand (RBNZ)" , countryLabel : "Nouvelle-Zélande" , officialSiteUrl : "https://www.rbnz.govt.nz" , policyPageUrl : "https://www.rbnz.govt.nz/monetary-policy" },
};
function staticFallback ( ccy : Currency , error : string ) : CBGovernance {
const info = CB_STATIC_INFO [ ccy ];
return {
currency : ccy , ... info ,
meetingDate : null , rateLevel : null , voteSummary : null , voteDetail : null ,
statementUrl : null , reportPdfUrl : null , reportLabel : null ,
fetchError : error ,
};
}
// ── Fed (USD) ─────────────────────────────────────────────────────────────────
function parseFedFraction ( s : string ) : number {
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 findLatestFedDates () : Promise < { statementDates : string []; sepDates : string [] } | null > {
const html = await fetchText ( "https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm" );
if ( ! html ) return null ;
const todayCompact = new Date (). toISOString (). slice ( 0 , 10 ). replace ( /-/g , "" );
const statementDates = Array . from ( new Set ( Array . from ( html . matchAll ( /\/newsevents\/pressreleases\/monetary(\d{8})a\.htm/g ), m => m [ 1 ])))
. filter ( d => d <= todayCompact ). sort ();
const sepDates = Array . from ( new Set ( Array . from ( html . matchAll ( /\/monetarypolicy\/fomcprojtabl[e]?(\d{8})\.htm/g ), m => m [ 1 ])))
. filter ( d => d <= todayCompact ). sort ();
return { statementDates , sepDates };
}
async function fetchFedStatement ( dateCompact : string ) : Promise < { rateLevel : string ; voteSummary : string ; voteDetail : string | null ; statementUrl : string } | null > {
const url = `https://www.federalreserve.gov/newsevents/pressreleases/monetary ${ dateCompact } a.htm` ;
const html = await fetchText ( url );
if ( ! html ) return null ;
const text = htmlToText ( html );
const rateMatch = text . match ( /target range for the federal funds rate[\s\S]{0,60}?(\d+(?:-\d\/\d)?)\s*to\s*(\d+(?:-\d\/\d)?)\s*percent/i );
const lower = rateMatch ? parseFedFraction ( rateMatch [ 1 ]) : NaN ;
const upper = rateMatch ? parseFedFraction ( rateMatch [ 2 ]) : NaN ;
const rateLevel = ! isNaN ( lower ) && ! isNaN ( upper ) ? ` ${ lower } – ${ upper } %` : null ;
const voteMatch = text . match ( /by a\s+(\d+)\s*[–—-]\s*(\d+)\s+vote/i );
const voteSummary = voteMatch ? ` ${ voteMatch [ 1 ] } – ${ voteMatch [ 2 ] } ` : null ;
const dissentMatch = text . match ( /Voting against[^:]*:\s*([^.]+)\./i );
const voteDetail = dissentMatch ? dissentMatch [ 1 ]. trim () : null ;
if ( ! rateLevel || ! voteSummary ) return null ;
return { rateLevel , voteSummary , voteDetail , statementUrl : url };
}
// Table 1 : médiane Fed funds rate (trimestre courant + trimestre précédent, si présent)
// + médianes GDP/PCE inflation (même table, mêmes 4 colonnes années) pour le bloc
// "prévisions" générique (comment le FOMC voit sa propre économie).
function parseSepTable1 ( text : string ) : {
years : string []; median : Record < string , number >; prevMedian : Record < string , number > | null ; prevLabel : string | null ;
gdpMedian : Record < string , number > | null ; pceMedian : Record < string , number > | null ;
} | null {
// Les 3 années couvertes glissent d'une publication SEP à l'autre (ex. déc. 2025
// couvre 2025-2028, juin 2026 couvre 2026-2028+LR) — on les lit depuis l'en-tête
// de Table 1 plutôt que de les figer, sinon l'historique se retrouve mal étiqueté.
const headerM = text . match ( /(\d{4})\s+(\d{4})\s+(\d{4})\s+Longer run/ );
if ( ! headerM ) return null ;
const years = [ headerM [ 1 ], headerM [ 2 ], headerM [ 3 ], "Longer run" ];
const start = text . indexOf ( "Federal funds rate" );
if ( start === - 1 ) return null ;
const noteIdx = text . indexOf ( "Note:" , start );
const block = noteIdx === - 1 ? text . slice ( start ) : text . slice ( start , noteIdx );
const medM = block . match ( /^Federal funds rate\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/ );
if ( ! medM ) return null ;
const prevM = block . match ( /(January|March|April|June|July|September|December)\s+projection\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i );
// "Change in real GDP X X X X ..." / "PCE inflation X X X X ..." — même format
// de ligne que "Federal funds rate", ailleurs dans Table 1 (pas dans `block`,
// qui commence après ces lignes, donc on cherche dans `text` en entier).
const toYearRecord = ( vals : [ string , string , string , string ] | null ) =>
vals ? { [ years [ 0 ]] : parseFloat ( vals [ 0 ]), [ years [ 1 ]] : parseFloat ( vals [ 1 ]), [ years [ 2 ]] : parseFloat ( vals [ 2 ]), [ years [ 3 ]] : parseFloat ( vals [ 3 ]) } : null ;
const gdpM = text . match ( /Change in real GDP\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/ );
const pceM = text . match ( /(?<!Core )PCE inflation\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/ );
return {
years ,
median : { [ years [ 0 ]] : parseFloat ( medM [ 1 ]), [ years [ 1 ]] : parseFloat ( medM [ 2 ]), [ years [ 2 ]] : parseFloat ( medM [ 3 ]), [ years [ 3 ]] : parseFloat ( medM [ 4 ]) },
prevMedian : prevM ? { [ years [ 0 ]] : parseFloat ( prevM [ 2 ]), [ years [ 1 ]] : parseFloat ( prevM [ 3 ]), [ years [ 2 ]] : parseFloat ( prevM [ 4 ]), [ years [ 3 ]] : parseFloat ( prevM [ 5 ]) } : null ,
prevLabel : prevM ? ` ${ prevM [ 1 ] } projection` : null ,
gdpMedian : gdpM ? toYearRecord ([ gdpM [ 1 ], gdpM [ 2 ], gdpM [ 3 ], gdpM [ 4 ]]) : null ,
pceMedian : pceM ? toYearRecord ([ pceM [ 1 ], pceM [ 2 ], pceM [ 3 ], pceM [ 4 ]]) : null ,
};
}
// Figure 2 : dots individuels — table HTML sémantique (th.stub = niveau de taux,
// td.data|td.emptydata = nb de participants par colonne année), bien plus fiable
// qu'un parsing du texte aplati (cellules vides ambiguës une fois le texte collapsé).
function parseSepFigure2 ( html : string ) : FedDot [] {
const dots : FedDot [] = [];
const figIdx = html . indexOf ( "Figure 2" );
if ( figIdx === - 1 ) return dots ;
const tableStart = html . indexOf ( "<table" , figIdx );
const tableEnd = html . indexOf ( "</table>" , tableStart );
if ( tableStart === - 1 || tableEnd === - 1 ) return dots ;
const tableHtml = html . slice ( tableStart , tableEnd );
const headerM = tableHtml . match ( /<thead>([\s\S]*?)<\/thead>/ );
const years = headerM
? Array . from ( headerM [ 1 ]. matchAll ( /<th[^>]*>([^<]+)<\/th>/g ), m => m [ 1 ]. trim ()). filter ( y => ! /midpoint/i . test ( y ))
: [ "2026" , "2027" , "2028" , "Longer run" ];
const rowRe = /<tr>\s*<th class="stub"[^>]*>([\d.]+)<\/th>([\s\S]*?)<\/tr>/g ;
let rm : RegExpExecArray | null ;
while (( rm = rowRe . exec ( tableHtml )) !== null ) {
const rate = parseFloat ( rm [ 1 ]);
const cells = Array . from ( rm [ 2 ]. matchAll ( /<td class="(data|emptydata)"[^>]*>([^<]*)<\/td>/g ));
cells . forEach (( c , i ) => {
if ( c [ 1 ] === "data" ) {
const count = parseInt ( c [ 2 ]. trim (), 10 );
if ( ! isNaN ( count ) && count > 0 && years [ i ]) dots . push ({ year : years [ i ], rate , count });
}
});
}
return dots ;
}
async function fetchFedDotPlot ( sepDates : string []) : Promise < FedDotPlot | null > {
if ( ! sepDates . length ) return null ;
const latest = sepDates . at ( - 1 ) ! ;
const htmlUrl = `https://www.federalreserve.gov/monetarypolicy/fomcprojtabl ${ latest } .htm` ;
const html = await fetchText ( htmlUrl );
if ( ! html ) return null ;
const text = htmlToText ( html );
const table1 = parseSepTable1 ( text );
if ( ! table1 ) return null ;
const dots = parseSepFigure2 ( html );
// Historique : jusqu'à 6 dernières publications SEP (trimestrielles) pour montrer l'évolution
const histDates = sepDates . slice ( - 6 );
const history : FedSepHistoryPoint [] = [];
const histResults = await Promise . all ( histDates . map ( async d => {
if ( d === latest ) return { date : d , medianByYear : table1.median };
const h = await fetchText ( `https://www.federalreserve.gov/monetarypolicy/fomcprojtabl ${ d } .htm` );
if ( ! h ) return null ;
const t1 = parseSepTable1 ( htmlToText ( h ));
return t1 ? { date : d , medianByYear : t1.median } : null ;
}));
for ( const h of histResults ) if ( h ) history . push ({ date : ` ${ h . date . slice ( 0 , 4 ) } - ${ h . date . slice ( 4 , 6 ) } - ${ h . date . slice ( 6 , 8 ) } ` , medianByYear : h.medianByYear });
return {
asOfDate : ` ${ latest . slice ( 0 , 4 ) } - ${ latest . slice ( 4 , 6 ) } - ${ latest . slice ( 6 , 8 ) } ` ,
years : table1.years ,
medianByYear : table1.median ,
prevMedianByYear : table1.prevMedian ,
prevLabel : table1.prevLabel ,
dots ,
history ,
gdpMedian : table1.gdpMedian ,
pceMedian : table1.pceMedian ,
sepHtmlUrl : htmlUrl ,
sepPdfUrl : `https://www.federalreserve.gov/monetarypolicy/files/fomcprojtabl ${ latest } .pdf` ,
};
}
export async function fetchFedGovernance () : Promise < CBGovernance > {
const info = CB_STATIC_INFO . USD ;
const dates = await findLatestFedDates ();
if ( ! dates || ! dates . statementDates . length ) return staticFallback ( "USD" , "Impossible de lire le calendrier FOMC" );
const latestStatementDate = dates . statementDates . at ( - 1 ) ! ;
const [ statement , dotPlot ] = await Promise . all ([
fetchFedStatement ( latestStatementDate ),
fetchFedDotPlot ( dates . sepDates ),
]);
if ( ! statement ) return staticFallback ( "USD" , "Communiqué FOMC illisible" );
const forecast : CBForecast | null = dotPlot && ( dotPlot . gdpMedian || dotPlot . pceMedian )
? {
asOf : dotPlot.asOfDate ,
years : dotPlot.years.filter ( y => y !== "Longer run" ),
gdp : dotPlot.gdpMedian ?? {},
inflation : dotPlot.pceMedian ?? {},
label : `Fed — Summary of Economic Projections (médianes) — ${ dotPlot . asOfDate } ` ,
sourceUrl : dotPlot.sepHtmlUrl ,
}
: null ;
return {
currency : "USD" , ... info ,
meetingDate : ` ${ latestStatementDate . slice ( 0 , 4 ) } - ${ latestStatementDate . slice ( 4 , 6 ) } - ${ latestStatementDate . slice ( 6 , 8 ) } ` ,
rateLevel : statement.rateLevel ,
voteSummary : statement.voteSummary ,
voteDetail : statement.voteDetail ,
statementUrl : statement.statementUrl ,
reportPdfUrl : dotPlot?.sepPdfUrl ?? null ,
reportLabel : dotPlot ? `Summary of Economic Projections — ${ dotPlot . asOfDate } ` : null ,
...( dotPlot ? { dotPlot } : {}),
forecast ,
};
}
// ── BoE (GBP) ─────────────────────────────────────────────────────────────────
const BOE_MONTHS = [ "january" , "february" , "march" , "april" , "may" , "june" , "july" , "august" , "september" , "october" , "november" , "december" ];
interface BoeParsed { rateLevel : string ; voteSummary : string ; meetingDate : string | null ; voteDetail : string | null ; }
// "voted by a majority of X– Y to maintain/raise/lower Bank Rate at Z%" ou, si
// unanime, "voted unanimously to maintain/raise/lower Bank Rate at Z%".
function parseBoeVote ( text : string ) : BoeParsed | null {
const majM = text . match ( /voted by a majority of\s+(\d+)\s*[–—-]\s*(\d+)\s+to\s+(?:maintain|reduce|increase|raise|cut)[^.]*?Bank Rate at\s+([\d.]+)%/i );
const unanM = ! majM && text . match ( /voted unanimously to\s+(?:maintain|reduce|increase|raise|cut)[^.]*?Bank Rate at\s+([\d.]+)%/i );
if ( ! majM && ! unanM ) return null ;
const dateM = text . match ( /At its meeting ending on\s+([^,]+),/i );
// Périodes tolérées à l'intérieur (ex. "0.25 percentage points") : on ne coupe
// que sur un point réellement suivi d'un espace (fin de phrase), pas un point décimal.
const dissentM = text . match ( /(\w+\s+members?\s+voted to(?:(?!\.\s)[\s\S])+)\.\s/i );
return majM
? { rateLevel : ` ${ majM [ 3 ] } %` , voteSummary : ` ${ majM [ 1 ] } – ${ majM [ 2 ] } ` , meetingDate : dateM?. [ 1 ] ? . trim () ?? null , voteDetail : dissentM?. [ 1 ] ? . trim () ?? null }
: { rateLevel : ` ${ ( unanM as RegExpMatchArray )[ 1 ] } %` , voteSummary : "Unanime" , meetingDate : dateM?. [ 1 ] ? . trim () ?? null , voteDetail : null };
}
export async function fetchBoeGovernance () : Promise < CBGovernance > {
const info = CB_STATIC_INFO . GBP ;
const now = new Date ();
// Certaines pages du mois courant existent déjà en placeholder avant la réunion
// ("to be published at Xpm") — on ne valide donc pas sur la simple présence de
// la page, mais sur un vote réellement trouvé, et on recule au besoin.
for ( let back = 0 ; back < 5 ; back ++ ) {
const d = new Date ( now . getFullYear (), now . getMonth () - back , 1 );
const monthName = BOE_MONTHS [ d . getMonth ()];
const url = `https://www.bankofengland.co.uk/monetary-policy-summary-and-minutes/ ${ d . getFullYear () } / ${ monthName } - ${ d . getFullYear () } ` ;
const html = await fetchText ( url );
if ( ! html ) continue ;
const parsed = parseBoeVote ( htmlToText ( html ));
if ( ! parsed ) continue ;
return {
currency : "GBP" , ... info ,
meetingDate : parsed.meetingDate ,
rateLevel : parsed.rateLevel ,
voteSummary : parsed.voteSummary ,
voteDetail : parsed.voteDetail ,
statementUrl : url ,
reportPdfUrl : ` ${ url } .pdf` ,
reportLabel : "Monetary Policy Summary and Minutes" ,
};
}
return staticFallback ( "GBP" , "Vote MPC introuvable sur les 5 derniers mois" );
}
// ── RBA (AUD) ─────────────────────────────────────────────────────────────────
// IMPORTANT : rba.gov.au renvoie 403 (Akamai) dès qu'un User-Agent de navigateur
// est envoyé — la demande "brute" (aucun header custom) passe en revanche très
// bien. Ne PAS utiliser fetchText() ici (il pose un User-Agent Chrome).
async function fetchPlain ( url : string ) : Promise < string | null > {
try {
const res = await fetch ( url , { next : { revalidate : 6 * 3600 } });
if ( ! res . ok ) return null ;
return await res . text ();
} catch { return null ; }
}
// Prévisions semestrielles (Table 3.2 "Detailed Baseline Forecast Table") de la
// dernière Statement on Monetary Policy — colonnes Dec/Jun (pas des années
// calendaires pleines, la RBA prévoit par semestre).
async function fetchRbaForecast () : Promise < CBForecast | null > {
const indexHtml = await fetchPlain ( "https://www.rba.gov.au/publications/smp/" );
if ( ! indexHtml ) return null ;
const linkM = indexHtml . match ( /href="(\/publications\/smp\/\d{4}\/[a-z]+\/)"/ );
if ( ! linkM ) return null ;
const pageUrl = `https://www.rba.gov.au ${ linkM [ 1 ] } outlook.html` ;
const html = await fetchPlain ( pageUrl );
if ( ! html ) return null ;
const text = htmlToText ( html );
const tblIdx = text . indexOf ( "Table 3.2" );
if ( tblIdx === - 1 ) return null ;
const endIdx = text . indexOf ( "Forecasts finalised" , tblIdx );
const win = text . slice ( tblIdx , endIdx === - 1 ? tblIdx + 6000 : endIdx );
const headerM = win . match ( /(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})\s+(Dec|Jun)\s+(\d{4})/ );
if ( ! headerM ) return null ;
const years = [ 0 , 2 , 4 , 6 , 8 , 10 ]. map ( i => ` ${ headerM [ i + 2 ] } - ${ headerM [ i + 1 ] === "Dec" ? "12" : "06" } ` );
const numRe = "(-?[\\d.]+)" ;
const seriesRe = ` ${ numRe } \\s+ ${ numRe } \\s+ ${ numRe } \\s+ ${ numRe } \\s+ ${ numRe } \\s+ ${ numRe } ` ;
const gdpM = win . match ( new RegExp ( `Gross domestic product\\s+ ${ seriesRe } ` ));
const cpiM = win . match ( new RegExp ( `Consumer Price Index\\s+ ${ seriesRe } ` ));
if ( ! gdpM && ! cpiM ) return null ;
const toRecord = ( m : RegExpMatchArray | null ) =>
m ? Object . fromEntries ( years . map (( y , i ) => [ y , parseFloat ( m [ i + 1 ])])) : {};
return {
asOf : new Date (). toISOString (). slice ( 0 , 10 ),
years ,
gdp : toRecord ( gdpM ),
inflation : toRecord ( cpiM ),
label : "RBA — Statement on Monetary Policy, prévisions semestrielles" ,
sourceUrl : pageUrl ,
};
}
export async function fetchRbaGovernance () : Promise < CBGovernance > {
const info = CB_STATIC_INFO . AUD ;
const [ indexHtml , forecast ] = await Promise . all ([
fetchPlain ( "https://www.rba.gov.au/monetary-policy/int-rate-decisions/" ),
fetchRbaForecast (). catch (() => null ),
]);
if ( ! indexHtml ) return staticFallback ( "AUD" , "Index des décisions introuvable" );
const links = Array . from ( new Set ( Array . from ( indexHtml . matchAll ( /\/media-releases\/(\d{4})\/mr-(\d{2})-(\d{2})\.html/g ), m => m [ 0 ])));
if ( ! links . length ) return staticFallback ( "AUD" , "Aucune décision trouvée sur l'index" );
// Le numéro de séquence (mr-YY-NN) croît avec le temps mais n'est pas daté — on
// trie par (année, numéro) pour prendre la plus récente.
links . sort (( a , b ) => {
const pa = a . match ( /mr-(\d{2})-(\d{2})/ ) ! , pb = b . match ( /mr-(\d{2})-(\d{2})/ ) ! ;
return pa [ 1 ] !== pb [ 1 ] ? + pa [ 1 ] - + pb [ 1 ] : + pa [ 2 ] - + pb [ 2 ];
});
const url = `https://www.rba.gov.au ${ links . at ( - 1 ) } ` ;
const html = await fetchPlain ( url );
if ( ! html ) return staticFallback ( "AUD" , "Communiqué RBA illisible" );
const text = htmlToText ( html );
const dateM = text . match ( /\bDate\s+(\d{1,2}\s+\w+\s+\d{4})/ );
const majM = text . match ( /made by majority:\s*([a-z]+)\s+members? voted to\s+(increase|decrease|lower|leave)[^;]*?(?:to\s+([\d.]+)\s+per cent)?;\s*([a-z]+)\s+members? voted to\s+(increase|decrease|lower|leave)[^.]*?(?:at\s+([\d.]+)\s+per cent)?/i );
const unanRateM = text . match ( /Board decided to\s+(?:leave the cash rate target unchanged at|increase the cash rate target(?:\s+by[^t]*)?\s*to|lower the cash rate target(?:\s+by[^t]*)?\s*to)\s+([\d.]+)\s+per cent/i );
const WORDNUM : Record < string , number > = { one :1 , two :2 , three :3 , four :4 , five :5 , six :6 , seven :7 , eight :8 , nine :9 };
let voteSummary : string , rateLevel : string | null , voteDetail : string | null ;
if ( majM ) {
const n1 = WORDNUM [ majM [ 1 ]. toLowerCase ()] ?? NaN ;
const n2 = WORDNUM [ majM [ 4 ]. toLowerCase ()] ?? NaN ;
voteSummary = ! isNaN ( n1 ) && ! isNaN ( n2 ) ? ` ${ Math . max ( n1 , n2 ) } – ${ Math . min ( n1 , n2 ) } ` : "Majorité (détail ci-dessous)" ;
rateLevel = ( majM [ 3 ] ?? majM [ 6 ]) ? ` ${ majM [ 3 ] ?? majM [ 6 ] } %` : null ;
voteDetail = majM [ 0 ];
} else {
voteSummary = "Unanime" ;
rateLevel = unanRateM ? ` ${ unanRateM [ 1 ] } %` : null ;
voteDetail = null ;
}
return {
currency : "AUD" , ... info ,
meetingDate : dateM ? dateM [ 1 ] : null ,
rateLevel ,
voteSummary ,
voteDetail ,
statementUrl : url ,
reportPdfUrl : null , // Statement on Monetary Policy = trimestriel, pas à chaque réunion
reportLabel : "Statement by the Monetary Policy Board" ,
forecast ,
};
}
// ── SNB (CHF) ─────────────────────────────────────────────────────────────────
// Décision collégiale (Direction générale à 3 membres) — aucun vote publié.
export async function fetchSnbGovernance () : Promise < CBGovernance > {
const info = CB_STATIC_INFO . CHF ;
const listHtml = await fetchText ( "https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions" );
const linkM = listHtml ? . match ( /href="(\/en\/publications\/communication\/press-releases-restricted\/pre_(\d{8})[^"]*)"/ );
if ( ! linkM ) return staticFallback ( "CHF" , "Décision SNB introuvable" );
const dateCompact = linkM [ 2 ];
const dateIso = ` ${ dateCompact . slice ( 0 , 4 ) } - ${ dateCompact . slice ( 4 , 6 ) } - ${ dateCompact . slice ( 6 , 8 ) } ` ;
const prHtml = await fetchText ( `https://www.snb.ch ${ linkM [ 1 ] } ` );
if ( ! prHtml ) return staticFallback ( "CHF" , "Communiqué SNB illisible" );
const prText = htmlToText ( prHtml );
const rateM = prText . match ( /SNB policy rate\s+(?:unchanged at|to)\s+(-?[\d.]+)%/i );
// "Conditional inflation forecast" — pas de prévision de croissance chiffrée
// publiée par la SNB dans ce même communiqué, seulement l'inflation.
const inflM = prText . match ( /average annual inflation at\s+(-?[\d.]+)%\s+for\s+(\d{4}),\s+(-?[\d.]+)%\s+for\s+(\d{4})\s+and\s+(-?[\d.]+)%\s+for\s+(\d{4})/i );
const forecast : CBForecast | null = inflM ? {
asOf : dateIso ,
years : [ inflM [ 2 ], inflM [ 4 ], inflM [ 6 ]],
gdp : {},
inflation : { [ inflM [ 2 ]] : parseFloat ( inflM [ 1 ]), [ inflM [ 4 ]] : parseFloat ( inflM [ 3 ]), [ inflM [ 6 ]] : parseFloat ( inflM [ 5 ]) },
label : "SNB — prévision conditionnelle d'inflation" ,
sourceUrl : `https://www.snb.ch ${ linkM [ 1 ] } ` ,
} : null ;
return {
currency : "CHF" , ... info ,
meetingDate : dateIso ,
rateLevel : rateM ? ` ${ rateM [ 1 ] } %` : null ,
voteSummary : "Décision collégiale (3 membres)" ,
voteDetail : null ,
statementUrl : `https://www.snb.ch ${ linkM [ 1 ] } ` ,
reportPdfUrl : `https://www.snb.ch/public/asset/en/www-snb-ch/publications/communication/press-releases-restricted/pre_ ${ dateCompact } /publications0_en/pre_ ${ dateCompact } .en.pdf` ,
reportLabel : "Monetary Policy Assessment" ,
forecast ,
};
}
// Projections du dernier Monetary Policy Report — Table 2 ("Contributions to
// average annual real GDP growth") donne les lignes annuelles GDP et CPI
// inflation ; Table 3 (juste après) redonne un CPI trimestriel qu'on veut
// éviter de capter par erreur, d'où la fenêtre de recherche bornée à
// [Table 2: … Table 3:].
async function fetchBocForecast () : Promise < CBForecast | null > {
const indexHtml = await fetchText ( "https://www.bankofcanada.ca/publications/mpr/" );
if ( ! indexHtml ) return null ;
const mprM = indexHtml . match ( /href="(https:\/\/www\.bankofcanada\.ca\/publications\/mpr\/mpr-\d{4}-\d{2}-\d{2}\/)"/ );
if ( ! mprM ) return null ;
const projUrl = ` ${ mprM [ 1 ] } projections/` ;
const html = await fetchText ( projUrl );
if ( ! html ) return null ;
const text = htmlToText ( html );
const t2Start = text . indexOf ( "Table 2:" );
const t3Start = text . indexOf ( "Table 3:" , t2Start );
if ( t2Start === - 1 || t3Start === - 1 ) return null ;
const window = text . slice ( t2Start , t3Start );
const yearsM = window . match ( /(\d{4})\s+(\d{4})\s+(\d{4})\s+(\d{4})/ );
const numRe = "(-?[\\d.]+)(?:\\s*\\([^)]*\\))?" ;
const gdpM = window . match ( new RegExp ( `\\bGDP\\s+ ${ numRe } \\s+ ${ numRe } \\s+ ${ numRe } \\s+ ${ numRe } ` ));
const cpiM = window . match ( new RegExp ( `\\bCPI inflation\\s+ ${ numRe } \\s+ ${ numRe } \\s+ ${ numRe } \\s+ ${ numRe } ` ));
if ( ! yearsM || ( ! gdpM && ! cpiM )) return null ;
const years = [ yearsM [ 1 ], yearsM [ 2 ], yearsM [ 3 ], yearsM [ 4 ]];
const toRecord = ( m : RegExpMatchArray | null ) =>
m ? { [ years [ 0 ]] : parseFloat ( m [ 1 ]), [ years [ 1 ]] : parseFloat ( m [ 2 ]), [ years [ 2 ]] : parseFloat ( m [ 3 ]), [ years [ 3 ]] : parseFloat ( m [ 4 ]) } : {};
return {
asOf : new Date (). toISOString (). slice ( 0 , 10 ),
years ,
gdp : toRecord ( gdpM ),
inflation : toRecord ( cpiM ),
label : "BoC — Monetary Policy Report, projections" ,
sourceUrl : projUrl ,
};
}
// ── BoC (CAD) ─────────────────────────────────────────────────────────────────
// Décision par consensus — aucun vote publié.
export async function fetchBocGovernance () : Promise < CBGovernance > {
const info = CB_STATIC_INFO . CAD ;
const [ html , forecast ] = await Promise . all ([
fetchText ( "https://www.bankofcanada.ca/core-functions/monetary-policy/key-interest-rate/" ),
fetchBocForecast (). catch (() => null ),
]);
if ( ! html ) return staticFallback ( "CAD" , "Page taux directeur introuvable" );
const text = htmlToText ( html );
// Tableau "Date* Target (%) Change (%) <date la plus récente> <taux> ..." —
// la première ligne du tableau est toujours la décision la plus récente.
const rowM = text . match ( /Target \(%\)\s+Change \(%\)\s+([A-Z][a-z]+ \d{1,2},\s*\d{4})\s+([\d.]+)/ );
return {
currency : "CAD" , ... info ,
meetingDate : rowM ? rowM [ 1 ] : null ,
rateLevel : rowM ? ` ${ rowM [ 2 ] } %` : null ,
voteSummary : "Décision par consensus" ,
voteDetail : null ,
statementUrl : "https://www.bankofcanada.ca/core-functions/monetary-policy/key-interest-rate/" ,
reportPdfUrl : null ,
reportLabel : "Monetary Policy Report" ,
forecast ,
};
}
// Extrait les projections macroéconomiques (staff projections) du même
// communiqué de décision déjà fetché — phrasé quasi-identique à chaque
// publication trimestrielle : "headline inflation is expected to average X%
// in Y1, Y% in Y2 and Z% in Y3" / "economic growth at an average of X% in Y1…".
function parseEcbForecast ( text : string , sourceUrl : string ) : CBForecast | null {
const inflM = text . match ( /(?:headline )?inflation is expected to average\s+([\d.]+)%\s+in\s+(\d{4}),\s+([\d.]+)%\s+in\s+(\d{4})\s+and\s+([\d.]+)%\s+in\s+(\d{4})/i );
const gdpM = text . match ( /(?:economic growth|GDP growth) at an average of\s+([\d.]+)%\s+in\s+(\d{4}),\s+([\d.]+)%\s+in\s+(\d{4})\s+and\s+([\d.]+)%\s+in\s+(\d{4})/i );
if ( ! inflM && ! gdpM ) return null ;
const years = Array . from ( new Set ([ inflM ? .[ 2 ], inflM ? .[ 4 ], inflM ? .[ 6 ], gdpM ? .[ 2 ], gdpM ? .[ 4 ], gdpM ? .[ 6 ]]. filter (( y ) : y is string => !! y ))). sort ();
return {
asOf : new Date (). toISOString (). slice ( 0 , 10 ),
years ,
gdp : gdpM ? { [ gdpM [ 2 ]] : parseFloat ( gdpM [ 1 ]), [ gdpM [ 4 ]] : parseFloat ( gdpM [ 3 ]), [ gdpM [ 6 ]] : parseFloat ( gdpM [ 5 ]) } : {},
inflation : inflM ? { [ inflM [ 2 ]] : parseFloat ( inflM [ 1 ]), [ inflM [ 4 ]] : parseFloat ( inflM [ 3 ]), [ inflM [ 6 ]] : parseFloat ( inflM [ 5 ]) } : {},
label : "Eurosystem staff macroeconomic projections" ,
sourceUrl ,
};
}
// ── ECB (EUR) ─────────────────────────────────────────────────────────────────
// Décision par consensus — aucun vote publié. Découverte du dernier communiqué
// via le flux RSS (les URLs de communiqués contiennent un hash non prévisible).
export async function fetchEcbGovernance () : Promise < CBGovernance > {
const info = CB_STATIC_INFO . EUR ;
// Le site ECB charge sa liste de décisions côté client (pas de lien statique
// fiable vers le dernier communiqué — hash d'URL non prévisible depuis la date).
// Le flux RSS général capte la décision si elle est encore dans sa fenêtre
// glissante ; sinon on retombe sur data/rate_decisions.json (déjà tenu à jour
// manuellement ailleurs dans l'app) pour au moins afficher le taux courant.
const rss = await fetchText ( "https://www.ecb.europa.eu/rss/press.html" );
const itemM = rss ? Array . from ( rss . matchAll ( /<item>([\s\S]*?)<\/item>/g )). map ( m => m [ 1 ]). find ( item => /ecb\.mp\d{6}~/ . test ( item )) : undefined ;
if ( itemM ) {
const urlM = itemM . match ( /<link>([^<]+)<\/link>/ );
const dateM = itemM . match ( /<pubDate>([^<]+)<\/pubDate>/ );
if ( urlM ) {
const html = await fetchText ( urlM [ 1 ]);
const text = html ? htmlToText ( html ) : "" ;
const rateM = text . match ( /deposit facility rate[^.]*?to\s+([\d.]+)%/i );
if ( rateM || text ) {
return {
currency : "EUR" , ... info ,
meetingDate : dateM ? new Date ( dateM [ 1 ]). toISOString (). slice ( 0 , 10 ) : null ,
rateLevel : rateM?. [ 1 ] ? ` ${ rateM [ 1 ] } %` : null ,
voteSummary : "Décision par consensus" ,
voteDetail : null ,
statementUrl : urlM [ 1 ],
reportPdfUrl : null ,
reportLabel : "Monetary Policy Decision" ,
forecast : parseEcbForecast ( text , urlM [ 1 ]),
};
}
}
}
// Repli : taux courant connu (data/rate_decisions.json), pas de date/URL de communiqué.
const rateDecEntry = ( rateDecisionsData as Array < { decisions : Record < string , { current : number ; source ?: string }> } > )[ 0 ];
const eurDecision = rateDecEntry ? . decisions ? . EUR ;
if ( eurDecision === undefined ) return staticFallback ( "EUR" , "Communiqué introuvable et rate_decisions.json indisponible" );
// Le champ "source" de rate_decisions.json contient parfois l'URL du
// communiqué (écrite manuellement lors de la dernière correction) — on la
// réutilise pour tenter d'en tirer les projections, à défaut de RSS.
const fallbackUrlM = eurDecision . source ? . match ( /https:\/\/www\.ecb\.europa\.eu\/press\/pr\/\S+?\.html/ );
const fallbackHtml = fallbackUrlM ? await fetchText ( fallbackUrlM [ 0 ]) : null ;
const fallbackForecast = fallbackHtml ? parseEcbForecast ( htmlToText ( fallbackHtml ), fallbackUrlM ! [ 0 ]) : null ;
return {
currency : "EUR" , ... info ,
meetingDate : null ,
rateLevel : ` ${ eurDecision . current } %` ,
voteSummary : "Décision par consensus" ,
voteDetail : null ,
statementUrl : fallbackUrlM?. [ 0 ] ?? null ,
reportPdfUrl : null ,
reportLabel : null ,
forecast : fallbackForecast ,
fetchError : "Dernier communiqué hors de la fenêtre RSS — taux affiché depuis rate_decisions.json" ,
};
}
// ── BoJ (JPY) ─────────────────────────────────────────────────────────────────
// Vote numérique publié (ex. "7-1 majority vote"). Statement PDF-only en 2026 ;
// date de réunion découverte par recherche en arrière (par lots concurrents,
// même technique que lib/investinglive.ts) faute de calendrier discret exploitable.
async function tryBojDate ( dateCompact : string ) : Promise < { text : string ; url : string } | null > {
const url = `https://www.boj.or.jp/en/mopo/mpmdeci/mpr_2026/k ${ dateCompact } a.pdf` ;
try {
// Pas de next:{revalidate} ici : le cache fetch de Next.js sur une réponse
// binaire (PDF) interfère avec la lecture arrayBuffer() qui suit (échec
// silencieux observé en pratique — un fetch "nu" comme pour RBA fonctionne).
const res = await fetch ( url );
if ( ! res . ok ) return null ;
const buf = await res . arrayBuffer ();
const { PDFParse } = await import ( "pdf-parse" );
const parser = new PDFParse ({ data : Buffer.from ( buf ) });
const result = await parser . getText ();
await parser . destroy ();
return { text : result.text , url };
} catch { return null ; }
}
// Outlook for Economic Activity and Prices ("Outlook Report") — publié 4x/an
// (env. jan/avr/jul/oct), pas à chaque réunion. Table "Forecasts of the
// Majority of the Policy Board Members" : pour chaque exercice fiscal, la
// médiane est entre crochets ; on ignore les lignes "Forecasts made in [mois]"
// qui sont l'ancienne prévision de comparaison, pas la prévision actuelle.
async function fetchBojOutlookPdfText ( year : number , month : number ) : Promise < string | null > {
const yy = String ( year % 100 ). padStart ( 2 , "0" );
const mm = String ( month ). padStart ( 2 , "0" );
const url = `https://www.boj.or.jp/en/mopo/outlook/gor ${ yy }${ mm } a.pdf` ;
try {
const res = await fetch ( url );
if ( ! res . ok ) return null ;
const buf = await res . arrayBuffer ();
const { PDFParse } = await import ( "pdf-parse" );
const parser = new PDFParse ({ data : Buffer.from ( buf ) });
const result = await parser . getText ();
await parser . destroy ();
return result . text ;
} catch { return null ; }
}
async function fetchBojForecast () : Promise < CBForecast | null > {
let y = new Date (). getFullYear ();
let m = new Date (). getMonth () + 1 ;
const quarterMonths = [ 1 , 4 , 7 , 10 ];
for ( let tries = 0 ; tries < 8 ; tries ++ ) {
if ( quarterMonths . includes ( m )) {
const text = await fetchBojOutlookPdfText ( y , m );
if ( text ) {
const flat = text . replace ( /\s+/g , " " );
const rows = Array . from ( flat . matchAll (
/Fiscal (20\d{2})\s+[+-][\d.]+\s+to\s+[+-][\d.]+\s+\[([+-][\d.]+)\]\s+([+-][\d.]+)(?:\s+to\s+[+-][\d.]+\s+\[([+-][\d.]+)\])?/g
));
if ( rows . length ) {
const years : string [] = [];
const gdp : Record < string , number > = {};
const inflation : Record < string , number > = {};
for ( const r of rows ) {
const [, fy , gdpMedian , cpiValue , cpiMedian ] = r ;
years . push ( fy );
gdp [ fy ] = parseFloat ( gdpMedian );
inflation [ fy ] = parseFloat ( cpiMedian ?? cpiValue );
}
return {
asOf : ` ${ y } - ${ String ( m ). padStart ( 2 , "0" ) } ` ,
years ,
gdp ,
inflation ,
label : "BoJ — Outlook for Economic Activity and Prices (médianes)" ,
sourceUrl : `https://www.boj.or.jp/en/mopo/outlook/gor ${ String ( y % 100 ). padStart ( 2 , "0" ) }${ String ( m ). padStart ( 2 , "0" ) } a.pdf` ,
};
}
}
}
m -- ; if ( m === 0 ) { m = 12 ; y -- ; }
}
return null ;
}
export async function fetchBojGovernance () : Promise < CBGovernance > {
const info = CB_STATIC_INFO . JPY ;
const now = new Date ();
const forecast = await fetchBojForecast (). catch (() => null );
for ( let batchStart = 0 ; batchStart <= 56 ; batchStart += 7 ) {
const days = Array . from ({ length : 7 }, ( _ , i ) => batchStart + i );
const results = await Promise . all ( days . map ( async d => {
const dt = new Date ( now ); dt . setDate ( now . getDate () - d );
const compact = dt . toISOString (). slice ( 0 , 10 ). replace ( /-/g , "" ). slice ( 2 ); // YYMMDD
const r = await tryBojDate ( compact );
return r ? { ... r , daysAgo : d , dateIso : dt.toISOString (). slice ( 0 , 10 ) } : null ;
}));
const found = results . filter (( r ) : r is NonNullable < typeof r > => r !== null ). sort (( a , b ) => a . daysAgo - b . daysAgo )[ 0 ];
if ( found ) {
const voteM = found . text . match ( /by an?\s+(\d+)-(\d+)\s+majority vote/i );
// Ex. "encourage the uncollateralized overnight call rate to remain at around 1.0 percent"
const rateM = found . text . match ( /(?:overnight call rate|policy rate)[^.]*?around\s+(-?[\d.]+)\s*percent/i );
const namesM = found . text . match ( /Voting against[^:]*:\s*([^.]+)\./i );
return {
currency : "JPY" , ... info ,
meetingDate : found.dateIso ,
rateLevel : rateM ? ` ${ rateM [ 1 ] } %` : null ,
voteSummary : voteM ? ` ${ voteM [ 1 ] } – ${ voteM [ 2 ] } ` : "Unanime" ,
voteDetail : namesM ? namesM [ 1 ]. trim () : null ,
statementUrl : found.url ,
reportPdfUrl : found.url ,
reportLabel : "Statement on Monetary Policy" ,
forecast ,
};
}
}
return staticFallback ( "JPY" , "Communiqué BoJ introuvable sur les 8 dernières semaines" );
}
// ── RBNZ (NZD) ────────────────────────────────────────────────────────────────
2026-07-08 23:46:31 +02:00
// rbnz.govt.nz bloque les pages génériques (index, listing) derrière un
// challenge Cloudflare interactif ("Verify you are human") — vérifié avec un
// vrai navigateur headless, pas seulement fetch(). On ne cherche pas à
// résoudre un captcha anti-bot / évader cette protection délibérée.
2026-07-08 16:46:05 +02:00
//
2026-07-08 23:46:31 +02:00
// MAIS : sitemap.xml, les pages de contenu individuelles qu'il référence
// (ex. la page d'une Monetary Policy Statement précise), et les PDF sous
// /-/media/ répondent normalement (200, pas de challenge) — voir
// .github/scripts/fetch-rbnz-mps.mjs, qui découvre et lit le dernier MPS pour
// en extraire les prévisions (Table 6.5), exactement comme le ferait un
// moteur de recherche indexant le site. Repli pour le reste (aucune requête
// vers les pages bloquées) :
2026-07-08 16:46:05 +02:00
// - Taux OCR courant : data/rate_decisions.json (auto-maintenu par
// update-rate-decisions.yml).
// - Date de la dernière réunion : calendrier économique Trading Economics
// (lib/tradingeconomics.ts, déjà utilisé ailleurs dans l'app pour le
// calendrier), qui couvre la Nouvelle-Zélande — pas de scraping du site
// RBNZ lui-même.
// - Vote : la RBNZ ne publie de toute façon pas de décompte individuel des
// votes (confirmé via TE/interest.co.nz — décision par consensus du
// comité, contrairement à la Fed/BoE), donc "non publié" est correct sur
// le fond, pas seulement une conséquence du blocage.
export async function fetchRbnzGovernance () : Promise < CBGovernance > {
const info = CB_STATIC_INFO . NZD ;
const rateDecEntry = ( rateDecisionsData as Array < { decisions : Record < string , { current : number }> } > )[ 0 ];
const nzdRate = rateDecEntry ? . decisions ? . NZD ? . current ;
if ( nzdRate === undefined ) return staticFallback ( "NZD" , "rbnz.govt.nz bloque les requêtes non-navigateur (Cloudflare) et rate_decisions.json indisponible" );
let meetingDate : string | null = null ;
try {
const to = new Date ();
const from = new Date ( to ); from . setDate ( from . getDate () - 70 );
const iso = ( d : Date ) => d . toISOString (). slice ( 0 , 10 );
// "new-zealand" n'est pas dans le G20 → absent de fetchTECalendarHTML (page
// /calendar générique), d'où l'usage du fetcher mono-pays dédié.
const events = await fetchTECalendarForCountry ( "new-zealand" , iso ( from ), iso ( to ));
const decisions = events
. filter ( e => e . category === "policy_rate" && e . isPublished )
. sort (( a , b ) => b . date . localeCompare ( a . date ));
if ( decisions [ 0 ]) meetingDate = decisions [ 0 ]. date . slice ( 0 , 10 );
} catch { /* meetingDate reste null, pas bloquant */ }
2026-07-08 23:46:31 +02:00
// Prévisions (Table 6.5 du Monetary Policy Statement) — maintenues par
// .github/workflows/fetch-rbnz-mps.yml. Découvertes via sitemap.xml + PDF
// public (pas la page d'index générique qui, elle, reste bloquée) : voir
// le script pour le détail. Premier CB de ce dashboard où l'on obtient une
// vraie prévision RBNZ malgré le blocage du reste du site.
const mps = rbnzMpsData as {
sourcePageUrl? : string ; sourcePdfUrl? : string ; mpsMonth? : number ; mpsYear? : number ;
years? : string []; gdp? : Record < string , number >; inflation? : Record < string , number >;
};
const forecast : CBForecast | null = mps ? . years ? . length ? {
asOf : ` ${ mps . mpsYear } - ${ String ( mps . mpsMonth ). padStart ( 2 , "0" ) } ` ,
years : mps.years ,
gdp : mps.gdp ?? {},
inflation : mps.inflation ?? {},
label : "RBNZ — Monetary Policy Statement (Table 6.5, année se terminant en mars)" ,
sourceUrl : mps.sourcePdfUrl ?? null ,
} : null ;
2026-07-08 16:46:05 +02:00
return {
currency : "NZD" , ... info ,
meetingDate ,
rateLevel : ` ${ nzdRate } %` ,
voteSummary : "Non publié" ,
voteDetail : "La RBNZ ne publie pas de décompte de vote (décision par consensus du comité) ; communiqué détaillé indisponible — rbnz.govt.nz bloque le scraping automatisé (challenge Cloudflare)." ,
2026-07-08 23:46:31 +02:00
statementUrl : mps?.sourcePageUrl ?? null ,
reportPdfUrl : mps?.sourcePdfUrl ?? null ,
reportLabel : mps?.sourcePdfUrl ? "Monetary Policy Statement" : null ,
forecast ,
fetchError : "rbnz.govt.nz bloque les requêtes non-navigateur (Cloudflare) sur ses pages génériques — taux (rate_decisions.json), date de réunion (Trading Economics) et prévisions (PDF public du MPS, non bloqué) affichés en repli." ,
2026-07-08 16:46:05 +02:00
};
}
// ── Agrégateur ────────────────────────────────────────────────────────────────
export async function fetchAllCBGovernance () : Promise < Record < Currency , CBGovernance >> {
const [ usd , gbp , eur , jpy , chf , cad , aud , nzd ] = await Promise . all ([
fetchFedGovernance (). catch (() => staticFallback ( "USD" , "Erreur de scraping" )),
fetchBoeGovernance (). catch (() => staticFallback ( "GBP" , "Erreur de scraping" )),
fetchEcbGovernance (). catch (() => staticFallback ( "EUR" , "Erreur de scraping" )),
fetchBojGovernance (). catch (() => staticFallback ( "JPY" , "Erreur de scraping" )),
fetchSnbGovernance (). catch (() => staticFallback ( "CHF" , "Erreur de scraping" )),
fetchBocGovernance (). catch (() => staticFallback ( "CAD" , "Erreur de scraping" )),
fetchRbaGovernance (). catch (() => staticFallback ( "AUD" , "Erreur de scraping" )),
fetchRbnzGovernance (). catch (() => staticFallback ( "NZD" , "Erreur de scraping" )),
]);
return { USD : usd , GBP : gbp , EUR : eur , JPY : jpy , CHF : chf , CAD : cad , AUD : aud , NZD : nzd };
}