Initial commit (v1.5.9)
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Appels réseau pour free-telecharger.cam.
|
||||
* Pas de challenge CF actif, fetch direct simple.
|
||||
*/
|
||||
|
||||
const TIMEOUT = 20_000;
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0';
|
||||
|
||||
async function ftGet(url: string): Promise<string> {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchSearch(baseUrl: string, query: string): Promise<string> {
|
||||
return ftGet(`${baseUrl}/1/recherche1/1.html?rech_fiche=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
export async function fetchTrending(baseUrl: string): Promise<string> {
|
||||
return ftGet(`${baseUrl}/page/1.html`);
|
||||
}
|
||||
|
||||
export async function fetchPage(pageUrl: string): Promise<string> {
|
||||
return ftGet(pageUrl);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ISource, SearchResult, MediaType, SelectionData, ContentLinks } from '../../src/types/source.js';
|
||||
import { CONFIG } from '../../src/utils/config.js';
|
||||
import { sourceRegistry } from '../../src/core/registry.js';
|
||||
import { fetchSearch, fetchTrending, fetchPage } from './api.js';
|
||||
import { parseSearchResults, parseTrendingResults, parseContentHTML, parseEpisodeLinks, parseOtherVersions } from './parser.js';
|
||||
|
||||
function isSeriesIdentifier(identifier: string): boolean {
|
||||
return /saison|pack-series|series-(vf|vostfr|terminee)/i.test(identifier);
|
||||
}
|
||||
|
||||
export class FreeTeleAPI implements ISource {
|
||||
name = 'freetel';
|
||||
displayName = 'Free-Télécharger';
|
||||
get baseUrl() {
|
||||
return CONFIG.FT_URL?.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!this.baseUrl) {
|
||||
console.warn('[FreeTel] ⚠️ FT_URL non définie.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(this.baseUrl, {
|
||||
method: 'HEAD',
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return true; // tolérant : le test réel se fait au premier scrape
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
|
||||
if (!query || query.length < 3) throw new Error('La recherche nécessite au moins 3 caractères.');
|
||||
const html = await fetchSearch(this.baseUrl, query);
|
||||
let results = parseSearchResults(html, this.baseUrl);
|
||||
if (mediaType === 'movie') {
|
||||
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
|
||||
} else {
|
||||
results = results.filter(r => r.type === 'series' || r.type === 'anime');
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) return [];
|
||||
try {
|
||||
const html = await fetchTrending(this.baseUrl);
|
||||
let results = parseTrendingResults(html, this.baseUrl);
|
||||
if (mediaType === 'movie') {
|
||||
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
|
||||
} else {
|
||||
results = results.filter(r => r.type === 'series' || r.type === 'anime');
|
||||
}
|
||||
return results.slice(0, 20);
|
||||
} catch (e: any) {
|
||||
console.error(`[FreeTel] Erreur trending ${mediaType}:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getRecent(): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) return [];
|
||||
try {
|
||||
const html = await fetchTrending(this.baseUrl);
|
||||
const results = parseTrendingResults(html, this.baseUrl).slice(0, 20);
|
||||
return results;
|
||||
} catch (e: any) {
|
||||
console.error(`[FreeTel] Erreur getRecent:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getContentLinks(identifier: string): Promise<ContentLinks> {
|
||||
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
|
||||
const url = identifier.startsWith('http') ? identifier : `${this.baseUrl}/${identifier.replace(/^\//, '')}`;
|
||||
const html = await fetchPage(url);
|
||||
return parseContentHTML(html, isSeriesIdentifier(identifier));
|
||||
}
|
||||
|
||||
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
|
||||
// Si seasonValue est fournie (l'UI a cliqué sur une autre qualité), on switch de fiche
|
||||
const targetIdentifier = seasonValue ? String(seasonValue) : identifier;
|
||||
const url = targetIdentifier.startsWith('http') ? targetIdentifier : `${this.baseUrl}/${targetIdentifier.replace(/^\//, '')}`;
|
||||
const html = await fetchPage(url);
|
||||
const isSeries = isSeriesIdentifier(targetIdentifier);
|
||||
const content = parseContentHTML(html, isSeries);
|
||||
|
||||
// Pour les films, exposer les autres qualités comme "seasons" (l'UI les affichera en dropdown)
|
||||
let seasons: { label: string; value: string }[] = [];
|
||||
if (!isSeries) {
|
||||
seasons = parseOtherVersions(html, this.baseUrl);
|
||||
// Ajouter la version courante comme première entrée (sélectionnée par défaut)
|
||||
const currentQuality = content.links[0]?.quality;
|
||||
if (currentQuality && currentQuality !== 'Inconnu') {
|
||||
seasons.unshift({ label: currentQuality, value: targetIdentifier });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
links: content.links,
|
||||
seasons,
|
||||
isSeries,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveLink(linkId: string): Promise<string | null> {
|
||||
let hostUrl: string | null = null;
|
||||
|
||||
// Cas série : page intermédiaire liens.free-telecharger.cam/SLUG-episode_N
|
||||
if (linkId.includes('liens.free-telecharger.')) {
|
||||
try {
|
||||
const html = await fetchPage(linkId);
|
||||
const hosts = parseEpisodeLinks(html);
|
||||
if (hosts.length === 0) {
|
||||
console.warn(`[FreeTel] Aucun hôte trouvé sur ${linkId}`);
|
||||
return null;
|
||||
}
|
||||
const preferred = hosts.find(h => /1fichier/i.test(h.host))
|
||||
|| hosts.find(h => /turbobit/i.test(h.host))
|
||||
|| hosts[0];
|
||||
hostUrl = preferred ? preferred.url : null;
|
||||
} catch (e: any) {
|
||||
console.error(`[FreeTel] Erreur resolveLink:`, e.message);
|
||||
return null;
|
||||
}
|
||||
} else if (linkId.startsWith('http')) {
|
||||
// Cas film : linkId est déjà l'URL hôte (1fichier, Turbobit, …)
|
||||
hostUrl = linkId;
|
||||
}
|
||||
|
||||
return hostUrl;
|
||||
}
|
||||
}
|
||||
|
||||
sourceRegistry.register(new FreeTeleAPI());
|
||||
@@ -0,0 +1,213 @@
|
||||
import { SearchResult, ContentLinks, VideoLink } from '../../src/types/source.js';
|
||||
|
||||
interface FilmMetadata {
|
||||
quality?: string;
|
||||
size?: string;
|
||||
langs?: string[];
|
||||
}
|
||||
|
||||
function parseFilmMetadata(html: string): FilmMetadata {
|
||||
const meta: FilmMetadata = {};
|
||||
const q = html.match(/Qualit[ée][^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
|
||||
if (q) meta.quality = q[1]!.trim();
|
||||
const t = html.match(/Taille[^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
|
||||
if (t) meta.size = t[1]!.trim();
|
||||
const l = html.match(/Langue[^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
|
||||
if (l) meta.langs = l[1]!.trim().split(/[,\/]/).map(s => s.trim()).filter(Boolean);
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les autres versions/qualités disponibles pour le même film.
|
||||
* Section "Autres versions disponibles pour ..."
|
||||
*/
|
||||
export function parseOtherVersions(html: string, baseUrl: string): { label: string; value: string }[] {
|
||||
const out: { label: string; value: string }[] = [];
|
||||
const sectionMatch = html.match(/Autres versions disponibles[\s\S]+?<\/div>\s*<\/div>/i);
|
||||
if (!sectionMatch) return out;
|
||||
const linkRegex = /<a\s+href="([^"]+)"[\s\S]*?🎞️\s*([^<]+?)<\/a>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRegex.exec(sectionMatch[0])) !== null) {
|
||||
const href = absUrl(m[1]!, baseUrl);
|
||||
const label = m[2]!.replace(/\s+/g, ' ').trim();
|
||||
if (!out.find(o => o.value === href)) out.push({ label, value: href });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/\b(web-?dl|web-?rip|blu-?ray|full-?blu-?ray|hdtv|hdrip|dvdrip|bdrip|hdlight|ultra-?hdlight|truefrench|french|multi(?:langues?)?|vff|vfq|vfi|vf|vostfr|english|hdts|cam|ts|r5|dvdscr|x264|x265|h\.?264|h\.?265|hevc)\b/g, '')
|
||||
.replace(/\b(720p|1080p|2160p|4k|uhd|3d|sd|hd)\b/g, '')
|
||||
.replace(/\(\s*\d{4}\s*\)/g, '')
|
||||
.replace(/-\s*saison\s*\d+/gi, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function deduplicateByTitle<T extends { title: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(it => {
|
||||
const k = normalizeTitle(it.title);
|
||||
if (!k || seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function detectType(href: string): 'movie' | 'series' | 'anime' {
|
||||
if (/saison|pack-series|series-(vf|vostfr|terminee)/i.test(href)) return 'series';
|
||||
if (/animes?/i.test(href)) return 'anime';
|
||||
return 'movie';
|
||||
}
|
||||
|
||||
function absUrl(url: string, baseUrl: string): string {
|
||||
let path = url;
|
||||
if (url.startsWith('http')) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
path = u.pathname + u.search + u.hash;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
const cleanedBase = baseUrl.replace(/\/$/, '');
|
||||
return cleanedBase + '/' + path.replace(/^\//, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format résultats de recherche : <div class="image-container"><img/></div>
|
||||
* puis <div class="content"><div class="titre1"><A href="...">Titre</A></div>
|
||||
*/
|
||||
export function parseSearchResults(html: string, baseUrl: string): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
const blockRegex = /<div\s+class="image-container">\s*<img[^>]+src="([^"]+)"[^>]*>[\s\S]*?<div\s+class="titre1">\s*<a\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = blockRegex.exec(html)) !== null) {
|
||||
const image = absUrl(m[1]!, baseUrl);
|
||||
const hrefRaw = m[2]!;
|
||||
const href = absUrl(hrefRaw, baseUrl);
|
||||
const title = m[3]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (!title) continue;
|
||||
let year: string | null = null;
|
||||
const yearMatch = title.match(/\(\s*(\d{4})\s*\)/) || hrefRaw.match(/-(\d{4})-/);
|
||||
if (yearMatch) {
|
||||
year = yearMatch[1];
|
||||
}
|
||||
|
||||
results.push({
|
||||
title,
|
||||
year,
|
||||
image,
|
||||
hrefPath: href,
|
||||
type: detectType(hrefRaw),
|
||||
source: 'freetel',
|
||||
});
|
||||
}
|
||||
return deduplicateByTitle(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format nouveautés (/page/1.html) : <a href="..." data-tip-b64="..."><img alt="Titre" src="..."/></a>
|
||||
*/
|
||||
export function parseTrendingResults(html: string, baseUrl: string): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
const blockRegex = /<a\s+href="((?:films?-|saison-|pack-series|series-)[^"]+\.html)"[^>]*data-tip-b64="[^"]+"[^>]*>\s*<img\s+alt="([^"]+)"[^>]+src="([^"]+)"/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = blockRegex.exec(html)) !== null) {
|
||||
const hrefRaw = m[1]!;
|
||||
const title = m[2]!.trim();
|
||||
const image = absUrl(m[3]!, baseUrl);
|
||||
let year: string | null = null;
|
||||
const yearMatch = title.match(/\(\s*(\d{4})\s*\)/) || hrefRaw.match(/-(\d{4})-/);
|
||||
if (yearMatch) {
|
||||
year = yearMatch[1];
|
||||
}
|
||||
|
||||
results.push({
|
||||
title,
|
||||
year,
|
||||
image,
|
||||
hrefPath: absUrl(hrefRaw, baseUrl),
|
||||
type: detectType(hrefRaw),
|
||||
source: 'freetel',
|
||||
});
|
||||
}
|
||||
return deduplicateByTitle(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse une fiche (film ou série).
|
||||
* - Film : <input name="lien" value="https://turbobit.net/..."> dans la section #link, précédé d'un <p>HOST</p>
|
||||
* - Série : <input name="lien" value="https://liens.free-telecharger.cam/SLUG-episode_N"> (à résoudre via resolveLink)
|
||||
*/
|
||||
export function parseContentHTML(html: string, isSeries: boolean): ContentLinks {
|
||||
const links: VideoLink[] = [];
|
||||
|
||||
if (isSeries) {
|
||||
const episodeRegex = /<input[^>]+name="lien"\s+value="(https?:\/\/liens\.free-telecharger\.[a-z]+\/[^"]+)"/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
let idx = 0;
|
||||
while ((m = episodeRegex.exec(html)) !== null) {
|
||||
const url = m[1]!;
|
||||
const epMatch = url.match(/episode_(\d+|final|complet)/i);
|
||||
const episode = epMatch ? epMatch[1] : null;
|
||||
links.push({
|
||||
id: url,
|
||||
host: 'multi',
|
||||
label: episode ? `Épisode ${episode}` : `Lien ${idx + 1}`,
|
||||
episode: episode || undefined,
|
||||
quality: 'multi',
|
||||
url: null,
|
||||
});
|
||||
idx++;
|
||||
}
|
||||
} else {
|
||||
// Films : section #link contient des blocs (Host name dans <p>, URL dans <input hidden lien>)
|
||||
const meta = parseFilmMetadata(html);
|
||||
const sectionMatch = html.match(/<div\s+id="link"[\s\S]+/);
|
||||
const sec = sectionMatch ? sectionMatch[0] : html;
|
||||
const pairRegex = /<p[^>]*>\s*([A-Za-z0-9-]+)\s*<\/p>[\s\S]{0,800}?<input[^>]+name="lien"\s+value="([^"]+)"/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pairRegex.exec(sec)) !== null) {
|
||||
const host = m[1]!.trim();
|
||||
const url = m[2]!;
|
||||
if (/free-telecharger|trustzone|get-trust-zone/i.test(url)) continue;
|
||||
links.push({
|
||||
id: url,
|
||||
host: host.toLowerCase(),
|
||||
label: host,
|
||||
quality: meta.quality || 'Inconnu',
|
||||
size: meta.size,
|
||||
langs: meta.langs,
|
||||
url: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { links };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse la page intermédiaire d'un épisode (liens.free-telecharger.cam/...).
|
||||
* Structure : <table class="gridtable"> avec <tr> contenant [HOST] et <a href="URL">.
|
||||
*/
|
||||
export function parseEpisodeLinks(html: string): { host: string; url: string }[] {
|
||||
const out: { host: string; url: string }[] = [];
|
||||
const tableMatch = html.match(/<table[^>]*class="gridtable"[\s\S]*?<\/table>/i);
|
||||
if (!tableMatch) return out;
|
||||
const rows = tableMatch[0].match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
||||
for (const row of rows) {
|
||||
const hostMatch = row.match(/\[([^\]]+)\]/);
|
||||
const aMatch = row.match(/<a\s+[^>]*href\s*=\s*["']?([^"'\s>]+)/i);
|
||||
if (hostMatch && aMatch) {
|
||||
out.push({
|
||||
host: hostMatch[1]!.toLowerCase().trim(),
|
||||
url: aMatch[1]!.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user