mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
auto: sync 2026-06-03 00:59
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchAllNews } from "@/lib/newsfeed";
|
||||
import type { NewsItem } from "@/lib/newsfeed";
|
||||
|
||||
export type { NewsItem } from "@/lib/newsfeed";
|
||||
|
||||
let _cache: { data: NewsItem[]; ts: number } | null = null;
|
||||
const TTL = 30 * 60_000; // 30 min
|
||||
|
||||
export async function GET() {
|
||||
if (_cache && Date.now() - _cache.ts < TTL) {
|
||||
return NextResponse.json({ items: _cache.data, fetchedAt: new Date(_cache.ts).toISOString() });
|
||||
}
|
||||
|
||||
const items = await fetchAllNews();
|
||||
_cache = { data: items, ts: Date.now() };
|
||||
|
||||
return NextResponse.json(
|
||||
{ items, fetchedAt: new Date().toISOString() },
|
||||
{ headers: { "Cache-Control": "s-maxage=1800, stale-while-revalidate=3600" } }
|
||||
);
|
||||
}
|
||||
+29
-3
@@ -11,7 +11,9 @@ import DriversBar from "@/components/DriversBar";
|
||||
import CalendarTab from "@/components/CalendarTab";
|
||||
import SentimentPairsTab from "@/components/SentimentPairsTab";
|
||||
import YieldsTab from "@/components/YieldsTab";
|
||||
import NewsTab from "@/components/NewsTab";
|
||||
import type { CalendarEvent } from "@/app/api/calendar/route";
|
||||
import type { NewsItem } from "@/app/api/news/route";
|
||||
|
||||
const REFRESH_MS = parseInt(process.env.NEXT_PUBLIC_REFRESH_INTERVAL_MS ?? "3600000");
|
||||
|
||||
@@ -23,7 +25,9 @@ export default function Dashboard() {
|
||||
const [cot, setCot] = useState<Record<string, CotEntry> | null>(null);
|
||||
const [calEvents, setCalEvents] = useState<CalendarEvent[]>([]);
|
||||
const [nextWeekAvail, setNextWeekAvail] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs" | "yields">("dashboard");
|
||||
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs" | "yields" | "news">("dashboard");
|
||||
const [newsItems, setNewsItems] = useState<NewsItem[]>([]);
|
||||
const [newsLoading, setNewsLoading] = useState(false);
|
||||
const [rawSymbols, setRawSymbols] = useState<Array<{ name: string; longPercentage: number; shortPercentage: number; totalPositions: number }> | null>(null);
|
||||
const [rateProbabilities, setRateProbabilities] = useState<RateProbData | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
||||
@@ -212,6 +216,23 @@ export default function Dashboard() {
|
||||
return () => clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
const refreshNews = useCallback(async () => {
|
||||
setNewsLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/news");
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setNewsItems(json.items ?? []);
|
||||
}
|
||||
} finally {
|
||||
setNewsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "news" && newsItems.length === 0) refreshNews();
|
||||
}, [activeTab, newsItems.length, refreshNews]);
|
||||
|
||||
const handleDivergenceUpdate = useCallback((currency: Currency, score: number) => {
|
||||
setActiveDivergences((prev) => {
|
||||
const filtered = prev.filter((d) => d.currency !== currency);
|
||||
@@ -271,7 +292,7 @@ export default function Dashboard() {
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="flex gap-0 border-b border-slate-800 mb-4">
|
||||
{(["dashboard", "calendar", "pairs", "yields"] as const).map((tab) => (
|
||||
{(["dashboard", "calendar", "pairs", "yields", "news"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -284,7 +305,8 @@ export default function Dashboard() {
|
||||
{tab === "dashboard" ? "Dashboard"
|
||||
: tab === "calendar" ? "📅 Calendrier"
|
||||
: tab === "pairs" ? "↕ Paires"
|
||||
: "📈 Yields 10Y"}
|
||||
: tab === "yields" ? "📈 Yields 10Y"
|
||||
: "📰 Actualités"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -345,6 +367,10 @@ export default function Dashboard() {
|
||||
<YieldsTab yieldsData={yields} />
|
||||
)}
|
||||
|
||||
{activeTab === "news" && (
|
||||
<NewsTab items={newsItems} loading={newsLoading} onRefresh={refreshNews} />
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center gap-5 flex-wrap text-[10px] text-slate-600">
|
||||
<div className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" /> Haussier / Sous-évalué</div>
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { ExternalLink, RefreshCw, TrendingUp, TrendingDown, Minus, Loader2, Zap } from "lucide-react";
|
||||
import type { NewsItem } from "@/app/api/news/route";
|
||||
import type { Currency } from "@/lib/types";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const CCY_FLAGS: Record<Currency, string> = {
|
||||
USD: "🇺🇸", EUR: "🇪🇺", GBP: "🇬🇧", JPY: "🇯🇵",
|
||||
CHF: "🇨🇭", CAD: "🇨🇦", AUD: "🇦🇺", NZD: "🇳🇿",
|
||||
};
|
||||
|
||||
const CCY_LIST: Currency[] = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"];
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
"InvestingLive": "bg-amber-500/20 text-amber-400 border-amber-500/30",
|
||||
"Reuters": "bg-orange-500/20 text-orange-400 border-orange-500/30",
|
||||
"Bloomberg Economics": "bg-sky-500/20 text-sky-400 border-sky-500/30",
|
||||
"Bloomberg CB": "bg-sky-500/20 text-sky-400 border-sky-500/30",
|
||||
"Bloomberg FX": "bg-sky-500/20 text-sky-400 border-sky-500/30",
|
||||
};
|
||||
|
||||
const CAT_COLORS: Record<string, string> = {
|
||||
"Risk-Off": "bg-red-500/15 text-red-400",
|
||||
"Risk-On": "bg-emerald-500/15 text-emerald-400",
|
||||
"Géopolitique": "bg-purple-500/15 text-purple-400",
|
||||
"Énergie": "bg-yellow-500/15 text-yellow-400",
|
||||
"Banque Centrale": "bg-blue-500/15 text-blue-400",
|
||||
"Commodités": "bg-orange-500/15 text-orange-400",
|
||||
"Chine": "bg-red-600/15 text-red-400",
|
||||
"Données Macro": "bg-slate-500/15 text-slate-400",
|
||||
"Commerce": "bg-indigo-500/15 text-indigo-400",
|
||||
"Agriculture": "bg-green-500/15 text-green-400",
|
||||
"Or": "bg-yellow-600/15 text-yellow-400",
|
||||
"Métaux": "bg-gray-400/15 text-gray-300",
|
||||
};
|
||||
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
const diff = Date.now() - new Date(isoDate).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "à l'instant";
|
||||
if (mins < 60) return `il y a ${mins}min`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `il y a ${hrs}h`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `il y a ${days}j`;
|
||||
}
|
||||
|
||||
// ── Composant principal ───────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
items: NewsItem[];
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function NewsTab({ items, loading, onRefresh }: Props) {
|
||||
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
|
||||
const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return items.filter(item => {
|
||||
if (filterCcy !== "ALL" && !item.impacts.some(i => i.ccy === filterCcy)) return false;
|
||||
if (filterDir !== "all") {
|
||||
const hasDir = filterCcy === "ALL"
|
||||
? item.impacts.some(i => i.direction === filterDir)
|
||||
: item.impacts.some(i => i.ccy === filterCcy && i.direction === filterDir);
|
||||
if (!hasDir) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [items, filterCcy, filterDir]);
|
||||
|
||||
// Compter les impacts par devise pour les badges de filtre
|
||||
const ccyCount = useMemo(() => {
|
||||
const counts: Partial<Record<Currency, { bull: number; bear: number }>> = {};
|
||||
for (const item of items) {
|
||||
for (const imp of item.impacts) {
|
||||
if (!counts[imp.ccy]) counts[imp.ccy] = { bull: 0, bear: 0 };
|
||||
if (imp.direction === "bullish") counts[imp.ccy]!.bull++;
|
||||
if (imp.direction === "bearish") counts[imp.ccy]!.bear++;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Barre de filtres */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-semibold">
|
||||
Filtrer par devise
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{(["all", "bullish", "bearish"] as const).map(d => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setFilterDir(d)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full font-semibold transition-colors ${
|
||||
filterDir === d
|
||||
? d === "bullish" ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
||||
: d === "bearish" ? "bg-red-500/20 text-red-400 border border-red-500/30"
|
||||
: "bg-slate-700 text-slate-300"
|
||||
: "text-slate-500 hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{d === "all" ? "Tous" : d === "bullish" ? "↑ Haussier" : "↓ Baissier"}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-slate-300 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={10} className={loading ? "animate-spin" : ""} />
|
||||
{loading ? "..." : "Actualiser"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setFilterCcy("ALL")}
|
||||
className={`text-[11px] px-3 py-1 rounded-full font-semibold transition-colors ${
|
||||
filterCcy === "ALL" ? "bg-slate-700 text-white" : "text-slate-500 hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
Toutes ({items.length})
|
||||
</button>
|
||||
{CCY_LIST.map(ccy => {
|
||||
const cnt = ccyCount[ccy];
|
||||
if (!cnt) return null;
|
||||
const isActive = filterCcy === ccy;
|
||||
return (
|
||||
<button
|
||||
key={ccy}
|
||||
onClick={() => setFilterCcy(ccy === filterCcy ? "ALL" : ccy)}
|
||||
className={`flex items-center gap-1.5 text-[11px] px-3 py-1 rounded-full font-semibold transition-colors ${
|
||||
isActive ? "bg-amber-500/20 text-amber-400 border border-amber-500/30" : "text-slate-500 hover:text-slate-300 border border-slate-700/50"
|
||||
}`}
|
||||
>
|
||||
<span>{CCY_FLAGS[ccy]}</span>
|
||||
<span>{ccy}</span>
|
||||
<span className="flex items-center gap-0.5 text-[9px]">
|
||||
{cnt.bull > 0 && <span className="text-emerald-400">↑{cnt.bull}</span>}
|
||||
{cnt.bear > 0 && <span className="text-red-400">↓{cnt.bear}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste de news */}
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-slate-600">
|
||||
<Loader2 size={20} className="animate-spin mr-2" />
|
||||
<span className="text-sm">Chargement des actualités…</span>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-600 text-sm">
|
||||
Aucune actualité pour ce filtre.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map(item => (
|
||||
<NewsCard key={item.id} item={item} activeCcy={filterCcy === "ALL" ? null : filterCcy} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── NewsCard ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function NewsCard({ item, activeCcy }: { item: NewsItem; activeCcy: Currency | null }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const visibleImpacts = activeCcy
|
||||
? item.impacts.filter(i => i.ccy === activeCcy)
|
||||
: item.impacts;
|
||||
|
||||
const overallDir = visibleImpacts.some(i => i.direction === "bullish") && visibleImpacts.some(i => i.direction === "bearish")
|
||||
? "mixed"
|
||||
: visibleImpacts.some(i => i.direction === "bullish") ? "bullish"
|
||||
: visibleImpacts.some(i => i.direction === "bearish") ? "bearish"
|
||||
: "neutral";
|
||||
|
||||
const borderColor =
|
||||
overallDir === "bullish" ? "border-emerald-500/20" :
|
||||
overallDir === "bearish" ? "border-red-500/20" :
|
||||
overallDir === "mixed" ? "border-amber-500/20" :
|
||||
"border-slate-700/30";
|
||||
|
||||
const bgColor =
|
||||
overallDir === "bullish" ? "bg-emerald-500/5" :
|
||||
overallDir === "bearish" ? "bg-red-500/5" :
|
||||
"bg-slate-800/30";
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border ${borderColor} ${bgColor} overflow-hidden`}>
|
||||
<div className="p-3">
|
||||
{/* Header : source + date */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-[9px] font-semibold px-2 py-0.5 rounded-full border ${SOURCE_COLORS[item.source] ?? "bg-slate-700/40 text-slate-400 border-slate-700/30"}`}>
|
||||
{item.source}
|
||||
</span>
|
||||
{item.categories.slice(0, 2).map(cat => (
|
||||
<span key={cat} className={`text-[9px] px-1.5 py-0.5 rounded-full ${CAT_COLORS[cat] ?? "bg-slate-700/20 text-slate-500"}`}>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-600 shrink-0">{formatRelativeTime(item.publishedAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-start gap-1.5 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-[12px] text-slate-200 leading-snug font-medium group-hover:text-white">
|
||||
{item.title}
|
||||
</span>
|
||||
<ExternalLink size={10} className="text-slate-600 group-hover:text-slate-400 shrink-0 mt-0.5" />
|
||||
</a>
|
||||
|
||||
{/* Summary si dispo */}
|
||||
{item.summary && (
|
||||
<p className="text-[11px] text-slate-500 mt-1 leading-relaxed line-clamp-2">
|
||||
{item.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Impact badges */}
|
||||
{visibleImpacts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{visibleImpacts.map(imp => (
|
||||
<div
|
||||
key={imp.ccy}
|
||||
className={`flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full font-semibold ${
|
||||
imp.direction === "bullish" ? "bg-emerald-500/15 text-emerald-400" :
|
||||
imp.direction === "bearish" ? "bg-red-500/15 text-red-400" :
|
||||
"bg-slate-700/40 text-slate-400"
|
||||
}`}
|
||||
title={imp.reason}
|
||||
>
|
||||
{CCY_FLAGS[imp.ccy]} {imp.ccy}
|
||||
{imp.direction === "bullish" ? <TrendingUp size={9} /> :
|
||||
imp.direction === "bearish" ? <TrendingDown size={9} /> :
|
||||
<Minus size={9} />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{visibleImpacts.length > 0 && (
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="text-[9px] text-slate-600 hover:text-slate-400 px-1.5 py-0.5 rounded-full border border-slate-700/30"
|
||||
>
|
||||
{expanded ? "Masquer" : "Détail"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Détail des raisons */}
|
||||
{expanded && visibleImpacts.length > 0 && (
|
||||
<div className="mt-2 space-y-1 border-t border-slate-700/30 pt-2">
|
||||
{visibleImpacts.map(imp => (
|
||||
<div key={imp.ccy} className="flex items-start gap-1.5 text-[10px]">
|
||||
<span className={`shrink-0 font-bold ${
|
||||
imp.direction === "bullish" ? "text-emerald-400" :
|
||||
imp.direction === "bearish" ? "text-red-400" : "text-slate-500"
|
||||
}`}>
|
||||
{CCY_FLAGS[imp.ccy]} {imp.ccy} {imp.direction === "bullish" ? "↑" : imp.direction === "bearish" ? "↓" : "→"}
|
||||
</span>
|
||||
<span className="text-slate-500 leading-relaxed">{imp.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+581
@@ -0,0 +1,581 @@
|
||||
// lib/newsfeed.ts
|
||||
// Agrège les news forex/macro depuis plusieurs sources publiques :
|
||||
// 1. investinglive.com/forex/ — articles forex de Giuseppe Dellamotta
|
||||
// 2. Reuters RSS — flux marchés publics
|
||||
// 3. Bloomberg meta tags — best-effort (titres publics malgré paywall)
|
||||
// Applique un moteur de pertinence devise pour chaque article.
|
||||
|
||||
import type { Currency } from "./types";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface NewsImpact {
|
||||
ccy: Currency;
|
||||
direction: "bullish" | "bearish" | "neutral";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
source: string;
|
||||
publishedAt: string; // ISO date string
|
||||
summary?: string;
|
||||
impacts: NewsImpact[];
|
||||
categories: string[]; // "Énergie", "Banque Centrale", "Géopolitique", etc.
|
||||
}
|
||||
|
||||
// ── Moteur de pertinence devise ───────────────────────────────────────────────
|
||||
|
||||
interface ImpactRule {
|
||||
pattern: RegExp;
|
||||
impacts: { ccy: Currency; dir: "bullish" | "bearish" | "neutral" }[];
|
||||
categories: string[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const IMPACT_RULES: ImpactRule[] = [
|
||||
// ── Fed / USD ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(hawkish fed|fed hike|fed rate (up|rise)|us inflation (surge|higher|above)|fomc (hike|tighten))\b/i,
|
||||
impacts: [{ ccy: "USD", dir: "bullish" }],
|
||||
categories: ["Banque Centrale", "USD"],
|
||||
reason: "Fed hawkish → USD haussier",
|
||||
},
|
||||
{
|
||||
pattern: /\b(fed (cut|ease|pivot|pause)|dovish fed|us inflation (cool|drop|lower|ease)|fomc (cut|ease|pause))\b/i,
|
||||
impacts: [{ ccy: "USD", dir: "bearish" }],
|
||||
categories: ["Banque Centrale", "USD"],
|
||||
reason: "Fed dovish → USD baissier",
|
||||
},
|
||||
{
|
||||
pattern: /\b(fed|fomc|federal reserve|powell|us monetary policy|us interest rate)\b/i,
|
||||
impacts: [{ ccy: "USD", dir: "neutral" }],
|
||||
categories: ["Banque Centrale", "USD"],
|
||||
reason: "Politique monétaire Fed",
|
||||
},
|
||||
|
||||
// ── ECB / EUR ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(ecb (cut|ease|lower rate|dovish)|lagarde (cut|dovish|ease)|eurozone (recession|weak|slow|deflat))\b/i,
|
||||
impacts: [{ ccy: "EUR", dir: "bearish" }],
|
||||
categories: ["Banque Centrale", "EUR"],
|
||||
reason: "BCE dovish → EUR baissier",
|
||||
},
|
||||
{
|
||||
pattern: /\b(ecb (hike|hawkish|hold|tighten)|lagarde (hawkish|hike)|eurozone inflation (higher|above|surge))\b/i,
|
||||
impacts: [{ ccy: "EUR", dir: "bullish" }],
|
||||
categories: ["Banque Centrale", "EUR"],
|
||||
reason: "BCE hawkish → EUR haussier",
|
||||
},
|
||||
{
|
||||
pattern: /\b(ecb|european central bank|lagarde|eurozone (gdp|cpi|inflation|rate|pmi))\b/i,
|
||||
impacts: [{ ccy: "EUR", dir: "neutral" }],
|
||||
categories: ["Banque Centrale", "EUR"],
|
||||
reason: "Politique monétaire BCE",
|
||||
},
|
||||
|
||||
// ── BoE / GBP ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(boe (cut|ease|dovish)|bank of england (cut|ease|pause)|uk inflation (drop|lower|cool))\b/i,
|
||||
impacts: [{ ccy: "GBP", dir: "bearish" }],
|
||||
categories: ["Banque Centrale", "GBP"],
|
||||
reason: "BoE dovish → GBP baissier",
|
||||
},
|
||||
{
|
||||
pattern: /\b(boe (hike|hawkish)|bank of england (hike|hawkish)|uk inflation (surge|higher|above|sticky))\b/i,
|
||||
impacts: [{ ccy: "GBP", dir: "bullish" }],
|
||||
categories: ["Banque Centrale", "GBP"],
|
||||
reason: "BoE hawkish → GBP haussier",
|
||||
},
|
||||
{
|
||||
pattern: /\b(bank of england|boe|sterling|uk (gdp|cpi|inflation|jobs|pmi|rate))\b/i,
|
||||
impacts: [{ ccy: "GBP", dir: "neutral" }],
|
||||
categories: ["Banque Centrale", "GBP"],
|
||||
reason: "Politique monétaire BoE",
|
||||
},
|
||||
|
||||
// ── BoJ / JPY ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(boj (hike|tighten|hawkish)|bank of japan (hike|raise|hawkish)|yen (strengthens?|stronger|surge)|japan (rate hike|wage))\b/i,
|
||||
impacts: [{ ccy: "JPY", dir: "bullish" }],
|
||||
categories: ["Banque Centrale", "JPY"],
|
||||
reason: "BoJ hawkish → JPY haussier",
|
||||
},
|
||||
{
|
||||
pattern: /\b(boj (cut|ease|hold|pause|dovish)|yen (weak|falls?|slide)|japan (rate hold|deflat))\b/i,
|
||||
impacts: [{ ccy: "JPY", dir: "bearish" }],
|
||||
categories: ["Banque Centrale", "JPY"],
|
||||
reason: "BoJ dovish → JPY baissier",
|
||||
},
|
||||
{
|
||||
pattern: /\b(yen intervention|mof japan|ministry of finance japan|japan (intervene|verbal intervention))\b/i,
|
||||
impacts: [{ ccy: "JPY", dir: "bullish" }],
|
||||
categories: ["Géopolitique", "JPY"],
|
||||
reason: "Intervention MoF → JPY haussier (défense du plancher)",
|
||||
},
|
||||
{
|
||||
pattern: /\b(bank of japan|boj|yen|japan (gdp|cpi|inflation|monetary|pmi))\b/i,
|
||||
impacts: [{ ccy: "JPY", dir: "neutral" }],
|
||||
categories: ["Banque Centrale", "JPY"],
|
||||
reason: "Politique monétaire BoJ",
|
||||
},
|
||||
|
||||
// ── SNB / CHF ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(snb|swiss national bank|swiss franc|switzerland (rate|inflation|gdp))\b/i,
|
||||
impacts: [{ ccy: "CHF", dir: "neutral" }],
|
||||
categories: ["Banque Centrale", "CHF"],
|
||||
reason: "Politique monétaire SNB",
|
||||
},
|
||||
|
||||
// ── BoC / CAD ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(bank of canada|boc|canada (rate|gdp|inflation|jobs)|loonie)\b/i,
|
||||
impacts: [{ ccy: "CAD", dir: "neutral" }],
|
||||
categories: ["Banque Centrale", "CAD"],
|
||||
reason: "Politique monétaire BoC",
|
||||
},
|
||||
|
||||
// ── RBA / AUD ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(rba|reserve bank of australia|australia (rate|gdp|inflation|jobs)|aussie)\b/i,
|
||||
impacts: [{ ccy: "AUD", dir: "neutral" }],
|
||||
categories: ["Banque Centrale", "AUD"],
|
||||
reason: "Politique monétaire RBA",
|
||||
},
|
||||
|
||||
// ── RBNZ / NZD ────────────────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(rbnz|reserve bank of new zealand|new zealand (rate|gdp|inflation)|kiwi)\b/i,
|
||||
impacts: [{ ccy: "NZD", dir: "neutral" }],
|
||||
categories: ["Banque Centrale", "NZD"],
|
||||
reason: "Politique monétaire RBNZ",
|
||||
},
|
||||
|
||||
// ── Pétrole → CAD haussier / JPY NZD baissiers ────────────────────────────
|
||||
{
|
||||
pattern: /\b(oil (price(s)?|surge|rise|higher|rally|soar|spike|record)|crude (oil )?(up|higher|rally|surge)|opec (cut|supply cut|restrict)|energy (price(s)? )?(surge|spike|higher))\b/i,
|
||||
impacts: [
|
||||
{ ccy: "CAD", dir: "bullish" },
|
||||
{ ccy: "JPY", dir: "bearish" },
|
||||
{ ccy: "NZD", dir: "bearish" },
|
||||
],
|
||||
categories: ["Énergie", "Commodités"],
|
||||
reason: "Pétrole en hausse → CAD haussier (exportateur), JPY/NZD baissiers (importateurs ~90% énergie)",
|
||||
},
|
||||
{
|
||||
pattern: /\b(oil (price(s)?|fall|drop|plunge|lower|crash|collapse|weak)|crude (oil )?(down|lower|fall|plunge)|opec (output|increase|supply up)|oil demand (weak|drop|slow))\b/i,
|
||||
impacts: [
|
||||
{ ccy: "CAD", dir: "bearish" },
|
||||
{ ccy: "JPY", dir: "bullish" },
|
||||
{ ccy: "NZD", dir: "bullish" },
|
||||
],
|
||||
categories: ["Énergie", "Commodités"],
|
||||
reason: "Pétrole en baisse → CAD baissier (exportateur), JPY/NZD haussiers (importateurs bénéficient)",
|
||||
},
|
||||
|
||||
// ── Or → AUD haussier / CHF ────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(gold (price(s)?|rise|rally|surge|record|higher|all.time)|gold (up|soar)|precious metal(s)? (up|rally|higher))\b/i,
|
||||
impacts: [
|
||||
{ ccy: "AUD", dir: "bullish" },
|
||||
{ ccy: "CHF", dir: "bullish" },
|
||||
],
|
||||
categories: ["Commodités", "Or"],
|
||||
reason: "Or en hausse → AUD haussier (3ème producteur mondial), CHF haussier (valeur refuge liée à l'or)",
|
||||
},
|
||||
{
|
||||
pattern: /\b(gold (fall|drop|plunge|lower|crash)|gold (down|weak)|precious metal(s)? (down|fall|lower))\b/i,
|
||||
impacts: [
|
||||
{ ccy: "AUD", dir: "bearish" },
|
||||
],
|
||||
categories: ["Commodités", "Or"],
|
||||
reason: "Or en baisse → AUD baissier",
|
||||
},
|
||||
|
||||
// ── Minerai de fer / Métaux → AUD ─────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(iron ore (price|rise|higher|surge|rally)|steel demand (up|higher|strong)|base metal(s)? (up|rally|higher)|copper (rise|surge|rally|higher))\b/i,
|
||||
impacts: [{ ccy: "AUD", dir: "bullish" }],
|
||||
categories: ["Commodités", "Métaux"],
|
||||
reason: "Minerai de fer / métaux en hausse → AUD haussier (1er exportateur mondial minerai de fer)",
|
||||
},
|
||||
{
|
||||
pattern: /\b(iron ore (fall|drop|lower|plunge|weak)|steel demand (weak|drop|slow|fall)|base metal(s)? (down|fall|lower))\b/i,
|
||||
impacts: [{ ccy: "AUD", dir: "bearish" }],
|
||||
categories: ["Commodités", "Métaux"],
|
||||
reason: "Minerai de fer en baisse → AUD baissier",
|
||||
},
|
||||
|
||||
// ── Chine / Demande asiatique → AUD / NZD ─────────────────────────────────
|
||||
{
|
||||
pattern: /\b(china (growth|gdp|stimulus|pmi|recovery|demand|boom|strong)|chinese (economy|manufacturing|demand) (up|strong|better|recover|boom|grow)|china stimulus|china (fiscal|monetary) (stimulus|ease|boost))\b/i,
|
||||
impacts: [
|
||||
{ ccy: "AUD", dir: "bullish" },
|
||||
{ ccy: "NZD", dir: "bullish" },
|
||||
],
|
||||
categories: ["Chine", "Géopolitique"],
|
||||
reason: "Croissance chinoise → AUD/NZD haussiers (principaux exportateurs vers la Chine : fer, LNG, produits laitiers)",
|
||||
},
|
||||
{
|
||||
pattern: /\b(china (slowdown|weak|contraction|deflat|property crisis|debt|evergrande|recession)|chinese (economy|demand|pmi) (weak|slow|contract|drop|fall)|china (trade war|tariff))\b/i,
|
||||
impacts: [
|
||||
{ ccy: "AUD", dir: "bearish" },
|
||||
{ ccy: "NZD", dir: "bearish" },
|
||||
],
|
||||
categories: ["Chine", "Géopolitique"],
|
||||
reason: "Ralentissement Chine → AUD/NZD baissiers (demande commodités en baisse)",
|
||||
},
|
||||
|
||||
// ── Blé / Agriculture → USD / AUD / CAD ───────────────────────────────────
|
||||
{
|
||||
pattern: /\b(wheat (price|supply|shortage|disruption|export block|higher)|grain (crisis|supply|shortage)|ukraine (wheat|grain|export)|food (inflation|crisis|price surge))\b/i,
|
||||
impacts: [
|
||||
{ ccy: "USD", dir: "bullish" },
|
||||
{ ccy: "AUD", dir: "bullish" },
|
||||
{ ccy: "CAD", dir: "bullish" },
|
||||
],
|
||||
categories: ["Agriculture", "Géopolitique"],
|
||||
reason: "Disruption blé/céréales → USD/AUD/CAD haussiers (grands exportateurs mondiaux)",
|
||||
},
|
||||
|
||||
// ── Produits laitiers → NZD ────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(dairy (price(s)?|index|gdt|auction|higher|surge)|fonterra|milk (price|higher)|new zealand export(s)? (up|higher|strong))\b/i,
|
||||
impacts: [{ ccy: "NZD", dir: "bullish" }],
|
||||
categories: ["Agriculture", "NZD"],
|
||||
reason: "Produits laitiers en hausse → NZD haussier (principal exportateur mondial lait/beurre)",
|
||||
},
|
||||
{
|
||||
pattern: /\b(dairy (price(s)?|index|gdt|auction) (lower|fall|drop|weak)|fonterra (cut|lower|reduce))\b/i,
|
||||
impacts: [{ ccy: "NZD", dir: "bearish" }],
|
||||
categories: ["Agriculture", "NZD"],
|
||||
reason: "Produits laitiers en baisse → NZD baissier",
|
||||
},
|
||||
|
||||
// ── Risk-Off → JPY / CHF haussiers, AUD / NZD baissiers ───────────────────
|
||||
{
|
||||
pattern: /\b(risk[- ]off|safe[- ]haven (demand|flow|bid|surge)|geopolit(ical)? (tension|risk|crisis|escalat)|war|military (conflict|strike|attack|tension)|sanction(s)?|nuclear (threat|risk)|market (crash|sell.off|panic|rout|turmoil)|recession (fear|risk|warning)|stock(s)? (crash|plunge|sell.off))\b/i,
|
||||
impacts: [
|
||||
{ ccy: "JPY", dir: "bullish" },
|
||||
{ ccy: "CHF", dir: "bullish" },
|
||||
{ ccy: "USD", dir: "bullish" },
|
||||
{ ccy: "AUD", dir: "bearish" },
|
||||
{ ccy: "NZD", dir: "bearish" },
|
||||
],
|
||||
categories: ["Risk-Off", "Géopolitique"],
|
||||
reason: "Risk-off → JPY/CHF/USD haussiers (valeurs refuges), AUD/NZD baissiers (devises risquées)",
|
||||
},
|
||||
{
|
||||
pattern: /\b(risk[- ]on|risk appetite (return|improve|recover)|market (rally|surge|boom|exuberance)|stock(s)? (rally|surge|soar|record)|optimism (return|grow)|peace (deal|agreement|ceasefire))\b/i,
|
||||
impacts: [
|
||||
{ ccy: "AUD", dir: "bullish" },
|
||||
{ ccy: "NZD", dir: "bullish" },
|
||||
{ ccy: "JPY", dir: "bearish" },
|
||||
{ ccy: "CHF", dir: "bearish" },
|
||||
],
|
||||
categories: ["Risk-On"],
|
||||
reason: "Risk-on → AUD/NZD haussiers, JPY/CHF baissiers (refuges vendus)",
|
||||
},
|
||||
|
||||
// ── Tarifs douaniers / Guerre commerciale ──────────────────────────────────
|
||||
{
|
||||
pattern: /\b(tariff(s)?|trade war|trade barrier|import duty|import tax|protectionism|trade (sanction|restriction)|export ban)\b/i,
|
||||
impacts: [
|
||||
{ ccy: "CAD", dir: "bearish" },
|
||||
{ ccy: "EUR", dir: "bearish" },
|
||||
{ ccy: "CNY", dir: "neutral" } as never,
|
||||
].filter(i => ["CAD", "EUR"].includes(i.ccy)) as { ccy: Currency; dir: "bullish" | "bearish" | "neutral" }[],
|
||||
categories: ["Géopolitique", "Commerce"],
|
||||
reason: "Tarifs douaniers → EUR/CAD baissiers (forte dépendance export vers US)",
|
||||
},
|
||||
|
||||
// ── Inflation générale ─────────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(global inflation (surge|spike|higher|accelerat|above)|inflation (surprise|beat|above expect))\b/i,
|
||||
impacts: [
|
||||
{ ccy: "USD", dir: "bullish" },
|
||||
{ ccy: "GBP", dir: "bullish" },
|
||||
],
|
||||
categories: ["Données Macro", "Inflation"],
|
||||
reason: "Inflation surprend → pression sur les banques centrales pour maintenir des taux élevés",
|
||||
},
|
||||
|
||||
// ── Croissance mondiale ────────────────────────────────────────────────────
|
||||
{
|
||||
pattern: /\b(global (recession|slowdown|contraction|downturn)|world (growth|gdp) (slow|drop|contract|negative)|imf (cut|lower|downgrade) (forecast|outlook|gdp))\b/i,
|
||||
impacts: [
|
||||
{ ccy: "AUD", dir: "bearish" },
|
||||
{ ccy: "NZD", dir: "bearish" },
|
||||
{ ccy: "CAD", dir: "bearish" },
|
||||
{ ccy: "JPY", dir: "bullish" },
|
||||
{ ccy: "CHF", dir: "bullish" },
|
||||
],
|
||||
categories: ["Données Macro", "Géopolitique"],
|
||||
reason: "Récession mondiale → refuges (JPY/CHF) haussiers, commodités (AUD/NZD/CAD) baissiers",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function applyRules(text: string): { impacts: NewsImpact[]; categories: string[] } {
|
||||
const impacts: NewsImpact[] = [];
|
||||
const categories = new Set<string>();
|
||||
const seenCcy = new Set<Currency>();
|
||||
|
||||
for (const rule of IMPACT_RULES) {
|
||||
if (!rule.pattern.test(text)) continue;
|
||||
|
||||
for (const cat of rule.categories) categories.add(cat);
|
||||
|
||||
for (const { ccy, dir } of rule.impacts) {
|
||||
if (seenCcy.has(ccy)) continue; // première règle matchée par devise gagne
|
||||
seenCcy.add(ccy);
|
||||
impacts.push({ ccy, direction: dir, reason: rule.reason });
|
||||
}
|
||||
|
||||
if (seenCcy.size >= 8) break; // toutes les devises couvertes
|
||||
}
|
||||
|
||||
return { impacts, categories: [...categories] };
|
||||
}
|
||||
|
||||
function parseDate(raw: string): string {
|
||||
try {
|
||||
return new Date(raw).toISOString();
|
||||
} catch {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
function extractMeta(html: string, property: string): string {
|
||||
const m = html.match(new RegExp(`<meta[^>]*(?:name|property)=["']${property}["'][^>]*content=["']([^"']+)["']`, "i"))
|
||||
?? html.match(new RegExp(`<meta[^>]*content=["']([^"']+)["'][^>]*(?:name|property)=["']${property}["']`, "i"));
|
||||
return m?.[1] ?? "";
|
||||
}
|
||||
|
||||
const TE_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
};
|
||||
|
||||
// ── Source 1 : InvestingLive /forex/ ─────────────────────────────────────────
|
||||
|
||||
async function fetchInvestingLiveNews(): Promise<NewsItem[]> {
|
||||
try {
|
||||
const res = await fetch("https://investinglive.com/forex/", {
|
||||
next: { revalidate: 1800 },
|
||||
headers: TE_HEADERS,
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const html = await res.text();
|
||||
|
||||
// Articles listés sous forme <article> ou <div class="...post...">
|
||||
// On cherche les liens + titres + dates dans le HTML
|
||||
const items: NewsItem[] = [];
|
||||
|
||||
// Pattern : <h2 ...><a href="URL">TITLE</a></h2> + datetime="DATE"
|
||||
const articlePattern = /<article[^>]*>([\s\S]*?)<\/article>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = articlePattern.exec(html)) !== null && items.length < 15) {
|
||||
const block = m[1];
|
||||
|
||||
const linkM = block.match(/href=["'](https?:\/\/investinglive\.com\/[^"']+)["']/i);
|
||||
const titleM = block.match(/<h[1-4][^>]*>[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h[1-4]>/i)
|
||||
?? block.match(/<a[^>]*class=["'][^"']*title[^"']*["'][^>]*>([\s\S]*?)<\/a>/i);
|
||||
const dateM = block.match(/datetime=["']([^"']+)["']/i);
|
||||
|
||||
if (!linkM || !titleM) continue;
|
||||
|
||||
const url = linkM[1];
|
||||
const title = titleM[1].replace(/<[^>]+>/g, "").trim();
|
||||
const dateStr = dateM ? dateM[1] : "";
|
||||
const { impacts, categories } = applyRules(title);
|
||||
|
||||
items.push({
|
||||
id: `il-${Buffer.from(url).toString("base64").slice(0, 12)}`,
|
||||
title,
|
||||
url,
|
||||
source: "InvestingLive",
|
||||
publishedAt: parseDate(dateStr),
|
||||
impacts,
|
||||
categories,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ── Source 2 : Reuters RSS (marchés) ─────────────────────────────────────────
|
||||
|
||||
const REUTERS_FEEDS = [
|
||||
"https://feeds.reuters.com/reuters/businessNews",
|
||||
"https://feeds.reuters.com/reuters/topNews",
|
||||
];
|
||||
|
||||
function parseRssItems(xml: string, source: string): NewsItem[] {
|
||||
const items: NewsItem[] = [];
|
||||
const itemBlocks = xml.match(/<item>([\s\S]*?)<\/item>/g) ?? [];
|
||||
|
||||
for (const block of itemBlocks.slice(0, 20)) {
|
||||
const titleM = block.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/i);
|
||||
const linkM = block.match(/<link>([\s\S]*?)<\/link>/i)
|
||||
?? block.match(/<guid[^>]*>([\s\S]*?)<\/guid>/i);
|
||||
const dateM = block.match(/<pubDate>([\s\S]*?)<\/pubDate>/i);
|
||||
const descM = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i);
|
||||
|
||||
if (!titleM || !linkM) continue;
|
||||
|
||||
const title = titleM[1].replace(/<[^>]+>/g, "").trim();
|
||||
const url = linkM[1].trim();
|
||||
const dateStr = dateM?.[1] ?? "";
|
||||
const summary = descM?.[1].replace(/<[^>]+>/g, "").trim().slice(0, 200);
|
||||
|
||||
const combined = `${title} ${summary ?? ""}`;
|
||||
const { impacts, categories } = applyRules(combined);
|
||||
|
||||
items.push({
|
||||
id: `rss-${Buffer.from(url).toString("base64").slice(0, 12)}`,
|
||||
title,
|
||||
url,
|
||||
source,
|
||||
publishedAt: parseDate(dateStr),
|
||||
summary,
|
||||
impacts,
|
||||
categories,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function fetchReutersNews(): Promise<NewsItem[]> {
|
||||
const results = await Promise.allSettled(
|
||||
REUTERS_FEEDS.map(async (feedUrl) => {
|
||||
const res = await fetch(feedUrl, {
|
||||
next: { revalidate: 1800 },
|
||||
headers: { ...TE_HEADERS, "Accept": "application/rss+xml, application/xml, text/xml, */*" },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const xml = await res.text();
|
||||
return parseRssItems(xml, "Reuters");
|
||||
})
|
||||
);
|
||||
|
||||
const all: NewsItem[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
for (const r of results) {
|
||||
if (r.status !== "fulfilled") continue;
|
||||
for (const item of r.value) {
|
||||
if (!seenUrls.has(item.url)) {
|
||||
seenUrls.add(item.url);
|
||||
all.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
// ── Source 3 : Bloomberg (meta tags publics) ─────────────────────────────────
|
||||
|
||||
const BLOOMBERG_PAGES = [
|
||||
{ url: "https://www.bloomberg.com/economics", source: "Bloomberg Economics" },
|
||||
{ url: "https://www.bloomberg.com/economics/central-banks", source: "Bloomberg CB" },
|
||||
{ url: "https://www.bloomberg.com/fx-center", source: "Bloomberg FX" },
|
||||
];
|
||||
|
||||
async function fetchBloombergMeta(): Promise<NewsItem[]> {
|
||||
const items: NewsItem[] = [];
|
||||
|
||||
for (const { url, source } of BLOOMBERG_PAGES) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
next: { revalidate: 3600 },
|
||||
headers: TE_HEADERS,
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const html = await res.text();
|
||||
|
||||
// Extraire les articles depuis les meta og:title / article JSON-LD
|
||||
const ldBlocks = html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi) ?? [];
|
||||
for (const block of ldBlocks.slice(0, 5)) {
|
||||
try {
|
||||
const json = JSON.parse(block.replace(/<script[^>]*>|<\/script>/gi, "").trim());
|
||||
const articles = Array.isArray(json) ? json : [json];
|
||||
for (const art of articles) {
|
||||
if (!art.headline || !art.url) continue;
|
||||
const title = String(art.headline);
|
||||
const artUrl = String(art.url);
|
||||
const dateStr = String(art.datePublished ?? art.dateModified ?? "");
|
||||
const { impacts, categories } = applyRules(title);
|
||||
items.push({
|
||||
id: `bb-${Buffer.from(artUrl).toString("base64").slice(0, 12)}`,
|
||||
title,
|
||||
url: artUrl,
|
||||
source,
|
||||
publishedAt: parseDate(dateStr),
|
||||
impacts,
|
||||
categories,
|
||||
});
|
||||
}
|
||||
} catch { /* skip malformed JSON-LD */ }
|
||||
}
|
||||
|
||||
// Fallback: og:title de la page elle-même
|
||||
const ogTitle = extractMeta(html, "og:title");
|
||||
const ogDesc = extractMeta(html, "og:description");
|
||||
if (ogTitle && ogTitle.length > 10) {
|
||||
const { impacts, categories } = applyRules(`${ogTitle} ${ogDesc}`);
|
||||
if (impacts.length > 0) {
|
||||
items.push({
|
||||
id: `bb-page-${Buffer.from(url).toString("base64").slice(0, 12)}`,
|
||||
title: ogTitle,
|
||||
url,
|
||||
source,
|
||||
publishedAt: new Date().toISOString(),
|
||||
summary: ogDesc || undefined,
|
||||
impacts,
|
||||
categories,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* Bloomberg peut bloquer, on continue */ }
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── Main export ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAllNews(): Promise<NewsItem[]> {
|
||||
const [ilNews, reutersNews, bbNews] = await Promise.allSettled([
|
||||
fetchInvestingLiveNews(),
|
||||
fetchReutersNews(),
|
||||
fetchBloombergMeta(),
|
||||
]);
|
||||
|
||||
const all: NewsItem[] = [
|
||||
...(ilNews.status === "fulfilled" ? ilNews.value : []),
|
||||
...(reutersNews.status === "fulfilled" ? reutersNews.value : []),
|
||||
...(bbNews.status === "fulfilled" ? bbNews.value : []),
|
||||
];
|
||||
|
||||
// Dédupliquer sur l'URL, trier par date décroissante
|
||||
const seenUrls = new Set<string>();
|
||||
const deduped = all.filter(item => {
|
||||
if (seenUrls.has(item.url)) return false;
|
||||
seenUrls.add(item.url);
|
||||
return true;
|
||||
});
|
||||
|
||||
deduped.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
||||
|
||||
return deduped;
|
||||
}
|
||||
Reference in New Issue
Block a user