mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-08-22 17:08:05 +00:00
auto: sync 2026-06-02 17:07
This commit is contained in:
@@ -415,7 +415,7 @@ export default function CurrencyCard({ currency, expectations, yields, sentiment
|
|||||||
{(inflFilter === "all" || inflFilter === "yoy") && (
|
{(inflFilter === "all" || inflFilter === "yoy") && (
|
||||||
<>
|
<>
|
||||||
<Row label="Core CPI YoY" ind={inds?.cpiCore ?? null} unit="%" consensus={fc?.cpiCore ?? fc?.cpi ?? null} surpriseVsCons={fc?.cpiSurprise ?? null} />
|
<Row label="Core CPI YoY" ind={inds?.cpiCore ?? null} unit="%" consensus={fc?.cpiCore ?? fc?.cpi ?? null} surpriseVsCons={fc?.cpiSurprise ?? null} />
|
||||||
<Row label="Inflation Rate YoY" ind={inds?.cpiYoY ?? null} unit="%" />
|
<Row label="Inflation Rate YoY" ind={inds?.cpiYoY ?? null} unit="%" consensus={fc?.cpi ?? null} />
|
||||||
{inds?.commodityPricesYoY && (
|
{inds?.commodityPricesYoY && (
|
||||||
<Row label="Commodity Prices YoY" ind={inds.commodityPricesYoY} unit="%" info="AUD — RBA Commodity Price Index (TradingEconomics)" />
|
<Row label="Commodity Prices YoY" ind={inds.commodityPricesYoY} unit="%" info="AUD — RBA Commodity Price Index (TradingEconomics)" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+85
-154
@@ -1,188 +1,119 @@
|
|||||||
// lib/investing.ts
|
// lib/investing.ts
|
||||||
// Investing.com Economic Calendar — POST API (JSON avec HTML interne)
|
// Scrape investing.com economic calendar (inflation events).
|
||||||
// Endpoint : POST https://fr.investing.com/economic-calendar/Service/getCalendarFilteredData
|
// Uses the AJAX endpoint which returns HTML inside JSON.
|
||||||
// Auth : aucune — Cloudflare peut rate-limiter en test mais pas en prod avec cache 30min
|
//
|
||||||
// Timezone : 55 = UTC
|
// Mapping confirmed by user:
|
||||||
|
// "CPI (YoY)" = Inflation Rate YoY → cpiYoY
|
||||||
|
// "Core CPI (YoY)" = Core Inflation Rate YoY → cpiCore
|
||||||
|
// "CPI (MoM)" = Inflation Rate MoM → cpiMoM
|
||||||
|
// "Core CPI (MoM)" = Core Inflation Rate MoM → cpiCoreMoM
|
||||||
|
// "PPI (MoM)" = PPI MoM → ppiMoM
|
||||||
|
|
||||||
import type { Currency } from "./types";
|
import type { Currency } from "./types";
|
||||||
import type { EventCategory } from "@/app/api/calendar/route";
|
|
||||||
|
|
||||||
// ── Country IDs (Investing.com internal) ─────────────────────────────────────
|
|
||||||
|
|
||||||
const INV_COUNTRY_IDS: Record<Currency, number> = {
|
const INV_COUNTRY_IDS: Record<Currency, number> = {
|
||||||
USD: 5,
|
USD: 5, EUR: 22, GBP: 4, JPY: 25, CHF: 35, CAD: 32, AUD: 6, NZD: 36,
|
||||||
GBP: 4,
|
|
||||||
AUD: 25,
|
|
||||||
CAD: 6,
|
|
||||||
EUR: 72, // Euro Area aggregate
|
|
||||||
NZD: 43,
|
|
||||||
JPY: 35,
|
|
||||||
CHF: 12,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Currency code → Currency type ─────────────────────────────────────────────
|
const INV_CCY_TO_CCY: Record<string, Currency> = {
|
||||||
|
USD: "USD", EUR: "EUR", GBP: "GBP", JPY: "JPY",
|
||||||
const INV_CCY_MAP: Record<string, Currency> = {
|
CHF: "CHF", CAD: "CAD", AUD: "AUD", NZD: "NZD",
|
||||||
USD: "USD", GBP: "GBP", AUD: "AUD", CAD: "CAD",
|
|
||||||
EUR: "EUR", NZD: "NZD", JPY: "JPY", CHF: "CHF",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Category detection (noms en français) ─────────────────────────────────────
|
export interface InvestingInflationForecasts {
|
||||||
|
cpiYoY: string | null;
|
||||||
function invCategory(title: string): EventCategory {
|
cpiCore: string | null;
|
||||||
const t = title.toLowerCase();
|
cpiMoM: string | null;
|
||||||
if (/\bpmi\b|purchasing\s+managers/.test(t)) return "pmi";
|
cpiCoreMoM: string | null;
|
||||||
if (/taux d.intér|décision.*taux|politique monétaire|bank\s+rate/.test(t)) return "policy_rate";
|
ppiMoM: string | null;
|
||||||
if (/discours|allocution|s.exprime|parole|communiqué|banque central/.test(t)) return "cb_speech";
|
|
||||||
if (/\bipc\b|\bcpi\b|\bhicp\b|\bipch\b|inflation|prix à la conso|prix\s+consom/.test(t)) return "inflation";
|
|
||||||
if (/\bpib\b|\bgdp\b|croissance économ/.test(t)) return "gdp";
|
|
||||||
if (/ventes au détail|retail\s+sales/.test(t)) return "retail_sales";
|
|
||||||
if (/balance commerc|balance des paiements|exportations|importations/.test(t)) return "trade_balance";
|
|
||||||
if (/emploi|chômage|payrolls|nonfarm|chomage|travail|emplois créés/.test(t)) return "employment";
|
|
||||||
return "other";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Importance (bull1/2/3 → impact) ───────────────────────────────────────────
|
function parseInvHTML(html: string, now: Date): Partial<Record<Currency, InvestingInflationForecasts>> {
|
||||||
|
const result: Partial<Record<Currency, InvestingInflationForecasts>> = {};
|
||||||
function invImpact(bull: string): "high" | "medium" | "low" {
|
const rowPat = /eventRowId_(\d+)[^>]+data-event-datetime="([^"]+)"[^>]*>([\s\S]*?)(?=eventRowId_|$)/g;
|
||||||
if (bull === "bull3") return "high";
|
|
||||||
if (bull === "bull2") return "medium";
|
|
||||||
return "low";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Normalise valeurs numériques françaises ──────────────────────────────────
|
|
||||||
// "0,6%" → "0.6%" | "52,3" → "52.3" | " " → null
|
|
||||||
|
|
||||||
function normVal(raw: string | null | undefined): string | null {
|
|
||||||
if (!raw) return null;
|
|
||||||
const s = raw.replace(/ /g, "").trim();
|
|
||||||
if (!s || s === "–" || s === "-") return null;
|
|
||||||
return s.replace(",", ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Public type ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface InvCalendarEvent {
|
|
||||||
id: string;
|
|
||||||
date: string; // ISO UTC
|
|
||||||
currency: Currency;
|
|
||||||
category: EventCategory;
|
|
||||||
title: string;
|
|
||||||
impact: "high" | "medium" | "low";
|
|
||||||
actual: string | null;
|
|
||||||
forecast: string | null;
|
|
||||||
previous: string | null;
|
|
||||||
isPublished: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Parse HTML dans la réponse JSON de l'API ──────────────────────────────────
|
|
||||||
|
|
||||||
function parseInvestingHTML(html: string): InvCalendarEvent[] {
|
|
||||||
const events: InvCalendarEvent[] = [];
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// Sépare chaque ligne événement
|
|
||||||
const ROW = /<tr\s+id="eventRowId_(\d+)"\s+class="js-event-item[^"]*"\s+event_attr_ID="(\d+)"\s+data-event-datetime="([^"]+)"[^>]*>([\s\S]*?)(?=<tr\s+id="eventRowId_|<\/tbody>)/g;
|
|
||||||
|
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = ROW.exec(html)) !== null) {
|
|
||||||
const [, rowId, , dateRaw, body] = m;
|
|
||||||
|
|
||||||
// Devise : " AUD" après le span ceFlags
|
while ((m = rowPat.exec(html)) !== null) {
|
||||||
const ccyMatch = body.match(/class="ceFlags[^"]*"[^>]*> <\/span>\s*([A-Z]{2,4})/);
|
const [, rowId, dtStr, body] = m;
|
||||||
if (!ccyMatch) continue;
|
const evDate = new Date(dtStr.replace(/\//g, "-"));
|
||||||
const ccy = INV_CCY_MAP[ccyMatch[1].trim()];
|
if (evDate <= now) continue;
|
||||||
|
|
||||||
|
const ccyM = body.match(/ceFlags[^>]*> <\/span>\s*(\w+)/);
|
||||||
|
if (!ccyM) continue;
|
||||||
|
const ccy = INV_CCY_TO_CCY[ccyM[1].trim()];
|
||||||
if (!ccy) continue;
|
if (!ccy) continue;
|
||||||
|
|
||||||
// DateTime UTC "2026/06/01 01:00:00" → "2026-06-01T01:00:00Z"
|
// Event name (skip country-specific EU sub-events like French/German CPI)
|
||||||
const isoDate = dateRaw.replace(/\//g, "-").replace(" ", "T") + "Z";
|
const nameM = body.match(/target="_blank">\s*([^<\n]+?)\s*<\/a>/);
|
||||||
const evDate = new Date(isoDate);
|
if (!nameM) continue;
|
||||||
|
const name = nameM[1].trim();
|
||||||
|
if (/\bfrench\b|\bgerman\b|\bitalian\b|\bspanish\b|\bdutch\b|\bbelgian\b/i.test(name)) continue;
|
||||||
|
|
||||||
// Importance : data-img_key="bull1/2/3"
|
// Forecast value
|
||||||
const bullMatch = body.match(/data-img_key="(bull\d)"/);
|
const foreM = body.match(new RegExp(`eventForecast_${rowId}">([^<]+)`));
|
||||||
const impact = bullMatch ? invImpact(bullMatch[1]) : "low";
|
const fore = foreM ? foreM[1].replace(/ /g, "").trim() : "";
|
||||||
|
if (!fore) continue;
|
||||||
|
|
||||||
// Nom de l'événement : texte dans <a href="/economic-calendar/...">
|
if (!result[ccy]) result[ccy] = { cpiYoY: null, cpiCore: null, cpiMoM: null, cpiCoreMoM: null, ppiMoM: null };
|
||||||
const nameMatch = body.match(/<a\s+href="\/economic-calendar\/[^"]*"[^>]*>\s*([\s\S]*?)\s*<\/a>/);
|
const r = result[ccy]!;
|
||||||
const title = nameMatch
|
const n = name.toLowerCase();
|
||||||
? nameMatch[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim()
|
|
||||||
: "";
|
|
||||||
if (!title) continue;
|
|
||||||
|
|
||||||
// Valeurs dans les td id="eventActual_XXX", eventForecast_XXX, eventPrevious_XXX
|
if (!r.ppiMoM && /\bppi\b.*\bmom\b/i.test(n)) r.ppiMoM = fore;
|
||||||
const actualRaw = body.match(new RegExp(`id="eventActual_${rowId}"[^>]*>([^<]*)<`));
|
else if (!r.cpiCoreMoM && /core\s+cpi.*mom/i.test(n)) r.cpiCoreMoM = fore;
|
||||||
const forecastRaw = body.match(new RegExp(`id="eventForecast_${rowId}"[^>]*>([^<]*)<`));
|
else if (!r.cpiCore && /core\s+cpi.*yoy/i.test(n)) r.cpiCore = fore;
|
||||||
const prevSpan = body.match(new RegExp(`id="eventPrevious_${rowId}"[^>]*>\\s*(?:<span[^>]*>)?([^<]*)(?:<\\/span>)?`));
|
else if (!r.cpiMoM && /^cpi\b.*mom/i.test(n)) r.cpiMoM = fore;
|
||||||
|
else if (!r.cpiYoY && /^cpi\b.*yoy/i.test(n)) r.cpiYoY = fore;
|
||||||
const actual = normVal(actualRaw?.[1]);
|
|
||||||
const forecast = normVal(forecastRaw?.[1]);
|
|
||||||
const previous = normVal(prevSpan?.[1]);
|
|
||||||
|
|
||||||
events.push({
|
|
||||||
id: `inv_${rowId}`,
|
|
||||||
date: isoDate,
|
|
||||||
currency: ccy,
|
|
||||||
category: invCategory(title),
|
|
||||||
title,
|
|
||||||
impact,
|
|
||||||
actual,
|
|
||||||
forecast,
|
|
||||||
previous,
|
|
||||||
isPublished: actual !== null || evDate < now,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return events;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fetch depuis l'API Investing.com ─────────────────────────────────────────
|
export async function fetchInvestingInflationForecasts(
|
||||||
|
fromDate?: string,
|
||||||
export async function fetchInvestingCalendar(
|
toDate?: string,
|
||||||
fromDate: string,
|
): Promise<Partial<Record<Currency, InvestingInflationForecasts>>> {
|
||||||
toDate: string,
|
|
||||||
): Promise<InvCalendarEvent[]> {
|
|
||||||
// Paramètres : toutes les devises + toutes les importances
|
|
||||||
const countryParams = Object.values(INV_COUNTRY_IDS)
|
|
||||||
.map(id => `country%5B%5D=${id}`)
|
|
||||||
.join("&");
|
|
||||||
|
|
||||||
const body = [
|
|
||||||
countryParams,
|
|
||||||
"importance%5B%5D=1&importance%5B%5D=2&importance%5B%5D=3",
|
|
||||||
"timeZone=55", // UTC
|
|
||||||
"timeFilter=timeRemain",
|
|
||||||
"currentTab=custom",
|
|
||||||
`dateFrom=${fromDate}&dateTo=${toDate}`,
|
|
||||||
"submitFilters=1",
|
|
||||||
"limit_from=0",
|
|
||||||
].join("&");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const from = fromDate ?? new Date().toISOString().slice(0, 10);
|
||||||
|
const to = toDate ?? (() => {
|
||||||
|
const d = new Date(); d.setDate(d.getDate() + 21);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const countryParams = Object.values(INV_COUNTRY_IDS)
|
||||||
|
.map((id) => `country%5B%5D=${id}`).join("&");
|
||||||
|
|
||||||
|
const reqBody = [
|
||||||
|
countryParams,
|
||||||
|
"importance%5B%5D=1&importance%5B%5D=2&importance%5B%5D=3",
|
||||||
|
"category%5B%5D=_inflation",
|
||||||
|
`dateFrom=${from}&dateTo=${to}`,
|
||||||
|
"currentTab=custom&submitFilters=1&limit_from=0",
|
||||||
|
].join("&");
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
"https://fr.investing.com/economic-calendar/Service/getCalendarFilteredData",
|
"https://www.investing.com/economic-calendar/Service/getCalendarFilteredData",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
next: { revalidate: 1800 }, // cache 30 min
|
next: { revalidate: 1800 },
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
|
||||||
"X-Requested-With": "XMLHttpRequest",
|
|
||||||
"Referer": "https://fr.investing.com/economic-calendar/",
|
|
||||||
"Origin": "https://fr.investing.com",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"Referer": "https://www.investing.com/economic-calendar/",
|
||||||
|
"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",
|
||||||
},
|
},
|
||||||
body,
|
body: reqBody,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
|
||||||
console.warn("[Investing] HTTP", res.status);
|
if (!res.ok) { console.warn("[investing] HTTP", res.status); return {}; }
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const json = await res.json() as { data?: string };
|
const json = await res.json() as { data?: string };
|
||||||
if (!json?.data) return [];
|
if (!json.data) return {};
|
||||||
return parseInvestingHTML(json.data);
|
|
||||||
|
const now = fromDate ? new Date(fromDate + "T00:00:00Z") : new Date();
|
||||||
|
return parseInvHTML(json.data, now);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[Investing] fetch error:", err);
|
console.error("[investing] error:", err);
|
||||||
return [];
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user