MovixOpenSource/API/Mainapi/routes/francetv.js
Movix cbc3f1dad9 Merge depuis le repo privé après tests
Corrections de vulnérabilités sur les commentaires (IDOR)
Rajouts de ratelimit sur les commentaires
Correction de pas de son sur hlsplayer
Livetvplayer détecte le type du flux automatiquement
Amélioration du scraper darkiworld (très chiant)
Correction du détection de l'extension

Correction de la vérification du VIP sur server.py (proxy)
Correction du scraper seekstreaming proxy

Nouvelle url streamonsport et coflix
Correction du scraper fstream (get seasons) car la route fonctionne plus sur leur site
Correction du scraper francetv
Ajout de hydrackerBatch (c'étais pour des tests)
2026-05-03 12:47:41 +02:00

779 lines
31 KiB
JavaScript

/**
* France.TV (FTV) routes module.
* Extracted from server.js -- handles France.TV search, episode listing,
* and programme/video info extraction.
*
* Mounted at /api/ftv (paths below are relative to that prefix).
*/
const express = require('express');
const router = express.Router();
const axios = require('axios');
const cheerio = require('cheerio');
const { CACHE_DIR, generateCacheKey } = require('../utils/cacheManager');
// ===========================================================================================
// ===== FRANCE.TV (FTV) SOURCE — Recherche + épisodes d'une série/collection =====
// ===========================================================================================
const FTV_BASE = 'https://www.france.tv';
// --- FTV: Cache du next-action hash (TTL 30 min) ---
let ftvNextActionHash = null;
let ftvNextActionExpiry = 0;
// Headers complets pour simuler un vrai navigateur Chrome sur france.tv
const FTV_BROWSER_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding': 'gzip, deflate, br, zstd',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Sec-Ch-Ua': '"Chromium";v="131", "Not_A Brand";v="24"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Upgrade-Insecure-Requests': '1',
};
// ---------------------------------------------------------------------------
// Dependencies injected via configure()
// ---------------------------------------------------------------------------
let getFromCacheWithExpiration;
let saveToCache;
/**
* Inject runtime dependencies that still live in server.js.
*/
function configure(deps) {
if (deps.getFromCacheWithExpiration) getFromCacheWithExpiration = deps.getFromCacheWithExpiration;
if (deps.saveToCache) saveToCache = deps.saveToCache;
}
// ---------------------------------------------------------------------------
// getFtvNextActionHash -- dynamically retrieve the Next.js server action hash
// ---------------------------------------------------------------------------
/**
* Récupère dynamiquement le hash next-action depuis la page /recherche/.
* Ce hash change à chaque redéploiement de france.tv (Next.js Server Actions).
*
* Étapes :
* 1. GET https://www.france.tv/recherche/
* 2. Fallback rapide : essayer d'extraire $ACTION_ID_HASH inliné dans le HTML
* 3. Sinon, scanner TOUS les chunks /_next/static/chunks/*.js en parallèle
* et chercher createServerReference("HASH", ..., "searchAction") dans chacun.
* On exige le marqueur "searchAction" pour ne pas confondre avec les autres
* Server Actions que Turbopack peut grouper dans le même chunk.
*
* Retente jusqu'à 3 fois en cas d'échec réseau.
*/
async function getFtvNextActionHash() {
const now = Date.now();
if (ftvNextActionHash && now < ftvNextActionExpiry) {
return ftvNextActionHash;
}
const MAX_RETRIES = 3;
const SEARCH_ACTION_RE = /createServerReference\)?\s*\(\s*"([a-f0-9]{40,})"[^)]*?"searchAction"/;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
console.log(`[FTV] Fetching next-action hash (attempt ${attempt}/${MAX_RETRIES}, direct) ...`);
try {
const pageResponse = await axios.get(`${FTV_BASE}/recherche/`, {
headers: { ...FTV_BROWSER_HEADERS },
proxy: false,
timeout: 15000,
maxRedirects: 5,
});
const html = pageResponse.data;
let hash = null;
// Fast path: hash inliné dans le HTML (certains layouts Next.js le font)
const inlineMatch = html.match(/\$ACTION_ID_([a-f0-9]{40,})/);
if (inlineMatch) {
hash = inlineMatch[1];
console.log(`[FTV] Found next-action hash inline in HTML: ${hash}`);
}
if (!hash) {
// Lister TOUS les chunks JS — Turbopack ne respecte plus la structure /app/PAGE/page-HASH.js
const chunkUrls = [...new Set(
[...html.matchAll(/<script[^>]+src="(\/_next\/static\/chunks\/[^"]+\.js)"/g)]
.map((m) => m[1])
)];
console.log(`[FTV] Scanning ${chunkUrls.length} chunks for searchAction reference...`);
const results = await Promise.allSettled(chunkUrls.map(async (url) => {
const res = await axios.get(`${FTV_BASE}${url}`, {
headers: {
'User-Agent': FTV_BROWSER_HEADERS['User-Agent'],
'Accept': '*/*',
'Accept-Language': 'fr-FR,fr;q=0.9',
'Accept-Encoding': 'gzip, deflate, br, zstd',
'Referer': `${FTV_BASE}/recherche/`,
'Sec-Ch-Ua': FTV_BROWSER_HEADERS['Sec-Ch-Ua'],
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Sec-Fetch-Dest': 'script',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'same-origin',
},
proxy: false,
timeout: 10000,
});
const m = (typeof res.data === 'string' ? res.data : '').match(SEARCH_ACTION_RE);
return m ? m[1] : null;
}));
for (const r of results) {
if (r.status === 'fulfilled' && r.value) {
hash = r.value;
console.log(`[FTV] Found next-action hash in chunk: ${hash}`);
break;
}
}
}
if (hash) {
ftvNextActionHash = hash;
ftvNextActionExpiry = now + 30 * 60 * 1000; // Cache 30 min
return hash;
}
console.warn(`[FTV] Attempt ${attempt}: could not extract hash from page or chunks`);
} catch (err) {
console.error(`[FTV] Attempt ${attempt} failed (direct): ${err.response?.status || err.message}`);
if (attempt < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, 500));
}
}
}
console.warn('[FTV] Could not extract next-action hash after all retries');
return null;
}
// ---------------------------------------------------------------------------
// POST /search
// Body: { "query": "ninjago" }
// Recherche sur france.tv et renvoie les programmes (séries/collections) et vidéos individuelles.
// ---------------------------------------------------------------------------
router.post('/search', async (req, res) => {
try {
const { query } = req.body;
if (!query || typeof query !== 'string' || !query.trim()) {
return res.status(400).json({ success: false, error: 'Le paramètre "query" est requis' });
}
const searchTerm = query.trim();
// Check cache (1h expiration for search)
const cacheKey = generateCacheKey(`ftv_search_${searchTerm.toLowerCase()}`);
const cached = await getFromCacheWithExpiration(CACHE_DIR.FTV, cacheKey, 1);
if (cached) {
console.log(`[FTV] Search cache hit for "${searchTerm}"`);
return res.json(cached);
}
// Récupérer dynamiquement le hash next-action (change à chaque déploiement de france.tv)
const nextActionHash = await getFtvNextActionHash();
if (!nextActionHash) {
return res.status(502).json({ success: false, error: 'Impossible de récupérer le hash de recherche France.tv. Le site a peut-être changé.' });
}
console.log(`[FTV] Using next-action hash: ${nextActionHash}`);
const response = await axios.post(`${FTV_BASE}/recherche/`, [searchTerm], {
headers: {
'accept': 'text/x-component',
'accept-encoding': 'gzip, deflate, br, zstd',
'accept-language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7',
'cache-control': 'no-cache',
'content-type': 'text/plain;charset=UTF-8',
'next-action': nextActionHash,
'next-router-state-tree': '%5B%22%22%2C%7B%22children%22%3A%5B%22recherche%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D',
'origin': FTV_BASE,
'pragma': 'no-cache',
'priority': 'u=1, i',
'referer': `${FTV_BASE}/recherche/`,
'sec-ch-ua': '"Chromium";v="131", "Not_A Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'user-agent': FTV_BROWSER_HEADERS['User-Agent'],
},
proxy: false,
timeout: 15000,
validateStatus: () => true,
});
console.log(`[FTV] Search response status: ${response.status}`);
const text = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
console.log(`[FTV] Response text length: ${text.length}`);
// Si 404, le hash a expiré entre-temps, forcer un refresh
if (response.status === 404) {
console.warn('[FTV] Got 404 — next-action hash is stale, invalidating cache...');
ftvNextActionHash = null;
ftvNextActionExpiry = 0;
return res.json({ success: true, programs: [], videos: [], error_hint: 'Hash expiré, réessayez' });
}
// Parse RSC streaming format: lines like "1:{...json...}"
const lines = text.split('\n');
let searchData = null;
for (const line of lines) {
if (line.trim()) console.log(`[FTV] RSC Line prefix: ${line.substring(0, 10)}...`);
// Chercher la ligne de données — peut commencer par 1:, 2:, etc.
const dataMatch = line.match(/^(\d+):\s*(\{.+)/);
if (dataMatch) {
console.log(`[FTV] Found data line starting with ${dataMatch[1]}: (length ${line.length})`);
try {
const parsed = JSON.parse(dataMatch[2]);
// Le résultat de recherche contient taxonomy et/ou video
if (parsed.taxonomy || parsed.video) {
searchData = parsed;
console.log(`[FTV] Parsed success. Taxonomy items: ${parsed.taxonomy ? parsed.taxonomy.length : 0}, Videos: ${parsed.video ? parsed.video.length : 0}`);
break;
}
} catch (e) {
console.error(`[FTV] JSON Parse error on line ${dataMatch[1]}:: ${e.message}`);
}
}
}
if (!searchData) {
console.log('[FTV] No searchData found in RSC response lines.');
// Log premiers 500 chars pour debug
console.log(`[FTV] Response preview: ${text.substring(0, 500)}`);
return res.json({ success: true, programs: [], videos: [] });
}
// Extract programs from taxonomy array
const programs = (searchData.taxonomy || [])
.filter(item => item.content && item.content.url)
.map(item => ({
title: item.content.title || '',
description: item.content.description || '',
url: `${FTV_BASE}${item.content.url}`,
thumbnail: item.content.thumbnail?.x2 || item.content.thumbnail?.x1 || null,
type: item.content.type || 'program', // "program" or "collection"
channel: item.content.channel || null,
category: item.content.category?.label || null,
program_id: item.tracking?.program_id || null,
}));
// Extract videos (individual episodes / films)
const videos = (searchData.video || [])
.filter(item => item.content && item.content.url)
.map(item => ({
title: item.content.title || '',
titleLeading: item.content.titleLeading || '',
description: item.content.description || '',
url: `${FTV_BASE}${item.content.url}`,
thumbnail: item.content.thumbnail?.x2 || item.content.thumbnail?.x1 || null,
type: item.content.type || 'video',
channel: item.content.channel || null,
category: item.content.category?.label || null,
id: item.content.id || null,
season: item.content.title?.match(/^S(\d+)/)?.[1] || null,
episode: item.content.title?.match(/E(\d+)/)?.[1] || null,
csa: item.content.csa || null,
caption: item.content.caption || null,
}));
console.log(`[FTV] Search results - Programs: ${programs.length}, Videos: ${videos.length}`);
const searchResult = { success: true, programs, videos };
await saveToCache(CACHE_DIR.FTV, cacheKey, searchResult);
return res.json(searchResult);
} catch (error) {
console.error('[FTV] Search error:', error.message);
return res.status(500).json({ success: false, error: error.message });
}
});
// ---------------------------------------------------------------------------
// GET /episodes?url=https://www.france.tv/france-3/lego-ninjago
// Récupère la page d'un programme et extrait tous les épisodes disponibles.
// Retourne la liste des épisodes avec leur URL d'extraction.
// ---------------------------------------------------------------------------
router.get('/episodes', async (req, res) => {
try {
const { url } = req.query;
if (!url || !url.includes('france.tv')) {
return res.status(400).json({ success: false, error: 'URL france.tv requise' });
}
// Check cache (4h expiration for episodes)
const epCacheKey = generateCacheKey(`ftv_episodes_${url}`);
const epCached = await getFromCacheWithExpiration(CACHE_DIR.FTV, epCacheKey, 4);
if (epCached) {
console.log(`[FTV] Episodes cache hit for ${url}`);
return res.json(epCached);
}
const response = await axios.get(url, {
headers: { ...FTV_BROWSER_HEADERS },
proxy: false,
timeout: 15000,
});
const html = response.data;
const episodes = [];
const seen = new Set();
// Extract JSON data from self.__next_f.push() calls in the HTML
const scriptRegex = /self\.__next_f\.push\(\[1,"(.*?)"\]\)/gs;
let scriptMatch;
let fullJsonText = '';
while ((scriptMatch = scriptRegex.exec(html)) !== null) {
// Unescape the string content
let chunk = scriptMatch[1];
try {
// The content is JSON-escaped (\\", \\n, \\u00xx, etc.)
chunk = JSON.parse(`"${chunk}"`);
} catch { /* use as-is */ }
fullJsonText += chunk;
}
// Most reliable: extract via Cheerio + script parsing
const $ = cheerio.load(html);
// Gather ALL text from __next_f scripts
let allScriptData = '';
$('script').each((_, el) => {
const raw = $(el).html() || '';
if (raw.includes('__next_f')) {
const pushMatch = raw.match(/self\.__next_f\.push\(\[1,"(.*)"\]\)/s);
if (pushMatch) {
try {
allScriptData += JSON.parse(`"${pushMatch[1]}"`);
} catch {
allScriptData += pushMatch[1];
}
}
}
});
// Now parse the concatenated text to find video objects
// Split by "variant" which marks the end of each card object
const cardChunks = allScriptData.split('"variant"');
for (const chunk of cardChunks) {
// Check if this chunk has a video-type content with a .html URL
if (!chunk.includes('"type":"video"') && !chunk.includes('"type": "video"')) continue;
const urlMatch = chunk.match(/"url"\s*:\s*"(\/[^"]+\.html)"/);
if (!urlMatch) continue;
const epUrl = urlMatch[1];
if (seen.has(epUrl)) continue;
seen.add(epUrl);
// Extract fields from this chunk
const titleMatch = chunk.match(/"title"\s*:\s*"([^"]+)"/);
const leadMatch = chunk.match(/"titleLeading"\s*:\s*"([^"]+)"/);
const descMatch = chunk.match(/"description"\s*:\s*"([^"]*(?:\\.[^"]*)*)"/);
const thumbMatch = chunk.match(/"x2"\s*:\s*"(https:\/\/medias\.france\.tv\/[^"]+)"/);
const idMatch = chunk.match(/"id"\s*:\s*(\d{4,})/);
const csaMatch = chunk.match(/"csa"\s*:\s*"([^"]+)"/);
const title = titleMatch ? titleMatch[1] : '';
const seasonMatch = title.match(/^S(\d+)/);
const episodeMatch = title.match(/E(\d+)/);
let desc = descMatch ? descMatch[1] : '';
try { desc = JSON.parse(`"${desc}"`); } catch { /* use as-is */ }
episodes.push({
title: title,
program: leadMatch ? leadMatch[1] : '',
description: desc,
url: `${FTV_BASE}${epUrl}`,
thumbnail: thumbMatch ? thumbMatch[1] : null,
id: idMatch ? parseInt(idMatch[1]) : null,
season: seasonMatch ? parseInt(seasonMatch[1]) : null,
episode: episodeMatch ? parseInt(episodeMatch[1]) : null,
csa: csaMatch ? csaMatch[1] : null,
});
}
// Sort by season then episode
episodes.sort((a, b) => {
if (a.season !== b.season) return (a.season || 0) - (b.season || 0);
return (a.episode || 0) - (b.episode || 0);
});
const episodesResult = {
success: true,
program_url: url,
total: episodes.length,
episodes,
};
await saveToCache(CACHE_DIR.FTV, epCacheKey, episodesResult);
return res.json(episodesResult);
} catch (error) {
console.error('[FTV] Episodes error:', error.message);
return res.status(500).json({ success: false, error: error.message });
}
});
// ---------------------------------------------------------------------------
// GET /info?url=https://www.france.tv/france-5/le-monde-de-jamy/...
// Récupère les informations d'une page film (player) ou série (programme).
// Détecte automatiquement le type de page et retourne les données structurées.
// ---------------------------------------------------------------------------
router.get('/info', async (req, res) => {
try {
const { url } = req.query;
if (!url || !url.includes('france.tv')) {
return res.status(400).json({ success: false, error: 'URL france.tv requise' });
}
// Check cache (4h expiration for info)
const infoCacheKey = generateCacheKey(`ftv_info_${url}`);
const infoCached = await getFromCacheWithExpiration(CACHE_DIR.FTV, infoCacheKey, 4);
if (infoCached) {
console.log(`[FTV] Info cache hit for ${url}`);
return res.json(infoCached);
}
const response = await axios.get(url, {
headers: { ...FTV_BROWSER_HEADERS },
proxy: false,
timeout: 15000,
});
const html = response.data;
// ---- Extract all RSC data from self.__next_f.push() calls ----
const pushRegex = /self\.__next_f\.push\(\[1,\s*"((?:[^"\\]|\\.)*)"\]\)/g;
let allRscText = '';
let pushMatch;
while ((pushMatch = pushRegex.exec(html)) !== null) {
let chunk = pushMatch[1];
try { chunk = JSON.parse(`"${chunk}"`); } catch { /* use as-is */ }
allRscText += chunk;
}
// ---- Detect page type ----
const isPlayer = /"pageType"\s*:\s*"player"/.test(allRscText) || /data-template-id="player-replay"/.test(html);
const isProgramme = /"pageType"\s*:\s*"programme"/.test(allRscText) || /data-template-id="programme"/.test(html);
if (isPlayer) {
// ===== FILM / VIDEO PAGE =====
const result = { success: true, type: 'video' };
// Extract title - try JSON-LD VideoObject first
const ldJsonRegex = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g;
let ldMatch;
while ((ldMatch = ldJsonRegex.exec(html)) !== null) {
try {
const ld = JSON.parse(ldMatch[1]);
if (ld['@type'] === 'VideoObject') {
result.title = ld.name || '';
result.description = ld.description || '';
result.thumbnail = (ld.thumbnailUrl && ld.thumbnailUrl[0]) || '';
result.duration = ld.duration || '';
result.director = ld.director ? (ld.director.name || ld.director) : '';
result.uploadDate = ld.uploadDate || '';
result.expires = ld.expires || '';
break;
}
} catch { /* skip invalid JSON-LD */ }
}
// Extract additional fields from RSC data
const csaMatch = allRscText.match(/"csaCode"\s*:\s*"([^"]+)"/);
result.csa = csaMatch ? csaMatch[1] : null;
const channelMatch = allRscText.match(/"broadcastChannel"\s*:\s*"([^"]+)"/);
result.channel = channelMatch ? channelMatch[1] : null;
if (!result.channel) {
const ch2 = allRscText.match(/"channel"\s*:\s*"([^"]+)"/);
result.channel = ch2 ? ch2[1] : null;
}
// Extract title from RSC if not from JSON-LD
if (!result.title) {
const titleMatch = allRscText.match(/"pageName"\s*:\s*"([^"]+)"/);
result.title = titleMatch ? titleMatch[1].replace(/_/g, ' ') : '';
}
// Extract description from RSC if not from JSON-LD
if (!result.description) {
const descMatch = allRscText.match(/"description"\s*:\s*"((?:[^"\\]|\\.)*)"/);
if (descMatch) {
try { result.description = JSON.parse(`"${descMatch[1]}"`); }
catch { result.description = descMatch[1]; }
}
}
// Extract thumbnail from RSC if not from JSON-LD
if (!result.thumbnail) {
const thumbMatch = allRscText.match(/"vignette_16x9"[^}]*?"1800"\s*:\s*"([^"]+)"/);
result.thumbnail = thumbMatch ? thumbMatch[1] : null;
if (!result.thumbnail) {
const thumbAlt = allRscText.match(/https:\/\/medias\.france\.tv\/[^"]+\.jpg/);
result.thumbnail = thumbAlt ? thumbAlt[0] : null;
}
}
// Extract duration in seconds from RSC if available
const durationSecMatch = allRscText.match(/"duration"\s*:\s*(\d{2,})/);
result.durationSeconds = durationSecMatch ? parseInt(durationSecMatch[1]) : null;
// Extract program name
const progMatch = allRscText.match(/"programName"\s*:\s*"([^"]+)"/);
result.program = progMatch ? progMatch[1].replace(/_/g, ' ') : null;
// Extract categories
const catMatch = allRscText.match(/"categories"\s*:\s*\["([^"]+)"\]/);
result.category = catMatch ? catMatch[1] : null;
await saveToCache(CACHE_DIR.FTV, infoCacheKey, result);
return res.json(result);
} else if (isProgramme) {
// ===== SÉRIE / PROGRAMME PAGE =====
const result = { success: true, type: 'programme' };
// Extract programme title from <title> tag or RSC
const titleTagMatch = html.match(/<title[^>]*>([^<]+)<\/title>/);
result.title = titleTagMatch ? titleTagMatch[1].replace(/ - (Regarder|France \d|France\.tv).*$/i, '').trim() : '';
// Extract description
const metaDescMatch = html.match(/<meta\s+name="description"\s+content="([^"]+)"/);
result.description = metaDescMatch ? metaDescMatch[1] : '';
// Try to get full description from RSC if meta one is truncated
if (result.description.endsWith('...') || result.description.endsWith('\u2026') || result.description.length < 150) {
const rscDescMatch = allRscText.match(/"description"\s*:\s*"((?:[^"\\]|\\.)*)"/);
if (rscDescMatch) {
try {
const parsedDesc = JSON.parse(`"${rscDescMatch[1]}"`);
if (parsedDesc.length > result.description.length) {
result.description = parsedDesc;
}
} catch {
if (rscDescMatch[1].length > result.description.length) {
result.description = rscDescMatch[1];
}
}
}
}
// Extract thumbnail from og:image
const ogImageMatch = html.match(/<meta\s+property="og:image"\s+content="([^"]+)"/);
result.thumbnail = ogImageMatch ? ogImageMatch[1] : null;
// Extract channel
const channelMatch = allRscText.match(/"channel"\s*:\s*"([^"]+)"/);
result.channel = channelMatch ? channelMatch[1] : null;
// Extract program_id
const progIdMatch = allRscText.match(/"program_id"\s*:\s*"(\d+)"/);
result.programId = progIdMatch ? progIdMatch[1] : null;
// Extract category
const catMatch = allRscText.match(/"category"\s*:\s*"([^"]+)"/);
result.category = catMatch ? catMatch[1] : null;
// ---- Extract cast/crew from RSC text ----
const directorMatch = allRscText.match(/R\u00e9alis\u00e9 par\s*:\s*([^"\\]+)/);
result.director = directorMatch ? directorMatch[1].trim() : null;
const castMatch = allRscText.match(/Avec\s*:\s*([^"\\]+)/);
result.cast = castMatch ? castMatch[1].trim() : null;
// ---- Extract seasons and episodes ----
const seasons = [];
const $ = cheerio.load(html);
// Gather ALL text from __next_f scripts
let allScriptData = '';
$('script').each((_, el) => {
const raw = $(el).html() || '';
if (raw.includes('__next_f')) {
const pm = raw.match(/self\.__next_f\.push\(\[1,"(.*)"\]\)/s);
if (pm) {
try { allScriptData += JSON.parse(`"${pm[1]}"`); }
catch { allScriptData += pm[1]; }
}
}
});
// More robust approach: split by "Saison" markers and extract episode blocks
const seasonSplits = allScriptData.split(/(?=\["Saison\s+\d+")/);
for (const block of seasonSplits) {
const nameMatch = block.match(/^\["(Saison\s+\d+)"/);
if (!nameMatch) continue;
const seasonName = nameMatch[1];
const seasonNum = parseInt(seasonName.match(/\d+/)[0]);
const blockEpisodes = [];
const blockSeen = new Set();
// Split by "variant" to find individual episode cards
const cardChunks = block.split('"variant"');
for (const chunk of cardChunks) {
if (!chunk.includes('"type":"video"')) continue;
const urlMatch = chunk.match(/"url"\s*:\s*"(\/[^"]+\.html)"/);
if (!urlMatch) continue;
const epUrl = urlMatch[1];
if (blockSeen.has(epUrl)) continue;
blockSeen.add(epUrl);
const titleMatch = chunk.match(/"title"\s*:\s*"([^"]+)"/);
const leadMatch = chunk.match(/"titleLeading"\s*:\s*"([^"]+)"/);
const descMatch = chunk.match(/"description"\s*:\s*"((?:[^"\\]|\\.)*)"/);
const thumbMatch = chunk.match(/"x2"\s*:\s*"(https:\/\/[^"]+)"/);
const idMatch = chunk.match(/"content_id"\s*:\s*(\d+)/);
let idMatch2;
if (!idMatch) {
idMatch2 = chunk.match(/"id"\s*:\s*(\d{4,})/);
}
const csaMatch = chunk.match(/"csa"\s*:\s*"([^"]+)"/);
const durationMatch = chunk.match(/"duration"\s*:\s*"([^"]+)"/);
const vfIdMatch = chunk.match(/"video_factory_id"\s*:\s*"([0-9a-f-]{36})"/);
const title = titleMatch ? titleMatch[1] : '';
const epNumMatch = title.match(/E(\d+)/);
let desc = descMatch ? descMatch[1] : '';
try { desc = JSON.parse(`"${desc}"`); } catch { /* use as-is */ }
blockEpisodes.push({
title,
program: leadMatch ? leadMatch[1] : '',
description: desc,
url: `${FTV_BASE}${epUrl}`,
thumbnail: thumbMatch ? thumbMatch[1] : null,
contentId: (idMatch ? parseInt(idMatch[1]) : (idMatch2 ? parseInt(idMatch2[1]) : null)),
videoId: vfIdMatch ? vfIdMatch[1] : null,
season: seasonNum,
episode: epNumMatch ? parseInt(epNumMatch[1]) : null,
csa: csaMatch ? csaMatch[1] : null,
duration: durationMatch ? durationMatch[1] : null,
});
}
// Sort episodes by episode number
blockEpisodes.sort((a, b) => (a.episode || 0) - (b.episode || 0));
if (blockEpisodes.length > 0) {
seasons.push({
name: seasonName,
number: seasonNum,
episodeCount: blockEpisodes.length,
episodes: blockEpisodes,
});
}
}
// If no seasons found with the split approach, try the card-based approach from /episodes
if (seasons.length === 0) {
const fallbackEpisodes = [];
const fallbackSeen = new Set();
const cardChunks = allScriptData.split('"variant"');
for (const chunk of cardChunks) {
if (!chunk.includes('"type":"video"')) continue;
const urlMatch = chunk.match(/"url"\s*:\s*"(\/[^"]+\.html)"/);
if (!urlMatch) continue;
const epUrl = urlMatch[1];
if (fallbackSeen.has(epUrl)) continue;
fallbackSeen.add(epUrl);
const titleMatch = chunk.match(/"title"\s*:\s*"([^"]+)"/);
const leadMatch = chunk.match(/"titleLeading"\s*:\s*"([^"]+)"/);
const descMatch = chunk.match(/"description"\s*:\s*"((?:[^"\\]|\\.)*)"/);
const thumbMatch = chunk.match(/"x2"\s*:\s*"(https:\/\/[^"]+)"/);
const idMatch = chunk.match(/"content_id"\s*:\s*(\d+)/) || chunk.match(/"id"\s*:\s*(\d{4,})/);
const vfIdMatch = chunk.match(/"video_factory_id"\s*:\s*"([0-9a-f-]{36})"/);
const durationMatch = chunk.match(/"duration"\s*:\s*"([^"]+)"/);
const csaMatch = chunk.match(/"csa"\s*:\s*"([^"]+)"/);
const title = titleMatch ? titleMatch[1] : '';
let desc = descMatch ? descMatch[1] : '';
try { desc = JSON.parse(`"${desc}"`); } catch {}
const seasonMatch = title.match(/S(\d+)/);
const epMatch = title.match(/E(\d+)/);
fallbackEpisodes.push({
title,
program: leadMatch ? leadMatch[1] : '',
description: desc,
url: `${FTV_BASE}${epUrl}`,
thumbnail: thumbMatch ? thumbMatch[1] : null,
contentId: idMatch ? parseInt(idMatch[1]) : null,
videoId: vfIdMatch ? vfIdMatch[1] : null,
season: seasonMatch ? parseInt(seasonMatch[1]) : null,
episode: epMatch ? parseInt(epMatch[1]) : null,
csa: csaMatch ? csaMatch[1] : null,
duration: durationMatch ? durationMatch[1] : null,
});
}
if (fallbackEpisodes.length > 0) {
// Group by season
const seasonMap = {};
for (const ep of fallbackEpisodes) {
const sNum = ep.season || 1;
if (!seasonMap[sNum]) seasonMap[sNum] = [];
seasonMap[sNum].push(ep);
}
for (const [sNum, eps] of Object.entries(seasonMap)) {
eps.sort((a, b) => (a.episode || 0) - (b.episode || 0));
seasons.push({
name: `Saison ${sNum}`,
number: parseInt(sNum),
episodeCount: eps.length,
episodes: eps,
});
}
seasons.sort((a, b) => a.number - b.number);
}
}
result.seasons = seasons;
result.totalEpisodes = seasons.reduce((sum, s) => sum + s.episodeCount, 0);
await saveToCache(CACHE_DIR.FTV, infoCacheKey, result);
return res.json(result);
} else {
// Unknown page type
return res.json({
success: false,
error: 'Type de page non reconnu (ni player ni programme)',
hint: 'Vérifiez que l\'URL pointe vers un film/vidéo ou une série sur france.tv',
});
}
} catch (error) {
console.error('[FTV] Info error:', error.message);
return res.status(500).json({ success: false, error: error.message });
}
});
console.log('FTV (france.tv) source loaded');
module.exports = router;
module.exports.configure = configure;
module.exports.getFtvNextActionHash = getFtvNextActionHash;