ci: fix pipeline, update readme and anonymize ZT references
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Appels réseau pour zt.news.
|
||||
* 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 ztnGet(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 ztnGet(`${baseUrl}/?p=films&search=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* News n'a pas vraiment de page "nouveautés" séparée — la home expose déjà
|
||||
* une grille de blocs cover_global avec les derniers films/séries.
|
||||
*/
|
||||
export async function fetchTrending(baseUrl: string, type: 'films' | 'series'): Promise<string> {
|
||||
return ztnGet(`${baseUrl}/?p=${type}`);
|
||||
}
|
||||
|
||||
export async function fetchPage(pageUrl: string): Promise<string> {
|
||||
return ztnGet(pageUrl);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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 { parseListingHTML, parseContentHTML, parseOtherVersions } from './parser.js';
|
||||
|
||||
function isSeriesIdentifier(identifier: string): boolean {
|
||||
return /[?&]p=serie\b|telecharger-serie/i.test(identifier);
|
||||
}
|
||||
|
||||
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, '');
|
||||
}
|
||||
|
||||
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 ZtTeamAPI implements ISource {
|
||||
name = 'ztteam';
|
||||
displayName = 'ZT (Team)';
|
||||
private baseUrl: string | undefined;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl?.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!this.baseUrl) {
|
||||
console.warn('[ztnews] ⚠️ ZTTEAM_URL non définie.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) throw new Error('ZTTEAM_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 = parseListingHTML(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 = await fetchTrending(this.baseUrl, mediaType === 'series' ? 'series' : 'films');
|
||||
const results = parseListingHTML(html, this.baseUrl);
|
||||
return deduplicateByTitle(results).slice(0, 20);
|
||||
} catch (e: any) {
|
||||
console.error(`[ztnews] Erreur trending ${mediaType}:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getRecent(): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) return [];
|
||||
try {
|
||||
const html = await fetchPage(this.baseUrl);
|
||||
const results = parseListingHTML(html, this.baseUrl);
|
||||
return deduplicateByTitle(results).slice(0, 20);
|
||||
} catch (e: any) {
|
||||
console.error(`[ztnews] Erreur getRecent:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getContentLinks(identifier: string): Promise<ContentLinks> {
|
||||
if (!this.baseUrl) throw new Error('ZTTEAM_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('ZTTEAM_URL non configurée.');
|
||||
const targetIdentifier = seasonValue ? String(seasonValue) : identifier;
|
||||
const isSeries = isSeriesIdentifier(targetIdentifier);
|
||||
const url = targetIdentifier.startsWith('http') ? targetIdentifier : `${this.baseUrl}/${targetIdentifier.replace(/^\//, '')}`;
|
||||
const html = await fetchPage(url);
|
||||
const content = parseContentHTML(html, isSeries);
|
||||
|
||||
const allLinks = [...content.links];
|
||||
let seasons: { label: string; value: string }[] = [];
|
||||
|
||||
if (!isSeries) {
|
||||
const otherVersions = parseOtherVersions(html, this.baseUrl);
|
||||
if (otherVersions.length > 0) {
|
||||
console.log(`[ztnews] Fetching ${otherVersions.length} other qualities concurrently...`);
|
||||
const qualityPromises = otherVersions.map(async (q) => {
|
||||
try {
|
||||
const qHtml = await fetchPage(q.value);
|
||||
const qContent = parseContentHTML(qHtml, isSeries);
|
||||
return qContent.links;
|
||||
} catch (e) {
|
||||
console.error(`[ztnews] Error fetching quality page ${q.value}:`, e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const otherQualitiesLinks = await Promise.all(qualityPromises);
|
||||
otherQualitiesLinks.forEach(links => allLinks.push(...links));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
links: allLinks,
|
||||
seasons,
|
||||
isSeries,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveLink(linkId: string): Promise<string | null> {
|
||||
console.log(`[ztTeam] 🔗 Renvoi du lien dl-protect brut (résolution via navigateur ou JDownloader requise) : ${linkId}`);
|
||||
return linkId || null;
|
||||
}
|
||||
}
|
||||
|
||||
sourceRegistry.register(new ZtTeamAPI(CONFIG.ZTTEAM_URL));
|
||||
@@ -0,0 +1,169 @@
|
||||
import { SearchResult, ContentLinks, VideoLink } from '../../src/types/source.js';
|
||||
|
||||
|
||||
function decodeFnMeta(url: string): { quality?: string; langs?: string[] } {
|
||||
try {
|
||||
const m = url.match(/[?&]fn=([^&]+)/);
|
||||
if (!m) return {};
|
||||
const decoded = Buffer.from(decodeURIComponent(m[1]!), 'base64').toString('utf-8');
|
||||
const qm = decoded.match(/\[([^\]]+)\]/);
|
||||
const quality = qm ? qm[1]!.trim() : undefined;
|
||||
// Tout après " - " jusqu'à la fin (typiquement la langue : FRENCH, MULTI, VOSTFR…)
|
||||
const lm = decoded.match(/-\s+([A-Za-z]+(?:\s+[A-Za-z]+)?)$/);
|
||||
const langs = lm ? [lm[1]!.trim()] : undefined;
|
||||
return { quality, langs };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function detectType(href: string): 'movie' | 'series' | 'anime' {
|
||||
if (/[?&]p=serie\b|telecharger-serie|serie-/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.startsWith('/') ? url : '/' + url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip suffixes de qualité/langue pour dedup par titre normalisé.
|
||||
*/
|
||||
function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/\b(web-?dl|web-?rip|blu-?ray|hdtv|hdrip|dvdrip|hdlight|truefrench|french|multi(?:langues?)?|vff|vf|vostfr|x264|x265|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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* News utilise la structure DLE classique avec cover_global / cover_infos_title / mainimg
|
||||
* sur la home/listing/recherche. On peut donc partager le parser de listing.
|
||||
*/
|
||||
export function parseListingHTML(html: string, baseUrl: string): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
const coverRegex = /<div class="cover_global"[^>]*>([\s\S]*?)(?=<div class="cover_global"|$)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = coverRegex.exec(html)) !== null) {
|
||||
const block = m[1]!;
|
||||
const titleMatch = block.match(/<div class="cover_infos_title"[^>]*>\s*<a href="([^"]+)"[^>]*>\s*([^<]+)/);
|
||||
if (!titleMatch) continue;
|
||||
const href = absUrl(titleMatch[1]!.trim(), baseUrl);
|
||||
const title = titleMatch[2]!.trim();
|
||||
const imgMatch = block.match(/<img class="mainimg"[^>]*src="([^"]+)"/);
|
||||
const image = imgMatch ? absUrl(imgMatch[1]!, baseUrl) : null;
|
||||
results.push({
|
||||
title,
|
||||
year: null,
|
||||
image,
|
||||
hrefPath: href,
|
||||
type: detectType(titleMatch[1]!),
|
||||
source: 'ztnews',
|
||||
});
|
||||
}
|
||||
return deduplicateByTitle(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse la fiche film/série de news.
|
||||
* Structure dans <div class="postinfo">:
|
||||
* <div style="color:#XXX">HOST_NAME</div>
|
||||
* <a href="dl-protect.link/SLUG?fn=...&rl=a2">Télécharger</a> (film)
|
||||
* <a href="dl-protect.link/SLUG?fn=...&rl=b2">Episode N</a> (série, plusieurs liens)
|
||||
*/
|
||||
export function parseContentHTML(html: string, isSeries: boolean): ContentLinks {
|
||||
const links: VideoLink[] = [];
|
||||
|
||||
const postMatch = html.match(/<div class="postinfo">([\s\S]*?)<\/div>\s*<\/center>/);
|
||||
if (!postMatch) return { links };
|
||||
const post = postMatch[1]!;
|
||||
|
||||
// Découper par hôte : chaque hôte est marqué par <div style="font-weight:bold;color:#XXX">HOST</div>
|
||||
const hostSplit = post.split(/<div\s+style="font-weight:bold;color:#[0-9a-fA-F]+">([^<]+)<\/div>/);
|
||||
// hostSplit[0] = pre-section, puis alterne (HOST, BLOCK)
|
||||
for (let i = 1; i < hostSplit.length; i += 2) {
|
||||
const host = hostSplit[i]!.trim();
|
||||
const block = hostSplit[i + 1] || '';
|
||||
// Tous les <a href="dl-protect.link..."> dans cette section
|
||||
const linkRegex = /<a[^>]+href="(https?:\/\/dl-protect\.link\/[0-9a-fA-F]+\?[^"]*?rl=[ab]2[^"]*)"[^>]*>([^<]+)<\/a>/g;
|
||||
let lm: RegExpExecArray | null;
|
||||
while ((lm = linkRegex.exec(block)) !== null) {
|
||||
const url = lm[1]!;
|
||||
const label = lm[2]!.trim();
|
||||
const epMatch = label.match(/Episode\s*(\d+|FiNAL|Final|final)/i);
|
||||
const meta = decodeFnMeta(url);
|
||||
|
||||
let quality = meta.quality || 'Inconnu';
|
||||
let langs: string[] = [];
|
||||
let subs: string[] = [];
|
||||
|
||||
const textToScan = `${quality} ${label}`;
|
||||
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: url,
|
||||
host: host.toLowerCase(),
|
||||
label: isSeries ? `${label} — ${host}` : host,
|
||||
episode: epMatch ? epMatch[1] : undefined,
|
||||
quality: quality,
|
||||
langs: langs,
|
||||
subs: subs,
|
||||
url: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { links };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les autres versions/qualités du film depuis la section "Qualités également disponibles".
|
||||
*/
|
||||
export function parseOtherVersions(html: string, baseUrl: string): { label: string; value: string }[] {
|
||||
const out: { label: string; value: string }[] = [];
|
||||
const sectionMatch = html.match(/<div class="otherversions"[\s\S]*?<\/div>/);
|
||||
if (!sectionMatch) return out;
|
||||
const linkRegex = /<a\s+href="([^"]+)"[^>]*>\s*<span class="otherquality">([\s\S]*?)<\/span>\s*<\/a>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRegex.exec(sectionMatch[0])) !== null) {
|
||||
const href = absUrl(m[1]!, baseUrl);
|
||||
const label = m[2]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (label && !out.find(o => o.value === href)) out.push({ label, value: href });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user