import { load } from 'cheerio'; import { defaultCover } from '@libs/defaultCover'; import { fetchApi } from '@libs/fetch'; import { NovelStatus } from '@libs/novelStatus'; import { Plugin } from '@/types/plugin'; const catalogueTypes = ['Web Novel', 'Light Novel', 'Manhwa']; const chapterPathPattern = /^(?:https?:\/\/trad-index\.com)?\/oeuvre\/([^/?#]+)\/chapitre\/(\d+(?:[.,]\d+)?)/; class TradIndexPlugin implements Plugin.PluginBase { id = 'tradindex'; name = 'Trad-Index'; icon = 'src/fr/tradindex/icon.png'; site = 'https://trad-index.com/'; version = '1.0.8'; // The app injects its own device User-Agent; send a desktop one so the // server-rendered catalogue/chapter pages stay identical on mobile. private readonly browserHeaders = { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36', }; resolveUrl(path: string, isNovel = false): string { const url = new URL(path, this.site); if (url.origin !== new URL(this.site).origin) throw new Error('Cannot resolve a foreign origin'); const cleanPath = url.pathname .replace(/^\/+|\/+$/g, '') .replace(/^oeuvre\//, ''); if (isNovel) return new URL(`/oeuvre/${cleanPath}`, this.site).href; const [slug, chapterNumber] = cleanPath .replace(/\/chapitre\//, '/') .split('/'); return new URL(`/oeuvre/${slug}/chapitre/${chapterNumber}`, this.site).href; } private retryDelay(response: Response, attempt: number): number { const retryAfter = response.headers.get('retry-after'); if (retryAfter) { const seconds = Number(retryAfter); if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000; const date = Date.parse(retryAfter); if (!Number.isNaN(date)) return Math.max(date - Date.now(), 0); } return 100 * (attempt + 1); } private async fetchHtml(path: string, retry = false): Promise { const attempts = retry ? 4 : 1; const url = new URL(path, this.site).href; for (let attempt = 0; attempt < attempts; attempt += 1) { const response = await fetchApi(url, { headers: this.browserHeaders }); if (response.ok) return response.text(); if ( !retry || (response.status !== 429 && response.status < 500) || attempt === attempts - 1 ) throw new Error(`Failed to load ${path}`); await new Promise(resolve => setTimeout(resolve, this.retryDelay(response, attempt)), ); } throw new Error(`Failed to load ${path}`); } private async catalogueSections(paths: string[]): Promise { const results = await Promise.allSettled( paths.map(path => this.fetchHtml(path)), ); const pages = results.flatMap(result => result.status === 'fulfilled' ? [result.value] : [], ); if (!pages.length) throw new Error('Failed to load catalogue'); return pages; } private parseCards(html: string): Plugin.NovelItem[] { const $ = load(html); const novels = new Map(); $('a[href]').each((_, element) => { const href = $(element).attr('href') || ''; const match = href.match( /^(?:https?:\/\/trad-index\.com)?\/oeuvre\/([^/?#]+)\/?$/, ); if (!match) return; const name = $(element) .find('[class*="line-clamp"]') .first() .text() .trim(); if (!name) return; const cover = $(element).find('img').first().attr('src'); novels.set(match[1], { name, path: match[1], cover: cover ? new URL(cover, this.site).href : defaultCover, }); }); return Array.from(novels.values()); } private cataloguePath(type: string, pageNo: number, searchTerm?: string) { const query = searchTerm ? `&q=${encodeURIComponent(searchTerm)}` : ''; return `/catalogue?type=${encodeURIComponent(type)}${query}&page=${pageNo}`; } private chapterItems(html: string, slug: string): Plugin.ChapterItem[] { const $ = load(html); const chapters = new Map(); $('a[href]').each((_, element) => { const href = $(element).attr('href') || ''; const match = href.match(chapterPathPattern); if (!match || match[1] !== slug) return; const number = Number(match[2].replace(',', '.')); if (!Number.isFinite(number)) return; const path = `${slug}/${match[2]}`; chapters.set(path, { name: $(element).text().trim() || `Chapitre ${match[2]}`, path, chapterNumber: number, }); }); return Array.from(chapters.values()); } private async fetchChapterPages( html: string, slug: string, ): Promise { const $ = load(html); let lastPage = 1; $('a[href*="onglet=chapitres"]').each((_, element) => { const href = $(element).attr('href'); if (!href) return; const page = Number( new URL(href, this.resolveUrl(slug, true)).searchParams.get('page'), ); if (Number.isInteger(page)) lastPage = Math.max(lastPage, page); }); const pages = await Promise.all( Array.from({ length: lastPage - 1 }, (_, index) => this.fetchHtml( `/oeuvre/${slug}?onglet=chapitres&tri=desc&page=${index + 2}`, true, ), ), ); const chapters = new Map(); for (const pageHtml of [html, ...pages]) { for (const chapter of this.chapterItems(pageHtml, slug)) { chapters.set(chapter.path, chapter); } } return Array.from(chapters.values()).sort( (left, right) => (left.chapterNumber || 0) - (right.chapterNumber || 0), ); } async popularNovels(pageNo: number): Promise { const sitePage = Math.max(1, pageNo); const pages = await this.catalogueSections( catalogueTypes.map(type => this.cataloguePath(type, sitePage)), ); const novels = Array.from( new Map( pages .flat() .flatMap(html => this.parseCards(html)) .map(novel => [novel.path, novel]), ).values(), ); if (sitePage === 1 && !novels.length) throw new Error('Trad-Index catalogue returned no work cards'); return novels; } async searchNovels( searchTerm: string, pageNo: number, ): Promise { const pages = await this.catalogueSections( catalogueTypes.map(type => this.cataloguePath(type, pageNo, searchTerm)), ); return Array.from( new Map( pages .flatMap(html => this.parseCards(html)) .map(novel => [novel.path, novel]), ).values(), ); } async parseNovel(novelPath: string): Promise { const novelUrl = this.resolveUrl(novelPath, true); const slug = new URL(novelUrl).pathname.replace(/^\/oeuvre\//, ''); const html = await this.fetchHtml(novelUrl, true); const $ = load(html); const details = $('body').text().replace(/\s+/g, ' '); const formatStatus = details.match( /(Web Novel|Light Novel|Manhwa)\s*·\s*([^\n]+)/i, ); const getDetail = (label: string) => { let value: string | undefined; $('*').each((_, element) => { const text = $(element).text().trim(); const match = text.match(new RegExp(`^${label}\\s*:\\s*(.+)$`, 'i')); if (match) { value = match[1].trim(); return false; } }); return value; }; const synopsisHeading = $('h1,h2,h3') .filter( (_, element) => $(element).text().trim().toLowerCase() === 'synopsis', ) .first(); return { path: slug, name: $('h1').first().text().trim(), cover: $('img[alt^="Couverture de"]').first().attr('src') ? new URL( $('img[alt^="Couverture de"]').first().attr('src')!, this.site, ).href : defaultCover, summary: synopsisHeading.nextAll('p').first().text().trim() || undefined, author: getDetail('Auteur'), artist: getDetail('Traducteur'), genres: getDetail('Genres'), status: /terminé/i.test(formatStatus?.[2] || '') ? NovelStatus.Completed : /en cours/i.test(formatStatus?.[2] || '') ? NovelStatus.Ongoing : NovelStatus.Unknown, chapters: await this.fetchChapterPages(html, slug), }; } async parseChapter(chapterPath: string): Promise { const html = await this.fetchHtml( this.resolveUrl(chapterPath).replace(this.site.slice(0, -1), ''), ); const $ = load(html); const main = $('main').first(); const stopPattern = /traduit par|traducteur|navigation|partager|signaler|commentaires?/i; let stopped = false; const prose: string[] = []; main .find( 'h1, h2, h3, h4, h5, h6, p, nav, form, [class*="comment"], [class*="share"], [class*="report"], [class*="translator"]', ) .each((_, element) => { const part = $(element); if (stopped) return false; if (stopPattern.test(part.text())) { stopped = true; return false; } if ( element.tagName === 'p' && (part.hasClass('narration') || part.hasClass('dialogue')) ) prose.push($.html(element)); }); const content = prose.join(''); if (load(content).text().trim().length < 200) throw new Error('No readable chapter content found'); return content; } } export default new TradIndexPlugin();