ci: fix pipeline, update readme and anonymize ZT references
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Appels réseau pour le plugin ZT.
|
||||
* Toutes les fonctions fetch sont ici ; le parsing reste dans parser.ts.
|
||||
*/
|
||||
|
||||
export async function fetchSearchResults(baseUrl: string, query: string): Promise<string> {
|
||||
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=0&q=${encodeURIComponent(query)}&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Accept': 'text/html, */*',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Referer': baseUrl
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchTrendingMovies(baseUrl: string): Promise<string> {
|
||||
const res = await fetch(`${baseUrl}/engine/ajax/controller.php?mod=filter&catid=3&q=&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchTrendingSeries(baseUrl: string): Promise<string> {
|
||||
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=15&q=&art=0&AiffchageMode=0&inputTirePar=1&cstart=0`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchContentPage(pageUrl: string): Promise<string> {
|
||||
const res = await fetch(pageUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchResolvedLink(zoneursUrl: string): Promise<string> {
|
||||
const url = zoneursUrl.startsWith('//') ? `https:${zoneursUrl}` : zoneursUrl;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3',
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} sur ${url}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchRecent(baseUrl: string): Promise<string> {
|
||||
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=55&q=&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { ISource, SearchResult, MediaType, ContentLinks, SelectionData } from '../../src/types/source.js';
|
||||
import { CONFIG } from '../../src/utils/config.js';
|
||||
import { sourceRegistry } from '../../src/core/registry.js';
|
||||
import { fetchSearchResults, fetchTrendingMovies, fetchTrendingSeries, fetchContentPage, fetchResolvedLink, fetchRecent } from './api.js';
|
||||
import { parseSearchHTML, parseContentHTML, extractLinkFromZtProtect } from './parser.js';
|
||||
|
||||
/**
|
||||
* Normalise un titre pour la comparaison (minuscules, sans accents, sans ponctuation).
|
||||
*/
|
||||
function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/-\s*saison\s*\d+/gi, '')
|
||||
.replace(/\(\s*\d{4}\s*\)/g, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Déduplique les résultats par titre normalisé, en gardant la première occurrence.
|
||||
*/
|
||||
function deduplicateByTitle(results: SearchResult[]): SearchResult[] {
|
||||
const seen = new Set<string>();
|
||||
return results.filter(r => {
|
||||
const key = normalizeTitle(r.title);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export class ZTAPI implements ISource {
|
||||
name = 'zt';
|
||||
displayName = 'ZT';
|
||||
private baseUrl: string | undefined;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!this.baseUrl) {
|
||||
console.warn('[ZT] ⚠️ ZT_URL non définie.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) throw new Error('ZT_URL non configurée.');
|
||||
if (!query || query.length < 4) throw new Error('La recherche nécessite au moins 4 caractères.');
|
||||
|
||||
const html = await fetchSearchResults(this.baseUrl, query);
|
||||
if (html.includes('Aucun résultat')) return [];
|
||||
|
||||
let results = parseSearchHTML(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 deduplicateByTitle(results);
|
||||
}
|
||||
|
||||
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) return [];
|
||||
try {
|
||||
const html = mediaType === 'movie'
|
||||
? await fetchTrendingMovies(this.baseUrl)
|
||||
: await fetchTrendingSeries(this.baseUrl);
|
||||
const results = parseSearchHTML(html, this.baseUrl).slice(0, 40);
|
||||
return deduplicateByTitle(results).slice(0, 20);
|
||||
} catch (e: any) {
|
||||
console.error(`[ZT] ❌ Erreur trending ${mediaType}:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getRecent(): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) return [];
|
||||
try {
|
||||
const html = await fetchRecent(this.baseUrl);
|
||||
const results = parseSearchHTML(html, this.baseUrl).slice(0, 40);
|
||||
return deduplicateByTitle(results).slice(0, 20);
|
||||
} catch (e: any) {
|
||||
console.error(`[ZT] ❌ Erreur getRecent:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getContentLinks(pageUrl: string): Promise<ContentLinks> {
|
||||
if (!this.baseUrl) throw new Error('ZT_URL non configurée.');
|
||||
const fullUrl = pageUrl.startsWith('http') ? pageUrl : (this.baseUrl + (pageUrl.startsWith('/') ? '' : '/') + pageUrl);
|
||||
const html = await fetchContentPage(fullUrl);
|
||||
return parseContentHTML(html);
|
||||
}
|
||||
|
||||
|
||||
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||
const targetUrl = seasonValue ? String(seasonValue) : identifier;
|
||||
const content = await this.getContentLinks(targetUrl);
|
||||
|
||||
const isSeries = targetUrl.includes('/telecharger-serie/') || targetUrl.includes('/serie-') || (content.relatedSeasons?.length || 0) > 0;
|
||||
|
||||
let currentSeasonLabel = "Saison (Actuelle)";
|
||||
if (content.releaseNames && content.releaseNames.length > 0) {
|
||||
const sm = content.releaseNames[0].match(/Saison\s*\d+/i);
|
||||
if (sm) currentSeasonLabel = sm[0];
|
||||
}
|
||||
|
||||
const formattedSeasons = (content.relatedSeasons || []).map(s => ({
|
||||
label: s.label,
|
||||
value: s.href
|
||||
}));
|
||||
|
||||
if (isSeries) {
|
||||
formattedSeasons.push({ label: currentSeasonLabel, value: targetUrl });
|
||||
formattedSeasons.sort((a, b) => {
|
||||
const numA = parseInt(a.label.replace(/\D/g, '')) || 0;
|
||||
const numB = parseInt(b.label.replace(/\D/g, '')) || 0;
|
||||
return numA - numB;
|
||||
});
|
||||
}
|
||||
|
||||
const allLinks = [...content.links];
|
||||
if (content.relatedQualities && content.relatedQualities.length > 0) {
|
||||
console.log(`[ZT] Fetching ${content.relatedQualities.length} other qualities concurrently...`);
|
||||
const qualityPromises = content.relatedQualities.map(async (q) => {
|
||||
try {
|
||||
const qContent = await this.getContentLinks(q.href);
|
||||
return qContent.links;
|
||||
} catch (e) {
|
||||
console.error(`[ZT] Error fetching quality page ${q.href}:`, e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const otherQualitiesLinks = await Promise.all(qualityPromises);
|
||||
otherQualitiesLinks.forEach(links => allLinks.push(...links));
|
||||
}
|
||||
|
||||
return { links: allLinks, seasons: formattedSeasons, isSeries };
|
||||
}
|
||||
|
||||
async resolveLink(linkId: string): Promise<string | null> {
|
||||
try {
|
||||
console.log(`[ZT] 🔓 Résolution du lien : ${linkId}`);
|
||||
const html = await fetchResolvedLink(linkId);
|
||||
const resolved = extractLinkFromZtProtect(html);
|
||||
if (!resolved) {
|
||||
console.warn(`[ZT] ⚠️ Impossible d'extraire le lien résolu du HTML de ZTProtect pour ${linkId}`);
|
||||
}
|
||||
return resolved;
|
||||
} catch (e: any) {
|
||||
console.error(`[ZT] ❌ Erreur resolveLink pour ${linkId}:`, e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-registration ──
|
||||
sourceRegistry.register(new ZTAPI(CONFIG.ZT_URL));
|
||||
@@ -0,0 +1,185 @@
|
||||
import { SearchResult, MediaType, ContentLinks, VideoLink } from '../../src/types/source.js';
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Parse le HTML de résultats de recherche ZT.
|
||||
*/
|
||||
export function parseSearchHTML(html: string, baseUrl: string | undefined): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
const coverRegex = /<div class="cover_global"[^>]*>([\s\S]*?)(?=<div class="cover_global"|$)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = coverRegex.exec(html)) !== null) {
|
||||
const block = match[1]!;
|
||||
|
||||
const titleMatch = block.match(/<div class="cover_infos_title"[^>]*>\s*<a href="([^"]+)"[^>]*>\s*([^<]+)/);
|
||||
if (!titleMatch) continue;
|
||||
|
||||
const href = titleMatch[1]!.trim();
|
||||
const title = titleMatch[2]!.trim();
|
||||
|
||||
const imgMatch = block.match(/<img class="mainimg"[^>]*src="([^"]+)"/);
|
||||
let image = imgMatch ? imgMatch[1]! : null;
|
||||
if (image && image.startsWith('/') && baseUrl) {
|
||||
image = baseUrl + image;
|
||||
}
|
||||
|
||||
let type: 'movie' | 'series' | 'anime' = 'movie';
|
||||
if (href.includes('/telecharger-serie/') || href.includes('/serie-')) {
|
||||
type = 'series';
|
||||
} else if (href.includes('/animes')) {
|
||||
type = 'anime';
|
||||
}
|
||||
|
||||
results.push({ title, image, hrefPath: href, year: null, type, source: 'zt' });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse le HTML d'une page de contenu ZT pour en extraire les liens et saisons.
|
||||
*/
|
||||
export function parseContentHTML(html: string): ContentLinks {
|
||||
const links: VideoLink[] = [];
|
||||
|
||||
const releaseNames: string[] = [];
|
||||
const releaseRegex = /<font color=red>([^<]+)<\/font>/g;
|
||||
let releaseMatch: RegExpExecArray | null;
|
||||
while ((releaseMatch = releaseRegex.exec(html)) !== null) {
|
||||
releaseNames.push(releaseMatch[1]!.trim());
|
||||
}
|
||||
|
||||
const sections = html.split(/<img src='\/img\/([^']+)'/);
|
||||
|
||||
for (let i = 1; i < sections.length; i += 2) {
|
||||
const hostImg = sections[i]!;
|
||||
const hostName = hostImg.replace('.png', '').replace('.jpg', '').replace('.webp', '');
|
||||
const sectionHtml = sections[i + 1] || '';
|
||||
|
||||
const linkRegex = /<a class="btnToLink"[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>/g;
|
||||
let linkMatch: RegExpExecArray | null;
|
||||
|
||||
while ((linkMatch = linkRegex.exec(sectionHtml)) !== null) {
|
||||
const zoneursUrl = linkMatch[1]!;
|
||||
const label = linkMatch[2]!.trim();
|
||||
|
||||
// Extraire la taille depuis le label : "NOM.FICHIER (11.5 GO)" → "11.5 GO"
|
||||
const sizeRegex = /\s*\(([\d.,]+\s*(?:go|gb|mo|mb|ko|kb|to|tb))\)/i;
|
||||
let sizeMatch = label.match(sizeRegex);
|
||||
let size = sizeMatch ? sizeMatch[1]!.trim().toUpperCase() : undefined;
|
||||
|
||||
// Si non trouvé dans le label, on cherche dans le nom de la release (qualité)
|
||||
if (!size && releaseNames.length > 0) {
|
||||
const qualityMatch = releaseNames[0].match(sizeRegex);
|
||||
if (qualityMatch) size = qualityMatch[1]!.trim().toUpperCase();
|
||||
}
|
||||
|
||||
// Nettoyer le label pour enlever la taille
|
||||
const cleanedLabel = label.replace(sizeRegex, "").trim();
|
||||
|
||||
// On n'utilise le label comme "épisode" que si c'est un vrai nom de fichier/épisode (pas juste "Télécharger")
|
||||
const isGenericLabel = /^(t\u00e9l\u00e9charger|download|cliquez ici|lien|turbobit|1fichier|uptobox|rapidgator|nitroflare|send.now)/i.test(cleanedLabel);
|
||||
let episode = (!isGenericLabel && cleanedLabel.length > 3) ? cleanedLabel : undefined;
|
||||
|
||||
// SI le label est générique, on cherche un texte juste avant (ex: "Episode 1")
|
||||
if (isGenericLabel || !episode) {
|
||||
const index = linkMatch.index;
|
||||
const prevHtml = sectionHtml.substring(Math.max(0, index - 100), index);
|
||||
// Cherche "Episode X", "Saison complète", etc.
|
||||
const epMatch = prevHtml.match(/(?:<b>|<strong>)?(Episode\s*\d+|Saison\s*compl\u00e8te)(?:<\/b>|<\/strong>)?/i);
|
||||
if (epMatch) {
|
||||
episode = epMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
let quality = releaseNames.length > 0 ? releaseNames[0] : 'Inconnu';
|
||||
if (quality.match(sizeRegex)) quality = quality.replace(sizeRegex, '');
|
||||
|
||||
let langs: string[] = [];
|
||||
let subs: string[] = [];
|
||||
|
||||
const textToScan = `${quality} ${cleanedLabel}`;
|
||||
const langMatch = textToScan.match(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi);
|
||||
if (langMatch) {
|
||||
const seenLangs = new Set<string>();
|
||||
const seenSubs = new Set<string>();
|
||||
langMatch.forEach(l => {
|
||||
const up = l.toUpperCase();
|
||||
if (up.includes('VOSTFR')) { seenLangs.add('VOSTFR'); seenSubs.add('French'); }
|
||||
else if (up.includes('TRUEFRENCH')) seenLangs.add('TrueFrench');
|
||||
else if (up.includes('FRENCH') || up === 'VF' || up === 'VFF') seenLangs.add('French');
|
||||
else if (up.includes('MULTI')) { seenLangs.add('MULTI'); seenSubs.add('Multi'); }
|
||||
});
|
||||
langs = Array.from(seenLangs);
|
||||
subs = Array.from(seenSubs);
|
||||
|
||||
quality = quality.replace(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi, '').trim();
|
||||
}
|
||||
|
||||
quality = quality.replace(/[\(\)\[\]\-]+$/g, '').replace(/[\(\)\[\]]/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (!quality || quality.toLowerCase() === 'inconnu') quality = 'WEB';
|
||||
|
||||
links.push({
|
||||
id: zoneursUrl,
|
||||
host: hostName,
|
||||
label: cleanedLabel,
|
||||
url: null,
|
||||
size,
|
||||
quality: quality,
|
||||
langs,
|
||||
subs,
|
||||
episode: episode,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const relatedSeasons: { href: string; label: string }[] = [];
|
||||
const relatedQualities: { href: string; label: string }[] = [];
|
||||
|
||||
// Chercher toutes les sections "également disponibles"
|
||||
const sectionRegex = /(Saisons?|Qualit(?:é|e)s?)\s*également disponibles[\s\S]*?<\/h3>([\s\S]*?)(?:<h3|<\/div>|<div[^>]*class="postinfo")/gi;
|
||||
let sSectionMatch: RegExpExecArray | null;
|
||||
while ((sSectionMatch = sectionRegex.exec(html)) !== null) {
|
||||
const type = sSectionMatch[1].toLowerCase();
|
||||
const seasonBlock = sSectionMatch[2]!;
|
||||
const seasonRegex = /<a[^>]*href="([^"]+)"[^>]*><span class="otherquality">([\s\S]*?)<\/span><\/a>/g;
|
||||
let sMatch: RegExpExecArray | null;
|
||||
while ((sMatch = seasonRegex.exec(seasonBlock)) !== null) {
|
||||
const label = sMatch[2]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
const href = sMatch[1]!.trim();
|
||||
if (type.includes('saison')) {
|
||||
if (!relatedSeasons.find(rs => rs.href === href)) {
|
||||
relatedSeasons.push({ href, label });
|
||||
}
|
||||
} else {
|
||||
if (!relatedQualities.find(rs => rs.href === href)) {
|
||||
relatedQualities.push({ href, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { links, releaseNames, relatedSeasons, relatedQualities };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le lien final déverrouillé de la page HTML de ZTPROTECT.
|
||||
*/
|
||||
export function extractLinkFromZtProtect(html: string): string | null {
|
||||
// 1. Essayer de trouver la valeur de l'input result-input
|
||||
let match = html.match(/class="result-input"\s+value="([^"]+)"/i);
|
||||
if (match && match[1]) return match[1];
|
||||
|
||||
// 2. Essayer de trouver l'attribut href du bouton de succès
|
||||
match = html.match(/<a\s+[^>]*href="([^"]+)"[^>]*class="[^"]*btn-success[^"]*"/i);
|
||||
if (match && match[1]) return match[1];
|
||||
|
||||
match = html.match(/class="[^"]*btn-success[^"]*"\s+[^>]*href="([^"]+)"/i);
|
||||
if (match && match[1]) return match[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user