mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
feat: RBNZ — automatise la découverte + lecture du Monetary Policy Statement
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>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// Monetary Policy Statement (RBNZ) — trouve automatiquement le PDF le plus
|
||||
// récent et en extrait le tableau de prévisions (Table 6.5 "Summary of
|
||||
// economic projections" : CPI, OCR, GDP par année civile "March year").
|
||||
//
|
||||
// Contexte : rbnz.govt.nz bloque les requêtes automatisées sur ses pages
|
||||
// génériques (challenge Cloudflare interactif — voir lib/centralBankGovernance.ts
|
||||
// pour le détail, on ne cherche pas à le contourner). MAIS deux exceptions
|
||||
// vérifiées manuellement, non protégées :
|
||||
// 1. https://www.rbnz.govt.nz/sitemap.xml (200 OK) — liste les pages MPS
|
||||
// individuelles ("monetary-policy-statement-filtered-listing-page/...")
|
||||
// 2. Ces pages individuelles elles-mêmes, ET les PDF sous /-/media/...
|
||||
// (200 OK) — apparemment seule la page d'INDEX générique est protégée,
|
||||
// pas le contenu spécifique référencé par le sitemap.
|
||||
// Aucun contournement : on lit des ressources servies normalement (200, pas
|
||||
// de challenge), avec un User-Agent standard, exactement comme le ferait
|
||||
// un moteur de recherche indexant le sitemap public du site.
|
||||
//
|
||||
// IMPORTANT : le fetch() natif de Node (undici) se prend un 403 sur ces
|
||||
// mêmes URLs alors que curl passe (200) — vérifié en direct, à la seconde
|
||||
// près, avec un User-Agent identique. Cloudflare fingerprinte visiblement la
|
||||
// pile TLS/HTTP2 du client (JA3/JA4), pas seulement l'en-tête User-Agent. On
|
||||
// shell out donc vers curl (préinstallé sur les runners GitHub Actions ;
|
||||
// c'est le client qui, vérifié, fonctionne) plutôt que d'utiliser fetch().
|
||||
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
import { execFileSync } from "child_process";
|
||||
|
||||
const UA = "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";
|
||||
|
||||
function curlGet(url) {
|
||||
return execFileSync("curl", [
|
||||
"-s", "-L", "--max-time", "30",
|
||||
"-A", UA,
|
||||
"-H", "Accept-Language: en-US,en;q=0.9",
|
||||
url,
|
||||
], { maxBuffer: 1024 * 1024 * 50 }); // Buffer (binaire OK pour le PDF)
|
||||
}
|
||||
|
||||
const MONTHS = {
|
||||
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
|
||||
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
|
||||
};
|
||||
|
||||
async function findLatestMpsPageUrl() {
|
||||
const xml = curlGet("https://www.rbnz.govt.nz/sitemap.xml").toString("utf8");
|
||||
if (!xml.includes("<urlset")) throw new Error("sitemap.xml : réponse inattendue (bloqué ?)");
|
||||
|
||||
const re = /<loc>(https:\/\/www\.rbnz\.govt\.nz\/monetary-policy\/monetary-policy-statement\/monetary-policy-statement-filtered-listing-page\/(\d{4})\/([a-z]+)-\d+\/monetary-policy-statement-[a-z]+-\d{4})<\/loc>/g;
|
||||
const candidates = [];
|
||||
let m;
|
||||
while ((m = re.exec(xml)) !== null) {
|
||||
const [url, yearStr, monthSlug] = [m[1], m[2], m[3]];
|
||||
const monthNum = MONTHS[monthSlug.slice(0, 3).toLowerCase()];
|
||||
if (!monthNum) continue;
|
||||
candidates.push({ url, year: parseInt(yearStr), month: monthNum });
|
||||
}
|
||||
if (!candidates.length) throw new Error("Aucune page MPS trouvée dans le sitemap");
|
||||
|
||||
candidates.sort((a, b) => (a.year - b.year) || (a.month - b.month));
|
||||
return candidates.at(-1);
|
||||
}
|
||||
|
||||
async function findPdfUrl(pageUrl) {
|
||||
const html = curlGet(pageUrl).toString("utf8");
|
||||
const m = html.match(/href="(https:\/\/www\.rbnz\.govt\.nz\/-\/media\/[^"]+monetary-policy-statement-[^"]+\.pdf)"/i)
|
||||
?? html.match(/href="(\/-\/media\/[^"]+monetary-policy-statement-[^"]+\.pdf)"/i);
|
||||
if (!m) throw new Error("Lien PDF introuvable sur la page MPS");
|
||||
return m[1].startsWith("http") ? m[1] : `https://www.rbnz.govt.nz${m[1]}`;
|
||||
}
|
||||
|
||||
async function fetchPdfText(pdfUrl) {
|
||||
const buf = curlGet(pdfUrl);
|
||||
if (buf.length < 1000 || buf.subarray(0, 4).toString("latin1") !== "%PDF") {
|
||||
throw new Error(`PDF invalide (${buf.length} bytes, bloqué ?)`);
|
||||
}
|
||||
const { PDFParse } = await import("pdf-parse");
|
||||
const parser = new PDFParse({ data: buf });
|
||||
const result = await parser.getText();
|
||||
await parser.destroy();
|
||||
return result.text;
|
||||
}
|
||||
|
||||
// Table 6.5 "Summary of economic projections" — lignes à largeur fixe (11
|
||||
// colonnes : March year 2019..2029, mélange actuals/projections). Certaines
|
||||
// étiquettes de ligne sont coupées sur 2 lignes dans le texte extrait (ex.
|
||||
// "GDP (production, annual\naverage % change)") d'où \s+ entre les morceaux.
|
||||
function parseForecastTable(text) {
|
||||
const flat = text.replace(/‑/g, "-"); // non-breaking hyphen -> hyphen standard
|
||||
const start = flat.indexOf("Summary of economic projections");
|
||||
if (start === -1) return null;
|
||||
const end = flat.indexOf("Appendix 2", start);
|
||||
const win = flat.slice(start, end === -1 ? start + 4000 : end);
|
||||
|
||||
const NUM = "((?:-?[\\d.]+\\s+){10}-?[\\d.]+)";
|
||||
const yearsM = win.match(/March year\s+((?:\d{4}\s+){10}\d{4})/);
|
||||
const cpiM = win.match(new RegExp("Price measures\\s+CPI\\s+" + NUM));
|
||||
const ocrM = win.match(new RegExp("OCR \\(year average\\)\\s+" + NUM));
|
||||
const gdpM = win.match(new RegExp("GDP \\(production, annual\\s+average % change\\)\\s*" + NUM));
|
||||
if (!yearsM || (!cpiM && !ocrM && !gdpM)) return null;
|
||||
|
||||
const years = yearsM[1].trim().split(/\s+/);
|
||||
const toRecord = (m) => {
|
||||
if (!m) return null;
|
||||
const vals = m[1].trim().split(/\s+/).map(Number);
|
||||
return Object.fromEntries(years.map((y, i) => [y, vals[i]]));
|
||||
};
|
||||
|
||||
// On ne garde que les 4 dernières années (horizon comparable aux autres CB)
|
||||
const lastN = (rec) => rec && Object.fromEntries(years.slice(-4).map(y => [y, rec[y]]));
|
||||
|
||||
return {
|
||||
years: years.slice(-4),
|
||||
gdp: lastN(toRecord(gdpM)) ?? {},
|
||||
inflation: lastN(toRecord(cpiM)) ?? {},
|
||||
ocrYearAvg: lastN(toRecord(ocrM)) ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Recherche de la dernière page MPS via sitemap.xml…");
|
||||
const { url: pageUrl, year, month } = await findLatestMpsPageUrl();
|
||||
console.log(`✓ Page trouvée (${year}-${String(month).padStart(2, "0")}): ${pageUrl}`);
|
||||
|
||||
const pdfUrl = await findPdfUrl(pageUrl);
|
||||
console.log(`✓ PDF trouvé: ${pdfUrl}`);
|
||||
|
||||
const text = await fetchPdfText(pdfUrl);
|
||||
console.log(`✓ PDF parsé (${text.length} caractères)`);
|
||||
|
||||
const forecast = parseForecastTable(text);
|
||||
if (!forecast) throw new Error("Table 6.5 introuvable ou format inattendu");
|
||||
console.log("✓ Prévisions extraites:", JSON.stringify(forecast));
|
||||
|
||||
mkdirSync("data", { recursive: true });
|
||||
writeFileSync("data/rbnz-mps.json", JSON.stringify({
|
||||
updated_at: new Date().toISOString().slice(0, 10),
|
||||
sourcePageUrl: pageUrl,
|
||||
sourcePdfUrl: pdfUrl,
|
||||
mpsYear: year,
|
||||
mpsMonth: month,
|
||||
note: "Table 6.5 'Summary of economic projections' — RBNZ Monetary Policy Statement. Années 'March year' (année se terminant en mars, convention budgétaire NZ) ; OCR en moyenne annuelle (pas un point de fin d'année comme les autres banques centrales de ce dashboard). Découvert et extrait via sitemap.xml + PDF public (pas de contournement du challenge Cloudflare qui protège les pages d'index génériques du site).",
|
||||
...forecast,
|
||||
}, null, 2) + "\n");
|
||||
console.log("✓ Saved data/rbnz-mps.json");
|
||||
} catch (e) {
|
||||
console.error(`✗ RBNZ MPS fetch failed: ${e.message}`);
|
||||
process.exit(0); // échec silencieux — ne bloque pas le workflow, données précédentes conservées
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Fetch RBNZ Monetary Policy Statement
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * *' # quotidien 12h UTC (le MPS n'est publié qu'~4x/an, mais découverte + parsing sont légers)
|
||||
workflow_dispatch: # déclenchement manuel
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
fetch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
persist-credentials: true
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install pdf-parse (script only, pas ajouté au package.json de l'app)
|
||||
run: npm install --no-save pdf-parse
|
||||
|
||||
- name: Fetch RBNZ Monetary Policy Statement (forecast table)
|
||||
run: node .github/scripts/fetch-rbnz-mps.mjs
|
||||
|
||||
- name: Commit updated data (if changed)
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add data/rbnz-mps.json
|
||||
git diff --staged --quiet || git commit -m "chore: update RBNZ Monetary Policy Statement data [skip ci]"
|
||||
git push
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"updated_at": "2026-07-08",
|
||||
"sourcePageUrl": "https://www.rbnz.govt.nz/monetary-policy/monetary-policy-statement/monetary-policy-statement-filtered-listing-page/2026/may-270/monetary-policy-statement-may-2026",
|
||||
"sourcePdfUrl": "https://www.rbnz.govt.nz/-/media/project/sites/rbnz/files/publications/monetary-policy-statements/2026/may-272605/monetary-policy-statement-may-2026.pdf",
|
||||
"mpsYear": 2026,
|
||||
"mpsMonth": 5,
|
||||
"note": "Table 6.5 'Summary of economic projections' — RBNZ Monetary Policy Statement. Années 'March year' (année se terminant en mars, convention budgétaire NZ) ; OCR en moyenne annuelle (pas un point de fin d'année comme les autres banques centrales de ce dashboard). Découvert et extrait via sitemap.xml + PDF public (pas de contournement du challenge Cloudflare qui protège les pages d'index génériques du site).",
|
||||
"years": [
|
||||
"2026",
|
||||
"2027",
|
||||
"2028",
|
||||
"2029"
|
||||
],
|
||||
"gdp": {
|
||||
"2026": 0.7,
|
||||
"2027": 1.7,
|
||||
"2028": 3.1,
|
||||
"2029": 3.4
|
||||
},
|
||||
"inflation": {
|
||||
"2026": 3.1,
|
||||
"2027": 3.7,
|
||||
"2028": 2,
|
||||
"2029": 2
|
||||
},
|
||||
"ocrYearAvg": {
|
||||
"2026": 2.8,
|
||||
"2027": 2.7,
|
||||
"2028": 3.1,
|
||||
"2029": 3.2
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,13 @@
|
||||
// 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/RBNZ bloquent tout fetch() non-navigateur).
|
||||
// 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";
|
||||
@@ -798,12 +801,18 @@ export async function fetchBojGovernance(): Promise<CBGovernance> {
|
||||
}
|
||||
|
||||
// ── RBNZ (NZD) ────────────────────────────────────────────────────────────────
|
||||
// rbnz.govt.nz bloque toute requête automatisée derrière un challenge Cloudflare
|
||||
// interactif ("Verify you are human") — vérifié avec un vrai navigateur headless,
|
||||
// pas seulement fetch(). Ce n'est pas contournable proprement (et on ne cherche
|
||||
// pas à résoudre un captcha anti-bot / évader une protection délibérée).
|
||||
// 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.
|
||||
//
|
||||
// Repli légitime (aucune requête vers rbnz.govt.nz) :
|
||||
// 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
|
||||
@@ -835,16 +844,35 @@ export async function fetchRbnzGovernance(): Promise<CBGovernance> {
|
||||
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: null,
|
||||
reportPdfUrl: null,
|
||||
reportLabel: null,
|
||||
fetchError: "rbnz.govt.nz bloque les requêtes non-navigateur (Cloudflare) — taux (rate_decisions.json) et date de réunion (Trading Economics) affichés en repli.",
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user