mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
1a2ea02b22
Ajout des onglets COT, Weekly Report, TvChart. Refonte Sentiment, News, Calendar, Drivers. Nouvelles API cot-history et fx-weekly. Intégration FinancialJuice. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
226 lines
8.5 KiB
TypeScript
226 lines
8.5 KiB
TypeScript
// lib/financialjuice.ts
|
|
// Fetch news depuis FinancialJuice / Forex Crunch (https://www.financialjuice.com)
|
|
// Stratégie :
|
|
// 1. Essai RSS public (company feed + global feed)
|
|
// 2. Authentification via session cookie si RSS échoue
|
|
// 3. Headers "full browser" pour passer Cloudflare basic protection
|
|
//
|
|
// Credentials stockés dans .env.local — jamais dans le code source.
|
|
|
|
import type { NewsItem } from "./newsfeed";
|
|
import { applyRulesPublic } from "./newsfeed";
|
|
|
|
const FJ_BASE = "https://www.financialjuice.com";
|
|
|
|
// Headers qui imitent un vrai navigateur Chrome — meilleure chance de passer Cloudflare
|
|
const BROWSER_HEADERS = {
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
"Accept-Language": "en-US,en;q=0.9,fr;q=0.8",
|
|
"Accept-Encoding": "gzip, deflate, br",
|
|
"Connection": "keep-alive",
|
|
"Upgrade-Insecure-Requests": "1",
|
|
"Sec-Fetch-Dest": "document",
|
|
"Sec-Fetch-Mode": "navigate",
|
|
"Sec-Fetch-Site": "none",
|
|
"Sec-Fetch-User": "?1",
|
|
"Cache-Control": "max-age=0",
|
|
};
|
|
|
|
// ── Parseur RSS ───────────────────────────────────────────────────────────────
|
|
|
|
function parseRssBlock(xml: string, source: string): NewsItem[] {
|
|
const items: NewsItem[] = [];
|
|
const blocks = xml.match(/<(?:item|entry)>([\s\S]*?)<\/(?:item|entry)>/gi) ?? [];
|
|
|
|
for (const block of blocks.slice(0, 50)) {
|
|
const titleM = block.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/i);
|
|
const linkM = block.match(/<link[^>]*href=["']([^"']+)["']/i)
|
|
?? block.match(/<link>([\s\S]*?)<\/link>/i)
|
|
?? block.match(/<guid[^>]*>(https?:\/\/[^\s<]+)<\/guid>/i);
|
|
const dateM = block.match(/<(?:pubDate|published|updated)>([\s\S]*?)<\/(?:pubDate|published|updated)>/i);
|
|
const descM = block.match(/<(?:description|summary)>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/(?:description|summary)>/i);
|
|
|
|
if (!titleM || !linkM) continue;
|
|
|
|
const title = titleM[1].replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/&#\d+;/g, "").trim();
|
|
const url = linkM[1].trim();
|
|
const dateStr = dateM?.[1]?.trim() ?? "";
|
|
const summary = descM?.[1].replace(/<[^>]+>/g, "").trim().slice(0, 300);
|
|
|
|
if (!title || !url.startsWith("http")) continue;
|
|
|
|
// Filtre 8 jours
|
|
const pubDate = new Date(dateStr);
|
|
if (!isNaN(pubDate.getTime()) && Date.now() - pubDate.getTime() > 8 * 86400_000) continue;
|
|
|
|
const combined = `${title} ${summary ?? ""}`;
|
|
const { impacts, categories } = applyRulesPublic(combined);
|
|
|
|
items.push({
|
|
id: `fj-${Buffer.from(url).toString("base64").slice(0, 12)}`,
|
|
title,
|
|
url,
|
|
source,
|
|
publishedAt: dateStr ? new Date(dateStr).toISOString() : new Date().toISOString(),
|
|
summary,
|
|
impacts,
|
|
categories,
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
// ── Essai RSS public ──────────────────────────────────────────────────────────
|
|
|
|
const PUBLIC_RSS_URLS = [
|
|
`${FJ_BASE}/company/Forex%20Crunch/feed`,
|
|
`${FJ_BASE}/feed`,
|
|
`${FJ_BASE}/rss`,
|
|
`${FJ_BASE}/news/feed`,
|
|
];
|
|
|
|
async function tryPublicRss(): Promise<NewsItem[]> {
|
|
for (const url of PUBLIC_RSS_URLS) {
|
|
try {
|
|
const res = await fetch(url, {
|
|
next: { revalidate: 300 }, // 5 min
|
|
headers: { ...BROWSER_HEADERS, "Accept": "application/rss+xml, application/xml, text/xml, */*" },
|
|
});
|
|
if (!res.ok) continue;
|
|
const xml = await res.text();
|
|
if (!xml.includes("<item>") && !xml.includes("<entry>")) continue;
|
|
const items = parseRssBlock(xml, "FinancialJuice");
|
|
if (items.length > 0) return items;
|
|
} catch { /* essai suivant */ }
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// ── Authentification + scraping session ──────────────────────────────────────
|
|
// Utilisé si RSS public échoue (Cloudflare peut bloquer même les RSS)
|
|
|
|
let _sessionCookie = "";
|
|
let _sessionExpiry = 0;
|
|
|
|
async function authenticate(): Promise<string> {
|
|
if (_sessionCookie && Date.now() < _sessionExpiry) return _sessionCookie;
|
|
|
|
const email = process.env.FINANCIALJUICE_EMAIL ?? "";
|
|
const password = process.env.FINANCIALJUICE_PASSWORD ?? "";
|
|
if (!email || !password) return "";
|
|
|
|
try {
|
|
// Étape 1 : récupérer le token CSRF
|
|
const loginPage = await fetch(`${FJ_BASE}/login`, {
|
|
headers: BROWSER_HEADERS,
|
|
redirect: "follow",
|
|
});
|
|
if (!loginPage.ok) return "";
|
|
|
|
const html = await loginPage.text();
|
|
const cookieHdr = loginPage.headers.get("set-cookie") ?? "";
|
|
const csrfMatch = html.match(/(?:name=["']_token["']|name=["']csrf_token["'])[^>]*value=["']([^"']+)["']/i)
|
|
?? html.match(/meta[^>]*name=["']csrf-token["'][^>]*content=["']([^"']+)["']/i);
|
|
const csrfToken = csrfMatch?.[1] ?? "";
|
|
|
|
// Étape 2 : POST credentials
|
|
const body = new URLSearchParams({
|
|
email,
|
|
password,
|
|
...(csrfToken ? { _token: csrfToken } : {}),
|
|
remember: "on",
|
|
});
|
|
|
|
const loginRes = await fetch(`${FJ_BASE}/login`, {
|
|
method: "POST",
|
|
headers: {
|
|
...BROWSER_HEADERS,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"Referer": `${FJ_BASE}/login`,
|
|
"Cookie": cookieHdr.split(",").map(c => c.split(";")[0]).join("; "),
|
|
},
|
|
body: body.toString(),
|
|
redirect: "manual",
|
|
});
|
|
|
|
const newCookies = loginRes.headers.get("set-cookie") ?? "";
|
|
if (!newCookies) return "";
|
|
|
|
// Extraire les cookies de session
|
|
const session = newCookies.split(",")
|
|
.map(c => c.split(";")[0].trim())
|
|
.filter(c => c.includes("=") && !c.startsWith("expires"))
|
|
.join("; ");
|
|
|
|
_sessionCookie = session;
|
|
_sessionExpiry = Date.now() + 3600_000; // valide 1h
|
|
return session;
|
|
} catch { return ""; }
|
|
}
|
|
|
|
async function fetchWithSession(): Promise<NewsItem[]> {
|
|
const cookie = await authenticate();
|
|
if (!cookie) return [];
|
|
|
|
try {
|
|
const res = await fetch(`${FJ_BASE}/company/Forex%20Crunch`, {
|
|
next: { revalidate: 300 },
|
|
headers: { ...BROWSER_HEADERS, "Cookie": cookie, "Referer": FJ_BASE },
|
|
});
|
|
if (!res.ok) return [];
|
|
const html = await res.text();
|
|
|
|
// Extraire les articles depuis le HTML (structure probable FinancialJuice)
|
|
const items: NewsItem[] = [];
|
|
const articleRe = /<(?:article|div)[^>]*class=["'][^"']*(?:news|post|headline|feed)[^"']*["'][^>]*>([\s\S]*?)<\/(?:article|div)>/gi;
|
|
let m: RegExpExecArray | null;
|
|
|
|
while ((m = articleRe.exec(html)) !== null && items.length < 30) {
|
|
const block = m[1];
|
|
const linkM = block.match(/href=["'](https?:\/\/[^"']+)["']/i);
|
|
const titleM = block.match(/<h[1-4][^>]*>([\s\S]*?)<\/h[1-4]>/i)
|
|
?? block.match(/<a[^>]*>([\s\S]{5,200}?)<\/a>/i);
|
|
const dateM = block.match(/datetime=["']([^"']+)["']/i)
|
|
?? block.match(/data-time=["']([^"']+)["']/i);
|
|
|
|
if (!linkM || !titleM) continue;
|
|
|
|
const title = titleM[1].replace(/<[^>]+>/g, "").trim();
|
|
const url = linkM[1];
|
|
const dateStr = dateM?.[1] ?? "";
|
|
|
|
if (title.length < 10) continue;
|
|
|
|
const pubDate = dateStr ? new Date(dateStr) : new Date();
|
|
if (!isNaN(pubDate.getTime()) && Date.now() - pubDate.getTime() > 8 * 86400_000) continue;
|
|
|
|
const { impacts, categories } = applyRulesPublic(title);
|
|
items.push({
|
|
id: `fj-${Buffer.from(url).toString("base64").slice(0, 12)}`,
|
|
title,
|
|
url,
|
|
source: "FinancialJuice",
|
|
publishedAt: pubDate.toISOString(),
|
|
impacts,
|
|
categories,
|
|
});
|
|
}
|
|
|
|
return items;
|
|
} catch { return []; }
|
|
}
|
|
|
|
// ── Export principal ──────────────────────────────────────────────────────────
|
|
|
|
export async function fetchFinancialJuiceNews(): Promise<NewsItem[]> {
|
|
// Essai 1 : RSS public (plus léger, moins de risque Cloudflare)
|
|
const rssItems = await tryPublicRss();
|
|
if (rssItems.length > 0) return rssItems;
|
|
|
|
// Essai 2 : Session authentifiée
|
|
const sessionItems = await fetchWithSession();
|
|
return sessionItems;
|
|
}
|