mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
465a81a27c
rbnz.govt.nz bloque bien ses pages génériques (index, listing) derrière un
challenge Cloudflare, comme déjà documenté — mais vérifié en direct que
sitemap.xml, les pages de contenu individuelles qu'il référence, et les PDF
sous /-/media/ répondent normalement (200, sans challenge). C'est exactement
ce qu'indexerait un moteur de recherche : aucun contournement, juste des
ressources publiques non protégées.
.github/scripts/fetch-rbnz-mps.mjs :
1. Lit sitemap.xml pour trouver la dernière page MPS ("...-filtered-listing-
page/{year}/{month}-{id}/monetary-policy-statement-{month}-{year}")
2. En extrait le lien du PDF officiel
3. Parse le PDF (pdf-parse, déjà utilisé pour BoJ) et extrait la Table 6.5
"Summary of economic projections" (CPI, OCR moyenne annuelle, GDP par
année "March year")
Note technique : le fetch() natif de Node (undici) se prend un 403 sur ces
mêmes URLs alors que curl passe (200, vérifié à la seconde près, même
User-Agent) — Cloudflare fingerprinte la pile TLS/HTTP2 du client, pas
seulement l'en-tête. Le script shell out donc vers curl (préinstallé sur les
runners GitHub Actions).
.github/workflows/fetch-rbnz-mps.yml : quotidien (le MPS n'est publié
qu'~4x/an mais découverte + parsing sont légers), + déclenchement manuel.
lib/centralBankGovernance.ts : fetchRbnzGovernance() expose désormais un vrai
reportPdfUrl/statementUrl (au lieu de null) et un CBForecast — la 8e et
dernière banque centrale de l'onglet Banques centrales à avoir ses
prévisions, malgré le blocage du reste du site RBNZ.
Vérifié en direct : PIB/inflation 2026-2029 extraits correctement (+0.7 à
+3.4% PIB, +3.1 à +2.0% inflation), card RBNZ affiche le bloc "Prévisions"
et un vrai bouton "Rapport PDF" fonctionnel.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
894 lines
44 KiB
TypeScript
894 lines
44 KiB
TypeScript
// 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
|
||
// 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).
|
||
|
||
import type { Currency } from "./types";
|
||
import rateDecisionsData from "@/data/rate_decisions.json";
|
||
import rbnzMpsData from "@/data/rbnz-mps.json";
|
||
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) ────────────────────────────────────────────────────────────────
|
||
// 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.
|
||
//
|
||
// 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) :
|
||
// - 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 */ }
|
||
|
||
// 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;
|
||
|
||
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).",
|
||
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.",
|
||
};
|
||
}
|
||
|
||
// ── 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 };
|
||
}
|