mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
0657b50c07
Central bank governance (nouvel onglet Banques centrales) : - lib/centralBankGovernance.ts + app/api/central-bank-sources : scraping live du vote de la derniere reunion, dot plot Fed (SEP), et desormais les previsions macro (PIB + inflation) publiees par chaque BC elle-meme (Fed SEP, Eurosystem staff projections, BoJ Outlook Report PDF, SNB conditional forecast, BoC MPR, RBA SMP). GBP/NZD laisses honnetement vides quand aucune source chiffree fiable n'est accessible (RBNZ bloque par Cloudflare). - components/CentralBankSourcesTab.tsx : nouvel onglet avec cards par banque. Taux directeurs + Money Supply M3 : - data/rate_decisions.json corrige (JPY, EUR, NZD etc. etaient perimes d'1-2 decisions) et desormais auto-maintenu : .github/workflows/update-rate-decisions.yml (horaire) detecte les changements de taux via Trading Economics et fait glisser current -> prev sans perte de donnee. - data/money-supply-m3.json (nouveau) + .github/workflows/fetch-money-supply.yml (hebdo) : M3 par devise (proxy M2 pour l'USD, la Fed ne publiant plus M3 depuis 2006), affiche dans CurrencyCard. Calendrier economique elargi : - lib/calendar-countries.ts, lib/calendar-taxonomy.ts, lib/fxstreetCalendar.ts : couverture pays elargie + classification des evenements + source FXStreet. Fix InvestingLive : - lib/investinglive.ts : l'ancienne API WordPress (wp-json) renvoyait 404 depuis leur migration Nuxt.js -> reecrit vers api.investinglive.com/api/homepage/articles, + fix crash silencieux (Tldr pas toujours un tableau). Divers : - .vercel/ ajoute au .gitignore (ne doit jamais etre commite, cf. son propre README). - scripts/ (lancement PWA, push env Vercel), captures d'ecran, cache InvestingLive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
177 lines
7.4 KiB
TypeScript
177 lines
7.4 KiB
TypeScript
// lib/fxstreetCalendar.ts
|
|
// Calendrier économique investingLive (investinglive.com/EconomicCalendar).
|
|
//
|
|
// investingLive n'a pas d'API JSON publique : la page embarque le widget
|
|
// FXStreet (calendar.fxstreet.com), chargé via /fxstreet-calendar.js sur
|
|
// investinglive.com. On appelle directement l'endpoint HTML de ce widget —
|
|
// c'est le même appel que fait le navigateur quand on visite
|
|
// investinglive.com/EconomicCalendar (Referer investingLive requis).
|
|
//
|
|
// Endpoint : GET https://calendar.fxstreet.com/EventDateWidget/GetMain
|
|
// ?culture=en-US&timezone=UTC&start=YYYYMMDD&end=YYYYMMDD&view=range
|
|
// &rows=5000&countrycode=US,UK,EMU,JP,...
|
|
// → retourne un fragment HTML (table <tr class="fxst-dateRow"> + <tr ...fxit-eventrow>)
|
|
// couvrant les 45 pays de CALENDAR_COUNTRIES (bien au-delà des 8 devises majeures).
|
|
|
|
import type { EventCategory } from "@/app/api/calendar/route";
|
|
import { FXSTREET_COUNTRYCODES, FX_NAME_TO_CURRENCY, FX_NAME_TO_CODE } from "./calendar-countries";
|
|
import { classifyOtherTitle } from "./calendar-taxonomy";
|
|
|
|
export interface WideCalendarEvent {
|
|
id: string;
|
|
date: string; // ISO UTC
|
|
currency: string; // ISO 4217 (univers élargi, pas seulement les 8 majeures)
|
|
countryCode: string; // code pays (ex. "US", "EMU", "CN")
|
|
category: EventCategory;
|
|
title: string;
|
|
impact: "high" | "medium" | "low";
|
|
actual: string | null;
|
|
forecast: string | null;
|
|
previous: string | null;
|
|
isPublished: boolean;
|
|
}
|
|
|
|
// ── Catégorie (mêmes heuristiques que teCategory/invCategory) ────────────────
|
|
|
|
function fxCategory(title: string): EventCategory {
|
|
const t = title.toLowerCase();
|
|
if (/\bspeech\b|speaks?\b|testimony|\bpress\s+conf/.test(t)) return "cb_speech";
|
|
if (/\bpmi\b|purchasing\s+managers/.test(t)) return "pmi";
|
|
if (/interest\s+rate|monetary\s+policy|rate\s+decision|bank\s+rate/.test(t)) return "policy_rate";
|
|
if (/inflation|\bcpi\b|\bhicp\b|consumer\s+price|\bppi\b|producer\s+price/.test(t)) return "inflation";
|
|
if (/\bgdp\b|gross\s+domestic|growth\s+rate/.test(t)) return "gdp";
|
|
if (/retail\s+sales|core\s+retail|household\s+spending/.test(t)) return "retail_sales";
|
|
if (/trade\s+balance|current\s+account|balance\s+of\s+trade/.test(t)) return "trade_balance";
|
|
if (/employment|payrolls|nonfarm|jobless|unemployment|job\s+creation|\badp\b|jolts|job\s+openings|job\s+quits|ism\s+\w+\s+employ/.test(t)) return "employment";
|
|
return classifyOtherTitle(title);
|
|
}
|
|
|
|
function impactFromVolatility(n: number): "high" | "medium" | "low" {
|
|
if (n >= 3) return "high";
|
|
if (n >= 2) return "medium";
|
|
return "low";
|
|
}
|
|
|
|
function decodeHTMLEntities(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/ /g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function stripTags(s: string): string {
|
|
return decodeHTMLEntities(s.replace(/<[^>]*>/g, " ").replace(/\s+/g, " "));
|
|
}
|
|
|
|
const MONTHS: Record<string, number> = {
|
|
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
|
|
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
|
};
|
|
|
|
// ── Parsing ────────────────────────────────────────────────────────────────
|
|
|
|
function parseFXStreetHTML(html: string, fromDate: string): WideCalendarEvent[] {
|
|
const events: WideCalendarEvent[] = [];
|
|
const now = new Date();
|
|
|
|
let currentYear = parseInt(fromDate.slice(0, 4), 10);
|
|
let lastMonth = -1;
|
|
let currentDateStr: string | null = null; // YYYY-MM-DD
|
|
|
|
const rowRe =
|
|
/<tr class="fxst-dateRow">\s*<td colspan="9">([^<]+)<\/td>\s*<\/tr>|<tr class="fxst-tr-event[^"]*"\s+data-eventdateid="([^"]*)"\s+data-actual="([^"]*)"\s+data-unit="([^"]*)"\s+data-precision="([^"]*)"\s+data-pot="([^"]*)"\s+data-isbetter="([^"]*)"\s+data-countryname="([^"]+)">([\s\S]*?)<\/tr>/g;
|
|
|
|
let m: RegExpExecArray | null;
|
|
while ((m = rowRe.exec(html)) !== null) {
|
|
if (m[1] !== undefined) {
|
|
// ── Ligne séparateur de date : "Wednesday, Jul 01" ──────────────────
|
|
const dm = m[1].match(/([A-Za-z]+)\s+(\d{1,2})/);
|
|
if (!dm) continue;
|
|
const monIdx = MONTHS[dm[1].slice(0, 3).toLowerCase()];
|
|
if (monIdx === undefined) continue;
|
|
const day = parseInt(dm[2], 10);
|
|
if (lastMonth !== -1 && monIdx < lastMonth) currentYear += 1; // rollover déc → jan
|
|
lastMonth = monIdx;
|
|
currentDateStr = `${currentYear}-${String(monIdx + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
continue;
|
|
}
|
|
|
|
// ── Ligne événement ────────────────────────────────────────────────────
|
|
const [, , eventDateId, , , , , , countryName, body] = m;
|
|
if (!currentDateStr) continue;
|
|
|
|
const ccy = FX_NAME_TO_CURRENCY[countryName];
|
|
const code = FX_NAME_TO_CODE[countryName];
|
|
if (!ccy || !code) continue;
|
|
|
|
const cells = Array.from(body.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g), c => c[1]);
|
|
if (cells.length < 7) continue;
|
|
|
|
const timeM = cells[0].match(/(\d{1,2}):(\d{2})/);
|
|
const time24 = timeM ? `${timeM[1].padStart(2, "0")}:${timeM[2]}` : "00:00";
|
|
const isoDate = `${currentDateStr}T${time24}:00Z`;
|
|
const evDate = new Date(isoDate);
|
|
|
|
const title = stripTags(cells[2]);
|
|
if (!title) continue;
|
|
|
|
const volM = stripTags(cells[3]).match(/\d+/);
|
|
const impact = impactFromVolatility(volM ? parseInt(volM[0], 10) : 0);
|
|
|
|
const actual = stripTags(cells[4]) || null;
|
|
const forecast = stripTags(cells[5]) || null;
|
|
const previous = stripTags(cells[6]) || null;
|
|
|
|
events.push({
|
|
id: `il_${eventDateId}`,
|
|
date: isoDate,
|
|
currency: ccy,
|
|
countryCode: code,
|
|
category: fxCategory(title),
|
|
title,
|
|
impact,
|
|
actual,
|
|
forecast,
|
|
previous,
|
|
isPublished: actual !== null || evDate < now,
|
|
});
|
|
}
|
|
|
|
events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
return events;
|
|
}
|
|
|
|
// ── Fetch ──────────────────────────────────────────────────────────────────
|
|
// fromDate / toDate au format YYYY-MM-DD
|
|
|
|
export async function fetchFXStreetCalendar(fromDate: string, toDate: string): Promise<WideCalendarEvent[]> {
|
|
const start = fromDate.replace(/-/g, "");
|
|
const end = toDate.replace(/-/g, "");
|
|
|
|
const url =
|
|
`https://calendar.fxstreet.com/EventDateWidget/GetMain` +
|
|
`?culture=en-US&timezone=UTC&start=${start}&end=${end}&view=range&rows=5000` +
|
|
`&countrycode=${FXSTREET_COUNTRYCODES}`;
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
next: { revalidate: 1800 },
|
|
headers: {
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
"Referer": "https://investinglive.com/EconomicCalendar",
|
|
},
|
|
});
|
|
if (!res.ok) { console.warn("[fxstreet] HTTP", res.status); return []; }
|
|
const html = await res.text();
|
|
return parseFXStreetHTML(html, fromDate);
|
|
} catch (err) {
|
|
console.error("[fxstreet] error:", err);
|
|
return [];
|
|
}
|
|
}
|