ci: fix pipeline, update readme and anonymize ZT references

This commit is contained in:
2026-06-12 12:30:18 +02:00
commit d1bd1a9ba9
68 changed files with 10353 additions and 0 deletions
+33
View File
@@ -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);
}
+142
View File
@@ -0,0 +1,142 @@
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';
private baseUrl: string | undefined;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl?.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.cam')) {
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(CONFIG.FT_URL));
+193
View File
@@ -0,0 +1,193 @@
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 {
if (url.startsWith('http')) return url;
const cleanedBase = baseUrl.replace(/\/$/, '');
return cleanedBase + '/' + url.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;
results.push({
title,
year: null,
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);
results.push({
title,
year: null,
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\.cam\/[^"]+)"/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;
}