From 2e685b870f9e5de8e28f41bdbb5909131851aefc Mon Sep 17 00:00:00 2001 From: Balrog57 <193098123+Balrog57@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:54:39 +0200 Subject: [PATCH] fix(french): update French sources and add J-Garden, Trad-Index, LightNovelVF --- plugins/french/chireads.ts | 447 ++++++++++++--------- plugins/french/harkeneliwood.ts | 53 ++- plugins/french/jgarden.ts | 330 +++++++++++++++ plugins/french/kisswood.ts | 142 +++++-- plugins/french/lightnovelvf.ts | 323 +++++++++++++++ plugins/french/noveldeglace.ts | 69 +++- plugins/french/novhell.ts | 58 ++- plugins/french/phenixscans.broken.ts | 275 ------------- plugins/french/rezerowebnovelfr.ts | 47 ++- plugins/french/tradindex.ts | 285 +++++++++++++ plugins/french/warriorlegendtrad.ts | 45 ++- plugins/french/wuxialnscantrad.ts | 99 ++++- plugins/french/xiaowaz.ts | 60 ++- public/static/src/fr/jgarden/icon.png | Bin 0 -> 15179 bytes public/static/src/fr/lightnovelvf/icon.png | Bin 0 -> 1631 bytes public/static/src/fr/phenixscans/icon.png | Bin 5672 -> 0 bytes public/static/src/fr/tradindex/icon.png | Bin 0 -> 5513 bytes 17 files changed, 1647 insertions(+), 586 deletions(-) create mode 100644 plugins/french/jgarden.ts create mode 100644 plugins/french/lightnovelvf.ts delete mode 100644 plugins/french/phenixscans.broken.ts create mode 100644 plugins/french/tradindex.ts create mode 100644 public/static/src/fr/jgarden/icon.png create mode 100644 public/static/src/fr/lightnovelvf/icon.png delete mode 100644 public/static/src/fr/phenixscans/icon.png create mode 100644 public/static/src/fr/tradindex/icon.png diff --git a/plugins/french/chireads.ts b/plugins/french/chireads.ts index 1d08feaa5..b872b476a 100644 --- a/plugins/french/chireads.ts +++ b/plugins/french/chireads.ts @@ -2,252 +2,311 @@ import { CheerioAPI, load } from 'cheerio'; import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { Filters, FilterTypes } from '@libs/filterInputs'; -import dayjs from 'dayjs'; import { defaultCover } from '@libs/defaultCover'; import { NovelStatus } from '@libs/novelStatus'; +type WordPressCategory = { + name: string; + link: string; +}; + class ChireadsPlugin implements Plugin.PluginBase { id = 'chireads'; name = 'Chireads'; icon = 'src/fr/chireads/icon.png'; site = 'https://chireads.com'; - version = '1.0.2'; + version = '2.3.4'; + + // The site is fronted by Cloudflare, which serves different HTML/JSON to a + // plain device User-Agent (the mobile app injects its own UA via fetchApi) + // than to a desktop browser. Send the same desktop Chrome UA on every + // request — HTML pages and the wp-json REST endpoints alike — so a novel's + // chapter list survives on the app. + 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', + }; + + private readonly restHeaders = { + Accept: 'application/json, */*;q=0.8', + 'User-Agent': this.browserHeaders['User-Agent'], + }; async getCheerio(url: string): Promise { - const r = await fetchApi(url, { - headers: { 'Accept-Encoding': 'deflate' }, - }); + const r = await fetchApi(url, { headers: this.browserHeaders }); + if (!r.ok) throw new Error(`HTTP ${r.status} while loading ${url}`); const body = await r.text(); - const $ = load(body); - return $; + return load(body); } - async popularNovels( - pageNo: number, - { filters, showLatestNovels }: Plugin.PopularNovelsOptions, - ): Promise { - let url = this.site; - let tag = 'all'; - if (showLatestNovels) url += '/category/translatedtales/page/' + pageNo; - else { - if ( - filters && - typeof filters.tag.value === 'string' && - filters.tag.value !== 'all' - ) - tag = filters.tag.value; - if (tag !== 'all') url += '/tag/' + tag + '/page/' + pageNo; - else if (pageNo > 1) return []; + private absoluteUrl(url?: string): string { + if (!url) return defaultCover; + try { + const absolute = new URL(url, this.site); + return /^https?:$/.test(absolute.protocol) ? absolute.href : defaultCover; + } catch { + return defaultCover; } - let $ = await this.getCheerio(url); + } + + private toPath(url?: string): string { + if (!url) return ''; + const parsed = new URL(url, this.site); + if (!/(?:^|\.)chireads\.com$/i.test(parsed.hostname)) return ''; + return `${parsed.pathname}${parsed.search}`; + } + + private compactChapterPath(url?: string): string { + if (!url) return ''; + const parsed = new URL(url, this.site); + if (!/(?:^|\.)chireads\.com$/i.test(parsed.hostname)) return ''; + const segments = parsed.pathname.split('/').filter(Boolean); + if (segments[0] === 'c' && segments[1]) return `/c/${segments[1]}/`; + const last = (offset: number) => segments[segments.length - offset]; + const hasDateSuffix = + /^\d{4}$/.test(last(3) || '') && + /^\d{1,2}$/.test(last(2) || '') && + /^\d{1,2}$/.test(last(1) || ''); + const chapterSlug = last(hasDateSuffix ? 4 : 1); + return chapterSlug ? `/c/${chapterSlug}/` : ''; + } + + private parseCards($: CheerioAPI): Plugin.NovelItem[] { const novels: Plugin.NovelItem[] = []; - let novel: Plugin.NovelItem; + const seen = new Set(); + $('ul.refresh-card-grid li.refresh-card').each((i, el) => { + const novelUrl = $(el).find('.refresh-card-title a').attr('href'); + if (!novelUrl || seen.has(novelUrl)) return; + seen.add(novelUrl); + novels.push({ + name: $(el).find('.refresh-card-title a').text().trim(), + cover: this.absoluteUrl( + $(el).find('.refresh-card-cover img').attr('src'), + ), + path: this.toPath(novelUrl), + }); + }); + return novels; + } - if (showLatestNovels || tag !== 'all') { - let loop = 1; - if (showLatestNovels) loop = 2; - for (let i = 0; i < loop; i++) { - if (i === 1) - $ = await this.getCheerio( - this.site + '/category/original/page/' + pageNo, - ); - let romans = $('.romans-content li'); - if (!romans.length) romans = $('#content li'); - romans.each((i, elem) => { - const novelName = $(elem) - .contents() - .find('div') + async popularNovels( + pageNo: number, + { filters, showLatestNovels }: Plugin.PopularNovelsOptions, + ): Promise { + if (showLatestNovels) { + if (pageNo !== 1) return []; + const $ = await this.getCheerio(this.site); + const novels: Plugin.NovelItem[] = []; + const seen = new Set(); + $('.dernieres-tabel tbody tr').each((i, el) => { + const novelUrl = $(el).find('td').first().find('a').attr('href'); + if (!novelUrl || seen.has(novelUrl)) return; + seen.add(novelUrl); + novels.push({ + name: $(el) + .find('td') .first() + .find('a') .text() - .trim(); - const novelCover = $(elem) - .find('div') - .first() - .find('img') - .attr('src'); - const novelUrl = $(elem).find('div').first().find('a').attr('href'); - - if (novelUrl) { - novel = { - name: novelName, - cover: novelCover, - path: novelUrl.replace(this.site, ''), - }; - novels.push(novel); - } + .trim() + .replace(/^\[[TO]\]\s*/, ''), + cover: defaultCover, + path: this.toPath(novelUrl), }); - } - } else { - const populaire = $(':contains("Populaire")') - .last() - .parent() - .next() - .find('li > div'); - if (populaire.length === 12) { - // pc - let novelCover: string | undefined; - let novelName: string | undefined; - let novelUrl: string | undefined; - populaire.each((i, elem) => { - if (i % 2 === 0) novelCover = $(elem).find('img').attr('src'); - else { - novelName = $(elem).text().trim(); - novelUrl = $(elem).find('a').attr('href'); + }); - if (!novelUrl) return; + // The homepage "latest" table carries no cover images, only links to + // each novel's page. Resolve covers from the detail pages so the list + // shows a real cover instead of the "not available" placeholder. + const covers = await Promise.allSettled( + novels.map(async novel => { + const page = await this.getCheerio(this.site + novel.path); + const cover = + page('.refresh-detail-cover img').attr('src') || + page('.refresh-detail-cover img').attr('data-src'); + return cover ? this.absoluteUrl(cover) : defaultCover; + }), + ); + return novels.map((novel, index) => ({ + ...novel, + cover: + covers[index]?.status === 'fulfilled' + ? covers[index].value + : defaultCover, + })); + } - novel = { - name: novelName, - cover: novelCover || defaultCover, - path: novelUrl.replace(this.site, ''), - }; + const tag = filters?.tag?.value; + const isAll = typeof tag !== 'string' || tag === '' || tag === 'all'; + const bases = isAll + ? ['/category/translatedtales', '/category/original'] + : [`/tag/${tag}`]; - novels.push(novel); - } - }); - } // mobile - else { - const imgs = populaire.find('div.popular-list-img img'); - const txts = populaire.find('div.popular-list-name'); - - txts.each((i, elem) => { - const novelName = $(elem).text().trim(); - const novelCover = $(imgs[i]).attr('src'); - const novelUrl = $(elem).find('a').attr('href'); + const catalogues = isAll + ? await Promise.allSettled( + bases.map(base => + this.getCheerio(`${this.site}${base}/page/${pageNo}`), + ), + ) + : [ + { + status: 'fulfilled' as const, + value: await this.getCheerio( + `${this.site}${bases[0]}/page/${pageNo}`, + ), + }, + ]; + if ( + isAll && + catalogues.every(catalogue => catalogue.status === 'rejected') + ) { + throw new Error('All catalogue pages failed'); + } - if (novelUrl) { - novel = { - name: novelName, - cover: novelCover, - path: novelUrl.replace(this.site, ''), - }; - novels.push(novel); - } - }); + const novels: Plugin.NovelItem[] = []; + const seen = new Set(); + for (const catalogue of catalogues) { + if (catalogue.status !== 'fulfilled') continue; + const $ = catalogue.value; + for (const novel of this.parseCards($)) { + if (seen.has(novel.path)) continue; + seen.add(novel.path); + novels.push(novel); } } return novels; } async parseNovel(novelPath: string): Promise { - const novel: Plugin.SourceNovel = { path: novelPath, name: 'Sans titre' }; + const novel: Plugin.SourceNovel = { + path: this.toPath(novelPath), + name: '', + }; - const $ = await this.getCheerio(this.site + novelPath); + const $ = await this.getCheerio(this.site + novel.path); - novel.name = - $('.inform-product-txt').first().text().trim() || - $('.inform-title').text().trim(); - novel.cover = - $('.inform-product img').attr('src') || - $('.inform-product-img img').attr('src') || - defaultCover; - novel.summary = - $('.inform-inform-txt').text().trim() || - $('.inform-intr-txt').text().trim(); + novel.name = $('h1.refresh-detail-title').first().text().trim(); + novel.cover = this.absoluteUrl( + $('.refresh-detail-cover img').attr('src') || + $('.refresh-detail-cover img').attr('data-src'), + ); + novel.summary = $('.refresh-detail-summary-content').text().trim(); - const infos = - $('div.inform-product-txt > div.inform-intr-col').text().trim() || - $('div.inform-inform-data > h6').text().trim(); - if (infos.includes('Auteur : ')) - novel.author = infos - .substring( - infos.indexOf('Auteur : ') + 9, - infos.indexOf('Statut de Parution : '), - ) - .trim(); - else if (infos.includes('Fantrad : ')) - novel.author = infos - .substring( - infos.indexOf('Fantrad : ') + 10, - infos.indexOf('Statut de Parution : '), - ) - .trim(); - else novel.author = 'Inconnu'; - switch ( - infos.substring(infos.indexOf('Statut de Parution : ') + 21).toLowerCase() - ) { - case 'en pause': - novel.status = NovelStatus.OnHiatus; - break; - case 'complet': - novel.status = NovelStatus.Completed; - break; - default: - novel.status = NovelStatus.Ongoing; - break; - } + $('.refresh-detail-meta > div').each((i, el) => { + const label = $(el).find('dt').text().trim(); + const value = $(el).find('dd').text().trim(); + if (label.includes('Auteur')) novel.author = value; + else if (label.includes('Statut')) { + const status = value.toLowerCase(); + if (status.includes('en pause') || status.includes('hiatus')) + novel.status = NovelStatus.OnHiatus; + else if (status.includes('complet') || status.includes('termin')) + novel.status = NovelStatus.Completed; + else novel.status = NovelStatus.Ongoing; + } + }); - const chapters: Plugin.ChapterItem[] = []; + const chapters = new Map(); + $('.refresh-detail-chapter-list a').each((i, el) => { + const chapterUrl = $(el).attr('href'); + const path = this.compactChapterPath(chapterUrl); + if (!path || chapters.has(path)) return; - let chapterList = $('.chapitre-table a'); - if (!chapterList.length) { - $('div.inform-annexe-list').first().remove(); - chapterList = $('.inform-annexe-list').find('a'); - } - chapterList.each((i, elem) => { - const chapterName = $(elem).text().trim(); - const chapterUrl = $(elem).attr('href'); - const releaseDate = dayjs( - chapterUrl?.substring(chapterUrl.length - 11, chapterUrl.length - 1), - ).format('DD MMMM YYYY'); + const title = $(el).text().trim(); + const match = title.match( + /^Chapitre\s+(\d+(?:[.,]\d+)?)\s*(?:(?:–|-|:)\s*)?(.*)$/i, + ); + const segments = new URL(chapterUrl!, this.site).pathname + .split('/') + .filter(Boolean); + const date = segments.slice(-3); + const hasDate = + /^\d{4}$/.test(date[0] || '') && + /^\d{1,2}$/.test(date[1] || '') && + /^\d{1,2}$/.test(date[2] || ''); - if (chapterUrl) { - chapters.push({ - name: chapterName, - releaseTime: releaseDate, - path: chapterUrl.replace(this.site, ''), - }); - } + chapters.set(path, { + name: match + ? `${match[1]}${match[2] ? ` - ${match[2].trim()}` : ''}` + : title, + path, + ...(match ? { chapterNumber: Number(match[1].replace(',', '.')) } : {}), + ...(hasDate + ? { + releaseTime: `${date[0]}-${date[1].padStart(2, '0')}-${date[2].padStart(2, '0')}`, + } + : {}), + }); }); - - novel.chapters = chapters; + novel.chapters = Array.from(chapters.values()); return novel; } async parseChapter(chapterUrl: string): Promise { - const $ = await this.getCheerio(this.site + chapterUrl); + const $ = await this.getCheerio( + this.site + this.compactChapterPath(chapterUrl), + ); - const chapterText = $('#content').html() || ''; + const content = $('#content').first(); + content + .find('script, style, iframe, form, nav, footer, .sharedaddy, .ads') + .remove(); + if (content.text().replace(/\s+/g, ' ').trim().length < 200) { + throw new Error('Chapter content is not readable'); + } - return chapterText; + return content.html() || ''; } async searchNovels( searchTerm: string, pageNo: number, ): Promise { - if (pageNo !== 1) return []; - let novels: Plugin.NovelItem[] = []; - - let i = 1; - let finised = false; - while (!finised) { - await this.popularNovels(i, { - showLatestNovels: true, - filters: undefined, - }).then(res => { - if (res.length === 0) finised = true; - novels.push(...res); - }); - i++; - } - - novels = novels.filter(novel => - novel.name - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .includes( - searchTerm - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, ''), - ), + const parents = await Promise.allSettled( + [2, 811].map(async parent => { + const response = await fetchApi( + `${this.site}/wp-json/wp/v2/categories?parent=${parent}&search=${encodeURIComponent(searchTerm)}&per_page=100&page=${pageNo}`, + { headers: this.restHeaders }, + ); + if (!response.ok) throw new Error(`Search parent ${parent} failed`); + const categories: unknown = await response.json(); + if ( + !Array.isArray(categories) || + !categories.every( + category => + category !== null && + typeof category === 'object' && + typeof (category as WordPressCategory).name === 'string' && + typeof (category as WordPressCategory).link === 'string', + ) + ) + throw new Error(`Search parent ${parent} returned invalid data`); + return categories as WordPressCategory[]; + }), + ); + const categories = parents.flatMap(parent => + parent.status === 'fulfilled' ? parent.value : [], ); + if (parents.every(parent => parent.status === 'rejected')) + throw new Error('Chireads search failed for all category parents'); - return novels; + const seen = new Set(); + return categories + .map(category => ({ + name: category.name, + cover: defaultCover, + path: this.toPath(category.link), + })) + .filter( + novel => + (novel.path.startsWith('/category/translatedtales/') || + novel.path.startsWith('/category/original/')) && + !seen.has(novel.path) && + Boolean(seen.add(novel.path)), + ); } filters = { diff --git a/plugins/french/harkeneliwood.ts b/plugins/french/harkeneliwood.ts index 6631e3afc..946a76121 100644 --- a/plugins/french/harkeneliwood.ts +++ b/plugins/french/harkeneliwood.ts @@ -5,20 +5,46 @@ import { defaultCover } from '@libs/defaultCover'; import { NovelStatus } from '@libs/novelStatus'; import dayjs from 'dayjs'; +const challengeTitles = new Set([ + 'bot verification', + 'you are being redirected...', + 'un instant...', + 'just a moment...', + 'redirecting...', +]); + +async function fetchCheckedHtml( + url: string, + init?: Parameters[1], +): Promise { + const response = await fetchApi(url, init); + if (!response.ok) + throw new Error(`HTTP ${response.status} while loading ${url}`); + const html = await response.text(); + const title = html + .match(/]*>(.*?)<\/title>/is)?.[1] + ?.replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + if (title && challengeTitles.has(title)) + throw new Error(`Bot challenge while loading ${url}`); + return html; +} + class HarkenEliwoodPlugin implements Plugin.PluginBase { id = 'harkeneliwood'; name = 'HarkenEliwood'; icon = 'src/fr/harkeneliwood/icon.png'; site = 'https://harkeneliwood.wordpress.com'; - version = '1.0.0'; + version = '1.0.3'; async getCheerio(url: string): Promise { - const r = await fetchApi(url, { - headers: { 'Accept-Encoding': 'deflate' }, - }); - const body = await r.text(); - const $ = load(body); - return $; + return load( + await fetchCheckedHtml(url, { + headers: { 'Accept-Encoding': 'deflate' }, + }), + ); } async popularNovels(pageNo: number): Promise { @@ -43,6 +69,14 @@ class HarkenEliwoodPlugin implements Plugin.PluginBase { novels.push(novel); } }); + await Promise.all( + novels.map(async item => { + const detail = await this.getCheerio(this.site + item.path); + item.cover = + detail('#content .entry-content p img').first().attr('src') || + defaultCover; + }), + ); return novels; } @@ -58,7 +92,7 @@ class HarkenEliwoodPlugin implements Plugin.PluginBase { $('#content .entry-content p img').first().attr('src') || defaultCover; novel.summary = this.getSummary($('#content .entry-content').text()); novel.author = this.getAuthor($('#content .entry-content').text()); - novel.status = NovelStatus.Ongoing; + novel.status = NovelStatus.Unknown; const chapters: Plugin.ChapterItem[] = []; $('#content .entry-content p a').each((i, elem) => { const chapterName = $(elem).text().trim(); @@ -67,7 +101,7 @@ class HarkenEliwoodPlugin implements Plugin.PluginBase { if (chapterUrl && chapterUrl.includes(this.site) && chapterName) { const releaseDate = dayjs( chapterUrl?.substring(this.site.length + 1, this.site.length + 11), - ).format('DD MMMM YYYY'); + ).format('YYYY-MM-DD'); chapters.push({ name: chapterName, path: chapterUrl.replace(this.site, ''), @@ -133,6 +167,7 @@ class HarkenEliwoodPlugin implements Plugin.PluginBase { const $ = await this.getCheerio(this.site + chapterPath); const title = $('h1.entry-title'); const chapter = $('div.entry-content'); + chapter.find('script, style, ins, iframe, .ads').remove(); return (title.html() || '') + (chapter.html() || ''); } diff --git a/plugins/french/jgarden.ts b/plugins/french/jgarden.ts new file mode 100644 index 000000000..27e5d8c50 --- /dev/null +++ b/plugins/french/jgarden.ts @@ -0,0 +1,330 @@ +import { load } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { fetchApi } from '@libs/fetch'; +import { NovelStatus } from '@libs/novelStatus'; +import { Plugin } from '@/types/plugin'; + +type WordPressPage = { + slug: string; + link: string; + title: { rendered: string }; + content: { rendered: string }; +}; + +const chapterSlug = + /(?:chapitre|prologue|epilogue|interlude|bonus|postface|preface)/i; + +class JGardenPlugin implements Plugin.PluginBase { + id = 'jgarden'; + name = 'J-Garden'; + icon = 'src/fr/jgarden/icon.png'; + site = 'https://j-garden.fr/'; + version = '1.0.5'; + + resolveUrl(path: string): string { + const url = new URL(path, this.site); + if (url.origin !== new URL(this.site).origin) + throw new Error('Cannot resolve a foreign origin'); + return url.toString(); + } + + private slugFromLink(link: string): string | undefined { + try { + const url = new URL(link, this.site); + if (url.origin !== new URL(this.site).origin) return undefined; + const parts = url.pathname.split('/').filter(Boolean); + return parts[parts.length - 1]; + } catch { + return undefined; + } + } + + private async getJson(path: string): Promise { + const response = await fetchApi(this.resolveUrl(path)); + if (!response.ok) throw new Error(`Failed to load ${path}`); + if (!/[/+]json\b/i.test(response.headers.get('content-type') || '')) + throw new Error(`Expected a JSON response for ${path}`); + return response.json() as Promise; + } + + private parseCatalogue(html: string): Plugin.NovelItem[] { + const $ = load(html); + const novels = new Map(); + $('a[href]').each((_, element) => { + const href = $(element).attr('href'); + const path = href ? this.slugFromLink(href) : undefined; + const image = $(element).find('img').first(); + const name = + $(element).text().trim() || + image.attr('alt')?.trim() || + this.nameFromSlug(path || ''); + const coverSrc = image.attr('src'); + const width = Number(image.attr('width')); + const height = Number(image.attr('height')); + // The catalogue renders wide series banners (e.g. 2567×487), not the + // portrait book covers. A landscape image zoomed into a portrait card + // looks broken, so fall back to the default cover instead. + const isBanner = + Number.isFinite(width) && Number.isFinite(height) && width > height; + const cover = + coverSrc && !isBanner ? this.resolveUrl(coverSrc) : defaultCover; + if (path && name) novels.set(path, { name, path, cover }); + }); + return Array.from(novels.values()); + } + + // The catalogue only carries wide series banners; the actual portrait book + // cover lives on each novel's own page. Fetch it there so the list shows a + // real cover instead of a zoomed banner or the fallback placeholder. + private async fetchCover(slug: string): Promise { + try { + const pages = await this.getJson<{ content: { rendered: string } }[]>( + `/wp-json/wp/v2/pages?slug=${encodeURIComponent(slug)}&_fields=content`, + ); + const content = pages[0]?.content?.rendered; + if (!content) return defaultCover; + const $ = load(content); + let cover = ''; + $('img').each((_, element) => { + if (cover) return; + const src = $(element).attr('src'); + if (!src) return; + const width = Number($(element).attr('width')); + const height = Number($(element).attr('height')); + const portrait = + !Number.isFinite(width) || + !Number.isFinite(height) || + height >= width; + if (portrait) cover = this.resolveUrl(src); + }); + return cover || defaultCover; + } catch { + return defaultCover; + } + } + + private nameFromSlug(slug: string): string { + return slug + .split('-') + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + private chapterSequence(name: string, path: string) { + const source = `${name} ${path}`; + const volume = Number( + source.match(/(?:tome|volume|vol\.?|v|t)[\s_-]*(\d+)/i)?.[1] || 0, + ); + // Within each volume: preface, prologue, numbered chapters, interlude, + // bonus, epilogue, then postface. Unrecognised links retain DOM order. + const specialKinds = [ + ['preface', 0], + ['prologue', 1], + ['interlude', 3], + ['bonus', 4], + ['epilogue', 5], + ['postface', 6], + ] as const; + const special = specialKinds.find(([label]) => + new RegExp(`(?:^|[\\s_-])${label}(?:$|[\\s_-])`, 'i').test(source), + ); + const chapter = source.match( + /(?:chapitre|chapter|ch\.?)[\s_-]*(\d+(?:[.,]\d+)?)/i, + ); + return { + volume, + kind: special?.[1] ?? (chapter ? 2 : 7), + chapter: Number(chapter?.[1].replace(',', '.') || 0), + }; + } + + async popularNovels(pageNo: number): Promise { + if (pageNo > 1) return []; + const sections = await Promise.allSettled( + ['jg-ln', 'jg-web-novel'].map(async section => { + const pages = await this.getJson( + `/wp-json/wp/v2/pages?slug=${section}&_fields=content`, + ); + if ( + !Array.isArray(pages) || + !pages.every( + page => + page !== null && + typeof page === 'object' && + (page as Record).content !== null && + typeof (page as Record).content === 'object' && + typeof (page as { content: Record }).content + .rendered === 'string', + ) + ) + throw new Error(`Invalid catalogue section: ${section}`); + return pages as Pick[]; + }), + ); + const catalogues = sections.flatMap(section => + section.status === 'fulfilled' ? [section.value] : [], + ); + if (!catalogues.length) throw new Error('Failed to load catalogue'); + const novels = new Map(); + for (const section of catalogues.flat()) { + for (const novel of this.parseCatalogue(section.content.rendered)) { + novels.set(novel.path, novel); + } + } + const list = Array.from(novels.values()); + const covers = await Promise.allSettled( + list.map(novel => this.fetchCover(novel.path)), + ); + return list.map((novel, index) => ({ + ...novel, + cover: + covers[index]?.status === 'fulfilled' + ? covers[index].value + : defaultCover, + })); + } + + async parseNovel(novelPath: string): Promise { + const novelUrl = this.resolveUrl(novelPath); + const slug = this.slugFromLink(novelUrl) || novelPath; + const pages = await this.getJson( + `/wp-json/wp/v2/pages?slug=${encodeURIComponent(slug)}&_fields=slug,link,title,content`, + ); + const page = pages[0]; + if (!page) throw new Error('Novel not found'); + + const $ = load(page.content.rendered); + const chapters = new Map< + string, + { chapter: Plugin.ChapterItem; index: number } + >(); + const firstChapter = $('a[href]') + .filter((_, element) => { + const href = $(element).attr('href'); + const chapterPath = href ? this.slugFromLink(href) : undefined; + return Boolean(chapterPath && chapterSlug.test(chapterPath)); + }) + .first(); + + $('a[href]').each((index, element) => { + const href = $(element).attr('href'); + const path = href ? this.slugFromLink(href) : undefined; + const name = $(element).text().trim(); + if (path && name && chapterSlug.test(path) && !chapters.has(path)) { + chapters.set(path, { chapter: { name, path }, index }); + } + }); + + const details = $('body *') + .map((_, element) => $(element).text().trim()) + .get() + .filter(Boolean) + .join(' '); + const status = + /\b(?:termin(?:é|ée|e)|completed|complete|fini)(?![A-Za-zÀ-ÖØ-öø-ÿ0-9_])/i.test( + details, + ) + ? NovelStatus.Completed + : /\b(?:hiatus|en pause|pause)\b/i.test(details) + ? NovelStatus.OnHiatus + : /\b(?:en cours|ongoing|publication)\b/i.test(details) + ? NovelStatus.Ongoing + : NovelStatus.Unknown; + + return { + path: page.slug, + name: load(page.title.rendered).text().trim(), + cover: $('img').first().attr('src') + ? this.resolveUrl($('img').first().attr('src')!) + : defaultCover, + summary: firstChapter.prevAll().text().trim(), + status, + chapters: (() => { + const items = Array.from(chapters.values()).map(entry => ({ + ...entry, + sequence: this.chapterSequence( + entry.chapter.name, + entry.chapter.path, + ), + })); + // When only some chapters carry a volume marker (e.g. v1/v2 books + // alongside untagged side stories), the site's DOM order is the + // reading order; keep it instead of interleaving by chapter number. + const mixedVolumes = + items.some(item => item.sequence.volume > 0) && + items.some(item => item.sequence.volume === 0); + items.sort((left, right) => { + if (mixedVolumes) return left.index - right.index; + return ( + left.sequence.volume - right.sequence.volume || + left.sequence.kind - right.sequence.kind || + left.sequence.chapter - right.sequence.chapter || + left.index - right.index + ); + }); + return items.map(({ chapter }, index) => ({ + ...chapter, + chapterNumber: index + 1, + })); + })(), + }; + } + + async parseChapter(chapterPath: string): Promise { + const chapterUrl = this.resolveUrl(chapterPath); + const slug = this.slugFromLink(chapterUrl) || chapterPath; + let posts = await this.getJson< + Pick[] + >( + `/wp-json/wp/v2/posts?slug=${encodeURIComponent(slug)}&_fields=content,title,link`, + ); + if (posts.length === 0) { + const resolved = await fetchApi(this.resolveUrl(chapterPath)); + const canonicalSlug = this.slugFromLink(resolved.url); + if (canonicalSlug && canonicalSlug !== slug) { + posts = await this.getJson( + `/wp-json/wp/v2/posts?slug=${encodeURIComponent(canonicalSlug)}&_fields=content,title,link`, + ); + } + } + const post = posts[0]; + if (!post) throw new Error('Chapter not found'); + + const $ = load(post.content.rendered); + const content = $('.elementor-widget-theme-post-content').first(); + const chapter = content.length ? content : $('body'); + chapter + .find( + 'script, style, nav, .sharedaddy, .share, [class*="share"], [id*="share"]', + ) + .remove(); + chapter + .find('*') + .filter((_, element) => !$(element).text().trim()) + .remove(); + const html = chapter.html()?.trim() || ''; + if (chapter.text().trim().length < 200) + throw new Error('No readable chapter content found'); + return html; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (pageNo !== 1) return []; + const normalize = (value: string) => + value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); + const query = normalize(searchTerm); + return (await this.popularNovels(1)).filter(novel => + normalize(novel.name).includes(query), + ); + } +} + +export default new JGardenPlugin(); diff --git a/plugins/french/kisswood.ts b/plugins/french/kisswood.ts index 7d18c2abf..d0e197b7e 100644 --- a/plugins/french/kisswood.ts +++ b/plugins/french/kisswood.ts @@ -4,18 +4,65 @@ import { Plugin } from '@/types/plugin'; import { defaultCover } from '@libs/defaultCover'; import { NovelStatus } from '@libs/novelStatus'; +const challengeTitles = new Set([ + 'bot verification', + 'you are being redirected...', + 'un instant...', + 'just a moment...', + 'redirecting...', +]); + +async function fetchCheckedHtml( + url: string, + init?: Parameters[1], +): Promise { + const response = await fetchApi(url, init); + if (!response.ok) + throw new Error(`HTTP ${response.status} while loading ${url}`); + const html = await response.text(); + const title = html + .match(/]*>(.*?)<\/title>/is)?.[1] + ?.replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + if (title && challengeTitles.has(title)) + throw new Error(`Bot challenge while loading ${url}`); + return html; +} + class KissWoodPlugin implements Plugin.PluginBase { id = 'kisswood'; name = 'KissWood'; icon = 'src/fr/kisswood/icon.png'; site = 'https://kisswood.eu'; - version = '1.0.0'; + version = '1.0.3'; + + private async findMovedChapter(chapterPath: string): Promise { + const slug = chapterPath.split('/').filter(Boolean).pop() || ''; + const match = slug.match(/^(.*?)-chapitre-(\d+)/i); + if (!match) return null; + const query = `${match[1].replace(/-/g, ' ')} chapitre ${match[2]}`; + const response = await fetchApi( + `${this.site}/wp-json/wp/v2/search?search=${encodeURIComponent(query)}&type=post&subtype=post&per_page=20`, + ); + if (!response.ok) return null; + const results = (await response.json()) as { + title?: string; + url?: string; + }[]; + const replacement = results.find(result => + new RegExp(`chapitre\\D*${match[2]}(?:\\D|$)`, 'i').test( + result.title || '', + ), + )?.url; + if (!replacement) return null; + const url = new URL(replacement, this.site); + return url.origin === new URL(this.site).origin ? url.toString() : null; + } async getCheerio(url: string): Promise { - const r = await fetchApi(url); - const body = await r.text(); - const $ = load(body); - return $; + return load(await fetchCheckedHtml(url)); } async getNovelsCovers( @@ -126,7 +173,7 @@ class KissWoodPlugin implements Plugin.PluginBase { let novel: Plugin.SourceNovel = { path: novelPath, name: 'Sans titre', - status: NovelStatus.Ongoing, + status: NovelStatus.Unknown, }; const $ = await this.getCheerio(this.site + novelPath); @@ -153,6 +200,7 @@ class KissWoodPlugin implements Plugin.PluginBase { ].join(', '); const chapters: Plugin.ChapterItem[] = []; + const chapterPaths = new Set(); $(chapterSelectors).each((i, elem) => { const chapterName = $(elem).text().trim(); const chapterUrl = $(elem).attr('href')?.replace('http://', 'https://'); @@ -164,13 +212,14 @@ class KissWoodPlugin implements Plugin.PluginBase { !chapterUrl.includes('share=facebook') && !chapterUrl.includes('share=x') && !chapterUrl.includes('/category/traductions/') && - !chapterUrl.includes('/category/tour-des-mondes/') && - // Removal of duplicates - !chapters.some(chapter => this.site + chapter.path === chapterUrl) + !chapterUrl.includes('/category/tour-des-mondes/') ) { + const path = chapterUrl.replace(this.site, ''); + if (chapterPaths.has(path)) return; + chapterPaths.add(path); chapters.push({ name: chapterName, - path: chapterUrl.replace(this.site, ''), + path, }); } }); @@ -179,35 +228,58 @@ class KissWoodPlugin implements Plugin.PluginBase { } async parseChapter(chapterPath: string): Promise { - const $ = await this.getCheerio(this.site + chapterPath); + let body: string; + try { + body = await fetchCheckedHtml(this.site + chapterPath); + } catch (error) { + const replacement = await this.findMovedChapter(chapterPath); + if (!replacement) throw error; + body = await fetchCheckedHtml(replacement); + } - const elements: string[] = $('.entry-content') + const $ = load(body); + const chapter = $('.entry-content').first().clone(); + chapter + .find( + 'script, style, ins, iframe, .ads, .sharedaddy, [class*="sharing"], [id*="sharing"]', + ) + .remove(); + + const elements = chapter .contents() - .map((_, el) => $.html(el)) + .map((_, element) => $.html(element)) .get(); - - let hrIndexes: number[] = elements - .map((elem, index) => (elem.includes('
') ? index : -1)) + const separators = elements + .map((element, index) => (/ index !== -1); - - if (hrIndexes.length === 0) { - hrIndexes = [ - 0, - elements.findIndex( - element => - element.includes('https://fr.tipeee.com/kisswood/') || - element.includes('>Sommaire') || - element.includes('>Chapitre Suivant') || - element.includes('———————————————————————————-') || - element.includes('share=facebook'), - ), - ]; - } else if (hrIndexes.length === 1) { - hrIndexes.unshift(0); - } else { - hrIndexes[0] += 1; - } - return elements.slice(hrIndexes[0], hrIndexes[1]).join('\n'); + const markerIndex = elements.findIndex( + element => + !/Sommaire', + '>Chapitre Suivant', + '———————————————————————————-', + 'share=facebook', + ].some(marker => element.includes(marker)), + ); + const start = separators.length > 1 ? separators[0] + 1 : 0; + const end = + separators.length > 1 + ? separators[1] + : separators.length === 1 + ? separators[0] + : markerIndex >= 0 + ? markerIndex + : elements.length; + const content = elements.slice(start, end).join('\n'); + const parsedContent = load(content); + if ( + parsedContent.text().replace(/\s+/g, ' ').trim().length < 200 && + !parsedContent('img').length + ) + throw new Error('No readable chapter content found'); + return content; } async searchNovels( diff --git a/plugins/french/lightnovelvf.ts b/plugins/french/lightnovelvf.ts new file mode 100644 index 000000000..2ecc8c20a --- /dev/null +++ b/plugins/french/lightnovelvf.ts @@ -0,0 +1,323 @@ +import { load } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { fetchApi } from '@libs/fetch'; +import { NovelStatus } from '@libs/novelStatus'; +import { Plugin } from '@/types/plugin'; + +type ChapterEntry = { + number: string; + slug: string; + name_fr?: string | null; + name?: string | null; + created_at?: string | null; +}; + +type ChapterPage = { + chapters: ChapterEntry[]; + current_page: number; + last_page: number; + total: number; +}; + +class LightNovelVFPlugin implements Plugin.PagePlugin { + id = 'lightnovelvf'; + name = 'LightNovelVF'; + icon = 'src/fr/lightnovelvf/icon.png'; + site = 'https://www.lightnovelvf.com/'; + version = '1.0.4'; + + resolveUrl(path: string, _isNovel = false): string { + void _isNovel; + 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(/^novel\//, ''); + return new URL(`/novel/${cleanPath}`, this.site).toString(); + } + + private async fetchHtml(url: string): Promise { + const response = await fetchApi(url); + if (!response.ok) throw new Error(`Failed to load ${url}`); + return response.text(); + } + + private retryDelay(response: Response | null, 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 1000 * 2 ** attempt; + } + + private isChapterPageMetadata( + value: unknown, + requestedPage: number, + ): value is Omit & { chapters: unknown[] } { + if (!value || typeof value !== 'object') return false; + const page = value as Record; + return ( + Array.isArray(page.chapters) && + typeof page.current_page === 'number' && + Number.isInteger(page.current_page) && + page.current_page > 0 && + page.current_page === requestedPage && + typeof page.last_page === 'number' && + Number.isInteger(page.last_page) && + page.last_page >= page.current_page && + typeof page.total === 'number' && + Number.isInteger(page.total) && + page.total >= 0 + ); + } + + private isChapterEntry(value: unknown): value is ChapterEntry { + if (!value || typeof value !== 'object') return false; + const chapter = value as Record; + return ( + typeof chapter.number === 'string' && + chapter.number.trim().length > 0 && + Number.isFinite(Number(chapter.number)) && + typeof chapter.slug === 'string' && + chapter.slug.trim().length > 0 && + (chapter.name_fr === undefined || + chapter.name_fr === null || + typeof chapter.name_fr === 'string') && + (chapter.name === undefined || + chapter.name === null || + typeof chapter.name === 'string') && + (chapter.created_at === undefined || + chapter.created_at === null || + typeof chapter.created_at === 'string') + ); + } + + private async fetchChapterPage( + url: string, + requestedPage: number, + ): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + let response: Response; + try { + response = await fetchApi(url); + } catch { + if (attempt === 5) throw new Error(`Failed to load ${url}`); + await new Promise(resolve => + setTimeout(resolve, this.retryDelay(null, attempt)), + ); + continue; + } + if ( + (response.status === 429 || + (response.status >= 500 && response.status < 600)) && + attempt < 5 + ) { + await new Promise(resolve => + setTimeout(resolve, this.retryDelay(response, attempt)), + ); + continue; + } + if (!response.ok) throw new Error(`Failed to load ${url}`); + if (!response.headers.get('content-type')?.includes('application/json')) + throw new Error(`Expected JSON chapter page from ${url}`); + const page: unknown = await response.json(); + if (!this.isChapterPageMetadata(page, requestedPage)) + throw new Error(`Invalid chapter page from ${url}`); + if (!page.chapters.every(chapter => this.isChapterEntry(chapter))) + throw new Error(`Invalid chapter entry from ${url}`); + return page as ChapterPage; + } + throw new Error(`Failed to load ${url}`); + } + + private parseCards(html: string): Plugin.NovelItem[] { + const $ = load(html); + const novels = new Map(); + $('a[href^="/novel/"]').each((_, element) => { + const href = $(element).attr('href') || ''; + const match = href.match(/^\/novel\/([^/?#]+)\/?$/); + if (!match) return; + + const card = $(element).clone(); + card.find('img').remove(); + card + .find('*') + .filter((_, child) => { + const text = $(child).text().trim(); + return ( + /\b\d[\d\s,.]*\s*(?:chapitres?|chapters?|ch\.?)(?:\s|$)/i.test( + text, + ) || + /^(?:note|rating\s*:?)?\s*\d(?:[.,]\d+)?\s*(?:\/\s*5)?$/i.test(text) + ); + }) + .remove(); + const name = card.text().replace(/\s+/g, ' ').trim(); + if (!name) return; + + const cover = + $(element).find('img').first().attr('data-src') || + $(element).find('img').first().attr('src'); + novels.set(match[1], { + name, + path: match[1], + cover: cover ? new URL(cover, this.site).toString() : defaultCover, + }); + }); + return Array.from(novels.values()); + } + + private catalogueUrl( + pageNo: number, + searchTerm?: string, + latest = false, + ): string { + const parts = [`page=${pageNo}`]; + if (searchTerm) parts.push(`search=${encodeURIComponent(searchTerm)}`); + if (latest) parts.push('sort=update', 'sort_dir=desc'); + return new URL(`/novels-list?${parts.join('&')}`, this.site).toString(); + } + + private labelledValue(html: string, label: string): string | undefined { + const $ = load(html); + let value: string | undefined; + $('dt, [class*="label" i]').each((_, element) => { + if (value || $(element).text().trim().toLowerCase() !== label) return; + value = $(element).next('dd').first().text().trim() || undefined; + }); + return value; + } + + async popularNovels( + pageNo: number, + { showLatestNovels }: Plugin.PopularNovelsOptions, + ): Promise { + return this.parseCards( + await this.fetchHtml( + this.catalogueUrl(pageNo, undefined, Boolean(showLatestNovels)), + ), + ); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const normalizedSearchTerm = searchTerm.replace(/\\(?=%)/g, ''); + return this.parseCards( + await this.fetchHtml(this.catalogueUrl(pageNo, normalizedSearchTerm)), + ); + } + + private chapterItems(page: ChapterPage, slug: string): Plugin.ChapterItem[] { + const chapters = new Map(); + for (const chapter of page.chapters) { + const chapterNumber = Number(chapter.number); + if (!chapter.slug || !Number.isFinite(chapterNumber)) continue; + chapters.set(chapter.slug, { + name: chapter.name_fr || chapter.name || `Chapitre ${chapter.number}`, + path: `${slug}/${chapter.slug}`, + chapterNumber, + releaseTime: chapter.created_at || null, + }); + } + return Array.from(chapters.values()).sort( + (left, right) => (left.chapterNumber || 0) - (right.chapterNumber || 0), + ); + } + + private chapterPageUrl(slug: string, pageNo: number): string { + return `${this.resolveUrl(`${slug}/chapitres`, true)}?p=${pageNo}&order=asc&q=`; + } + + async parseNovel( + novelPath: string, + ): Promise { + const novelUrl = this.resolveUrl(novelPath, true); + const slug = new URL(novelUrl).pathname.replace(/^\/novel\//, ''); + const html = await this.fetchHtml(novelUrl); + const $ = load(html); + const statusText = + this.labelledValue(html, 'statut') || + $('span') + .filter((_, element) => + /^(?:en\s+cours|terminé|complete|hiatus|pause)$/i.test( + $(element).text().trim(), + ), + ) + .first() + .text() + .trim(); + const firstPage = await this.fetchChapterPage( + this.chapterPageUrl(slug, 1), + 1, + ); + + const cover = + $('.lnv-novel-cover, .lnv-novel__cover, .lnv-hero img, .hero img') + .first() + .attr('src') || $('img').first().attr('src'); + return { + path: slug, + name: $('h1').first().text().trim(), + cover: cover ? new URL(cover, this.site).toString() : defaultCover, + summary: $('.lnv-synopsis__body').first().text().trim() || undefined, + author: + this.labelledValue(html, 'auteur') || + $('[itemprop="author"]').first().text().trim() || + undefined, + genres: + this.labelledValue(html, 'catégories') || + this.labelledValue(html, 'categories') || + $('[itemprop="genre"]') + .map((_, element) => $(element).text().trim()) + .get() + .filter(Boolean) + .join(', ') || + undefined, + status: /termin|complet/i.test(statusText) + ? NovelStatus.Completed + : /hiatus|pause/i.test(statusText) + ? NovelStatus.OnHiatus + : /cours|ongoing/i.test(statusText) + ? NovelStatus.Ongoing + : NovelStatus.Unknown, + chapters: this.chapterItems(firstPage, slug), + totalPages: Math.max(firstPage.last_page, 1), + }; + } + + async parsePage(novelPath: string, page: string): Promise { + const pageNo = Number(page); + if (!Number.isInteger(pageNo) || pageNo < 1) + throw new Error('Invalid page'); + const novelUrl = this.resolveUrl(novelPath, true); + const slug = new URL(novelUrl).pathname.replace(/^\/novel\//, ''); + const pageData = await this.fetchChapterPage( + this.chapterPageUrl(slug, pageNo), + pageNo, + ); + return { chapters: this.chapterItems(pageData, slug) }; + } + + async parseChapter(chapterPath: string): Promise { + const html = await this.fetchHtml(this.resolveUrl(chapterPath)); + const $ = load(html); + const content = $('.lnv-reader-content').first(); + content + .find( + 'script, style, header, footer, nav, form, [class*="nav" i], [id*="nav" i], [class*="advert" i], [id*="advert" i], [class*="share" i], [class*="control" i]', + ) + .remove(); + const chapter = content.html()?.trim() || ''; + if (content.text().replace(/\s+/g, ' ').trim().length < 200) + throw new Error('No readable chapter content found'); + return chapter; + } +} + +export default new LightNovelVFPlugin(); diff --git a/plugins/french/noveldeglace.ts b/plugins/french/noveldeglace.ts index 10410df1c..ea74bfdf0 100644 --- a/plugins/french/noveldeglace.ts +++ b/plugins/french/noveldeglace.ts @@ -5,21 +5,46 @@ import { NovelStatus } from '@libs/novelStatus'; import { Filters, FilterTypes } from '@libs/filterInputs'; import { defaultCover } from '@libs/defaultCover'; +const challengeTitles = new Set([ + 'bot verification', + 'you are being redirected...', + 'un instant...', + 'just a moment...', + 'redirecting...', +]); + +async function fetchCheckedHtml( + url: string, + init?: Parameters[1], +): Promise { + const response = await fetchApi(url, init); + if (!response.ok) + throw new Error(`HTTP ${response.status} while loading ${url}`); + const html = await response.text(); + const title = html + .match(/]*>(.*?)<\/title>/is)?.[1] + ?.replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + if (title && challengeTitles.has(title)) + throw new Error(`Bot challenge while loading ${url}`); + return html; +} + class NovelDeGlacePlugin implements Plugin.PluginBase { id = 'noveldeglace'; name = 'NovelDeGlace'; icon = 'src/fr/noveldeglace/icon.png'; site = 'https://noveldeglace.com/'; - version = '1.0.5'; + version = '1.0.8'; - async getCheerio(url: string): Promise { - const r = await fetchApi(url, { - headers: { 'Accept-Encoding': 'deflate' }, - }); - if (!r.ok) return undefined; - const body = await r.text(); - const loadedCheerio = load(body); - return loadedCheerio; + async getCheerio(url: string): Promise { + return load( + await fetchCheckedHtml(url, { + headers: { 'Accept-Encoding': 'deflate' }, + }), + ); } parseNovels( @@ -76,14 +101,11 @@ class NovelDeGlacePlugin implements Plugin.PluginBase { } url += '/page/' + pageNo; const $ = await this.getCheerio(url); - if (!$) return []; return this.parseNovels($, showLatestNovels); } async parseNovel(novelPath: string): Promise { const $ = await this.getCheerio(this.site + novelPath); - if (!$) throw new Error('Failed to load page (open in web view)'); - const novel: Plugin.SourceNovel = { path: novelPath, name: 'Untitled' }; novel.name = $('span.current').text().trim(); @@ -214,19 +236,26 @@ class NovelDeGlacePlugin implements Plugin.PluginBase { }); }); - novel.chapters = novelChapters; + novel.chapters = novelChapters.map((chapter, index) => ({ + ...chapter, + chapterNumber: index, + })); return novel; } async parseChapter(chapterPath: string): Promise { const $ = await this.getCheerio(this.site + chapterPath); - if (!$) throw new Error('Failed to load page (open in web view)'); - - $('.mistape_caption').remove(); - const chapterText = - $('.chapter-content').html() || $('.entry-content').html() || ''; - return chapterText; + const chapter = $('.chapter-content').first().length + ? $('.chapter-content').first() + : $('.entry-content').first(); + chapter.find('script, style, ins, iframe, .ads, .mistape_caption').remove(); + if ( + chapter.text().replace(/\s+/g, ' ').trim().length < 200 && + !chapter.find('img').length + ) + throw new Error('No readable chapter content found'); + return chapter.html() || ''; } async searchNovels( @@ -236,8 +265,6 @@ class NovelDeGlacePlugin implements Plugin.PluginBase { if (num !== 1) return []; // only 1 page of results const url = this.site + 'roman'; const $ = await this.getCheerio(url); - if (!$) throw new Error('Failed to load page (open in web view)'); - let novels = this.parseNovels($, false); novels = novels.filter(novel => diff --git a/plugins/french/novhell.ts b/plugins/french/novhell.ts index 2253c7442..a71ea2013 100644 --- a/plugins/french/novhell.ts +++ b/plugins/french/novhell.ts @@ -4,20 +4,46 @@ import { Plugin } from '@/types/plugin'; import { defaultCover } from '@libs/defaultCover'; import { NovelStatus } from '@libs/novelStatus'; +const challengeTitles = new Set([ + 'bot verification', + 'you are being redirected...', + 'un instant...', + 'just a moment...', + 'redirecting...', +]); + +async function fetchCheckedHtml( + url: string, + init?: Parameters[1], +): Promise { + const response = await fetchApi(url, init); + if (!response.ok) + throw new Error(`HTTP ${response.status} while loading ${url}`); + const html = await response.text(); + const title = html + .match(/]*>(.*?)<\/title>/is)?.[1] + ?.replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + if (title && challengeTitles.has(title)) + throw new Error(`Bot challenge while loading ${url}`); + return html; +} + class NovhellPlugin implements Plugin.PluginBase { id = 'novhell'; name = 'Novhell'; icon = 'src/fr/novhell/icon.png'; site = 'https://novhell.org'; - version = '1.0.1'; + version = '1.0.4'; async getCheerio(url: string): Promise { - const r = await fetchApi(url, { - headers: { 'Accept-Encoding': 'deflate' }, - }); - const body = await r.text(); - const $ = load(body); - return $; + return load( + await fetchCheckedHtml(url, { + headers: { 'Accept-Encoding': 'deflate' }, + }), + ); } async popularNovels(pageNo: number): Promise { @@ -65,7 +91,7 @@ class NovhellPlugin implements Plugin.PluginBase { ?.replace('- NovHell', '') || ''; novel.cover = $('section div div div div div img').first().attr('src') || defaultCover; - novel.status = NovelStatus.Ongoing; + novel.status = NovelStatus.Unknown; novel.author = $("strong:contains('Ecrit par ')") .parent() .text() @@ -107,6 +133,7 @@ class NovhellPlugin implements Plugin.PluginBase { .replace(':', '') .trim(); const chapters: Plugin.ChapterItem[] = []; + const chapterPaths = new Set(); $('main div article div div section div div div div div p a').each( (i, elem) => { @@ -118,6 +145,9 @@ class NovhellPlugin implements Plugin.PluginBase { const chapterUrl = $(elem).attr('href'); // Check if the chapter URL exists and contains the site name. if (chapterUrl && chapterUrl.includes(this.site)) { + const path = chapterUrl.replace(this.site, ''); + if (chapterPaths.has(path)) return; + chapterPaths.add(path); const regex = /Chapitre (\d+)/g; let chapterNumber = 0; let match; @@ -127,7 +157,7 @@ class NovhellPlugin implements Plugin.PluginBase { } chapters.push({ name: chapterName, - path: chapterUrl.replace(this.site, ''), + path, chapterNumber: chapterNumber, }); } @@ -170,10 +200,16 @@ class NovhellPlugin implements Plugin.PluginBase { const chapter = sections.eq(numberOfSection - positionChapter); if (title && chapter) { - return (title.html() || '') + (chapter.html() || ''); + const content = (title.html() || '') + (chapter.html() || ''); + const parsedContent = load(content); + if ( + parsedContent.text().replace(/\s+/g, ' ').trim().length >= 200 || + parsedContent('img').length + ) + return content; } } - return ''; + throw new Error('No readable chapter content found'); } async searchNovels( diff --git a/plugins/french/phenixscans.broken.ts b/plugins/french/phenixscans.broken.ts deleted file mode 100644 index ebb7421b4..000000000 --- a/plugins/french/phenixscans.broken.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { CheerioAPI, load } from 'cheerio'; -import { fetchApi } from '@libs/fetch'; -import { Plugin } from '@/types/plugin'; -import { Filters, FilterTypes } from '@libs/filterInputs'; -import { defaultCover } from '@libs/defaultCover'; -import { NovelStatus } from '@libs/novelStatus'; -import dayjs from 'dayjs'; - -class PhenixScansTradPlugin implements Plugin.PluginBase { - id = 'phenixscans'; - name = 'PhenixScans'; - icon = 'src/fr/phenixscans/icon.png'; - site = 'https://phenixscans.fr'; - version = '1.0.1'; - - async getCheerio(url: string): Promise { - const r = await fetchApi(url); - const body = await r.text(); - const $ = load(body); - return $; - } - - async popularNovels( - pageNo: number, - { showLatestNovels, filters }: Plugin.PopularNovelsOptions, - ): Promise { - const novels: Plugin.NovelItem[] = []; - let novel: Plugin.NovelItem; - - let filter = ''; - for (const key in filters) { - if (typeof filters[key].value === 'object') { - for (const value of filters[key].value as string[]) { - filter += `&genre%5B%5D=${value}`; - } - } - } - - const order = showLatestNovels ? 'update' : 'popular'; - const url = `${this.site}/manga/?page=${pageNo}${filter}&status=&type=novel&order=${order}`; - - const $ = await this.getCheerio(url); - $('div div div div a').each((i, elem) => { - const novelName = $(elem).attr('title')?.trim(); - const novelUrl = $(elem).attr('href'); - const novelCover = $(elem).find('div img').attr('src') || defaultCover; - - if (novelUrl && novelName) { - novel = { - name: novelName, - cover: novelCover, - path: novelUrl.replace(this.site, ''), - }; - novels.push(novel); - } - }); - return novels; - } - - async parseNovel(novelPath: string): Promise { - const novel: Plugin.SourceNovel = { - path: novelPath, - name: 'Sans titre', - }; - - const $ = await this.getCheerio(this.site + novelPath); - - novel.name = $('h1[itemprop=name]').text().replace('– Novel', '').trim(); - novel.cover = - $('div[itemprop=image] img').first().attr('src') || defaultCover; - novel.author = $('.fmed b:contains(Auteur)+span').text().trim(); - novel.genres = $('.mgen a') - .map(function () { - return $(this).text(); - }) - .get() - .join(', '); - novel.summary = $('.entry-content[itemprop=description]').text().trim(); - novel.status = this.getStatus( - $('.tsinfo .imptdt:contains(Statut)').text().replace('Statut', '').trim(), - ); - - const chapters: Plugin.ChapterItem[] = []; - $('ul li:has(div.chbox):has(div.eph-num)').each((i, elem) => { - const chapterName = $(elem).find('a .chapternum').text().trim(); - const chapterUrl = $(elem).find('a').attr('href'); - const releaseDate = this.parseDate($(elem).find('a .chapterdate').text()); - if (chapterUrl && chapterUrl.includes(this.site) && chapterName) { - chapters.push({ - name: chapterName, - path: chapterUrl.replace(this.site, ''), - releaseTime: releaseDate, - }); - } - }); - novel.chapters = chapters; - return novel; - } - - parseDate(date: string): string { - const monthMapping: Record = { - janvier: 1, - fevrier: 2, - mars: 3, - avril: 4, - mai: 5, - juin: 6, - juillet: 7, - aout: 8, - septembre: 9, - octobre: 10, - novembre: 11, - decembre: 12, - }; - - const [day, month, year] = date.split(' '); - return dayjs( - `${day} ${monthMapping[month.normalize('NFD').replace(/[\u0300-\u036f]/g, '')]} ${year}`, - 'D MMMM YYYY', - ).format('DD MMMM YYYY'); - } - - getStatus(status: string) { - const lowerCaseStatus = status.toLowerCase(); - const ongoing = ['en cours', 'en cours de publication']; - const onhiatus = ['en pause', 'en attente']; - const completed = ['complété', 'fini', 'achevé', 'terminé']; - const cancelled = ['abandonné']; - - if (ongoing.includes(lowerCaseStatus)) { - return NovelStatus.Ongoing; - } else if (onhiatus.includes(lowerCaseStatus)) { - return NovelStatus.OnHiatus; - } else if (completed.includes(lowerCaseStatus)) { - return NovelStatus.Completed; - } else if (cancelled.includes(lowerCaseStatus)) { - return NovelStatus.Cancelled; - } - return NovelStatus.Unknown; - } - - async parseChapter(chapterPath: string): Promise { - const $ = await this.getCheerio(this.site + chapterPath); - return $('#readerarea').html() || ''; - } - - async searchNovels( - searchTerm: string, - pageNo: number, - ): Promise { - if (pageNo !== 1) return []; - - const popularNovels = this.popularNovels(1, { - showLatestNovels: true, - filters: undefined, - }); - - const novels = (await popularNovels).filter(novel => - novel.name - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .trim() - .includes( - searchTerm - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .trim(), - ), - ); - - return novels; - } - - filters = { - genre: { - type: FilterTypes.CheckboxGroup, - label: 'Genre', - value: [], - options: [ - { label: 'Action', value: 'action' }, - { - label: 'Action Adventure Fantaisie Psychologique', - value: 'action-adventure-fantaisie-psychologique', - }, - { - label: 'Action Arts martiaux Aventure Fantastique Surnaturel', - value: 'action-arts-martiaux-aventure-fantastique-surnaturel', - }, - { - label: 'Action Aventure Fantastique', - value: 'action-aventure-fantastique', - }, - { label: 'Action Drame Shônen', value: 'action-drame-shonen' }, - { label: 'Adult', value: 'adult' }, - { label: 'Adventure', value: 'adventure' }, - { label: 'amitié', value: 'amitie' }, - { label: 'amour', value: 'amour' }, - { label: 'Art-martiaux', value: 'art-martiaux' }, - { label: 'Arts-martiaux', value: 'arts-martiaux' }, - { label: 'Aventure', value: 'aventure' }, - { label: 'Combat', value: 'combat' }, - { label: 'Comedie', value: 'comedie' }, - { label: 'Comedy', value: 'comedy' }, - { label: 'Demons', value: 'demons' }, - { label: 'Dragon', value: 'dragon' }, - { label: 'Drama', value: 'drama' }, - { label: 'Drame', value: 'drame' }, - { label: 'Ecchi', value: 'ecchi' }, - { label: 'Fantaisie', value: 'fantaisie' }, - { label: 'fantastique', value: 'fantastique' }, - { label: 'Fantasy', value: 'fantasy' }, - { label: 'Ghosts', value: 'ghosts' }, - { label: 'Harem', value: 'harem' }, - { label: 'Historical', value: 'historical' }, - { label: 'Historique', value: 'historique' }, - { label: 'Horreur', value: 'horreur' }, - { label: 'Horror', value: 'horror' }, - { label: 'Isekai', value: 'isekai' }, - { label: 'Josei', value: 'josei' }, - { label: 'longstrip art', value: 'longstrip-art' }, - { label: 'Magic', value: 'magic' }, - { label: 'Magie', value: 'magie' }, - { label: 'Male Protagonist', value: 'male-protagonist' }, - { label: 'manga', value: 'manga' }, - { label: 'Manhwa', value: 'manhwa' }, - { label: 'Manhwa Player', value: 'manhwa-player' }, - { label: 'Manwha', value: 'manwha' }, - { label: 'Martial Arts', value: 'martial-arts' }, - { label: 'Mature', value: 'mature' }, - { label: 'Monstre', value: 'monstre' }, - { label: 'Murim', value: 'murim' }, - { label: 'mystère', value: 'mystere' }, - { label: 'Mystery', value: 'mystery' }, - { label: 'Necromancien', value: 'necromancien' }, - { label: 'necromencer', value: 'necromencer' }, - { label: 'Novel', value: 'novel' }, - { label: 'One Shot', value: 'one-shot' }, - { label: 'Over Power MC', value: 'over-power-mc' }, - { label: 'Partenaire', value: 'partenaire' }, - { label: 'Player', value: 'player' }, - { label: 'Player Manhwa', value: 'player-manhwa' }, - { label: 'Portail', value: 'portail' }, - { label: 'Psychological', value: 'psychological' }, - { label: 'Psychologique', value: 'psychologique' }, - { label: 'regresseur', value: 'regresseur' }, - { label: 'régression', value: 'regression' }, - { label: 'Réincarnation', value: 'reincarnation' }, - { label: 'Returner', value: 'returner' }, - { label: 'Romance', value: 'romance' }, - { label: 'School Life', value: 'school-life' }, - { label: 'Sci-fi', value: 'sci-fi' }, - { label: 'Seinen', value: 'seinen' }, - { label: 'Shôjo', value: 'shojo' }, - { label: 'Shônen', value: 'shonen' }, - { label: 'Shotacon', value: 'shotacon' }, - { label: 'Shoujo', value: 'shoujo' }, - { label: 'Shounen', value: 'shounen' }, - { label: 'Slice of Life', value: 'slice-of-life' }, - { label: 'Slide of Life', value: 'slide-of-life' }, - { label: 'Smut', value: 'smut' }, - { label: 'Sports', value: 'sports' }, - { label: 'Supernatural', value: 'supernatural' }, - { label: 'Surnaturel', value: 'surnaturel' }, - { label: 'Système', value: 'systeme' }, - { label: 'Tragédie', value: 'tragedie' }, - { label: 'Tragedy', value: 'tragedy' }, - { label: 'Webtoons', value: 'webtoons' }, - ], - }, - } satisfies Filters; -} - -export default new PhenixScansTradPlugin(); diff --git a/plugins/french/rezerowebnovelfr.ts b/plugins/french/rezerowebnovelfr.ts index 8d56e2237..a734f8a6e 100644 --- a/plugins/french/rezerowebnovelfr.ts +++ b/plugins/french/rezerowebnovelfr.ts @@ -3,6 +3,33 @@ import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { NovelStatus } from '@libs/novelStatus'; +const challengeTitles = new Set([ + 'bot verification', + 'you are being redirected...', + 'un instant...', + 'just a moment...', + 'redirecting...', +]); + +async function fetchCheckedHtml( + url: string, + init?: Parameters[1], +): Promise { + const response = await fetchApi(url, init); + if (!response.ok) + throw new Error(`HTTP ${response.status} while loading ${url}`); + const html = await response.text(); + const title = html + .match(/]*>(.*?)<\/title>/is)?.[1] + ?.replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + if (title && challengeTitles.has(title)) + throw new Error(`Bot challenge while loading ${url}`); + return html; +} + const NOVEL_METADATA: Record = { '/histoire-principale/': { name: 'Re:Zero - Histoire Principale', @@ -626,15 +653,14 @@ class ReZeroWebNovelFrPlugin implements Plugin.PluginBase { name = 'Re:Zero Web Novel FR'; icon = 'src/fr/rezerowebnovelfr/icon.png'; site = 'https://rezerowebnovelfr.wordpress.com'; - version = '1.0.1'; + version = '1.0.3'; async getCheerio(url: string): Promise { - const r = await fetchApi(url, { - headers: { 'Accept-Encoding': 'deflate' }, - }); - const body = await r.text(); - const $ = load(body); - return $; + return load( + await fetchCheckedHtml(url, { + headers: { 'Accept-Encoding': 'deflate' }, + }), + ); } async popularNovels(pageNo: number): Promise { @@ -656,7 +682,7 @@ class ReZeroWebNovelFrPlugin implements Plugin.PluginBase { const $ = await this.getCheerio(this.site + novelPath); novel.name = $('h1.entry-title').text().trim(); novel.author = 'Tappei Nagatsuki'; - novel.status = NovelStatus.Ongoing; + novel.status = NovelStatus.Unknown; const meta = NOVEL_METADATA[novelPath]; if (meta) { @@ -666,6 +692,7 @@ class ReZeroWebNovelFrPlugin implements Plugin.PluginBase { } const chapters: Plugin.ChapterItem[] = []; + const chapterPaths = new Set(); const tryAddChapter = ( href: string | undefined, @@ -677,7 +704,8 @@ class ReZeroWebNovelFrPlugin implements Plugin.PluginBase { const dateMatch = cleanHref.match(/\/(\d{4})\/(\d{2})\/(\d{2})\//); if (dateMatch && name) { const path = cleanHref.replace(this.site, ''); - if (!chapters.some(c => c.path === path)) { + if (!chapterPaths.has(path)) { + chapterPaths.add(path); const releaseDate = `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`; let cleanName = name; if (novelPath === '/if-stories/') { @@ -806,6 +834,7 @@ class ReZeroWebNovelFrPlugin implements Plugin.PluginBase { $( 'div.entry-content .sharedaddy, div.entry-content .wpcnt, div.entry-content #jp-post-flair, div.entry-content div[id^="atatags-"]', ).remove(); + $('div.entry-content').find('script, style, ins, iframe, .ads').remove(); const title = $('h1.entry-title').html() || ''; const chapter = $('div.entry-content').html() || ''; diff --git a/plugins/french/tradindex.ts b/plugins/french/tradindex.ts new file mode 100644 index 000000000..632f7ad58 --- /dev/null +++ b/plugins/french/tradindex.ts @@ -0,0 +1,285 @@ +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(); diff --git a/plugins/french/warriorlegendtrad.ts b/plugins/french/warriorlegendtrad.ts index b411b424d..f9e579fb0 100644 --- a/plugins/french/warriorlegendtrad.ts +++ b/plugins/french/warriorlegendtrad.ts @@ -5,12 +5,39 @@ import { defaultCover } from '@libs/defaultCover'; import { NovelStatus } from '@libs/novelStatus'; import dayjs from 'dayjs'; +const challengeTitles = new Set([ + 'bot verification', + 'you are being redirected...', + 'un instant...', + 'just a moment...', + 'redirecting...', +]); + +async function fetchCheckedHtml( + url: string, + init?: Parameters[1], +): Promise { + const response = await fetchApi(url, init); + if (!response.ok) + throw new Error(`HTTP ${response.status} while loading ${url}`); + const html = await response.text(); + const title = html + .match(/]*>(.*?)<\/title>/is)?.[1] + ?.replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + if (title && challengeTitles.has(title)) + throw new Error(`Bot challenge while loading ${url}`); + return html; +} + class WarriorLegendTradPlugin implements Plugin.PluginBase { id = 'warriorlegendtrad'; name = 'Warrior Legend Trad'; icon = 'src/fr/warriorlegendtrad/icon.png'; site = 'https://warriorlegendtrad.wordpress.com'; - version = '1.0.1'; + version = '1.0.4'; regexAuthors = [/Auteur\u00A0:([^\n]*)/]; @@ -19,10 +46,7 @@ class WarriorLegendTradPlugin implements Plugin.PluginBase { regexSummary = [/Synopsis\u00A0:([\s\S]*)index chapitre :/i]; async getCheerio(url: string): Promise { - const r = await fetchApi(url); - const body = await r.text(); - const $ = load(body); - return $; + return load(await fetchCheckedHtml(url)); } async popularNovels(pageNo: number): Promise { @@ -84,7 +108,7 @@ class WarriorLegendTradPlugin implements Plugin.PluginBase { const chapterUrl = $(elem).attr('href'); const releaseDate = dayjs( chapterUrl?.substring(this.site.length + 1, this.site.length + 11), - ).format('DD MMMM YYYY'); + ).format('YYYY-MM-DD'); if (chapterUrl && chapterUrl.includes(this.site) && chapterName) { chapters.push({ name: chapterName, @@ -137,11 +161,12 @@ class WarriorLegendTradPlugin implements Plugin.PluginBase { return NovelStatus.Cancelled; } - return NovelStatus.Ongoing; + return NovelStatus.Unknown; } async parseChapter(chapterPath: string): Promise { const $ = await this.getCheerio(this.site + chapterPath); + $('.entry-content').find('script, style, ins, iframe, .ads').remove(); let contenuHtml = ''; $('.entry-content') .contents() @@ -163,9 +188,11 @@ class WarriorLegendTradPlugin implements Plugin.PluginBase { ): Promise { if (pageNo !== 1) return []; - const popularNovels = this.popularNovels(1); + const popularNovels = ( + await Promise.all([this.popularNovels(1), this.popularNovels(2)]) + ).flat(); - const novels = (await popularNovels).filter(novel => + const novels = popularNovels.filter(novel => novel.name .toLowerCase() .normalize('NFD') diff --git a/plugins/french/wuxialnscantrad.ts b/plugins/french/wuxialnscantrad.ts index 5789ec2b8..29fc756b3 100644 --- a/plugins/french/wuxialnscantrad.ts +++ b/plugins/french/wuxialnscantrad.ts @@ -5,20 +5,70 @@ import { defaultCover } from '@libs/defaultCover'; import { NovelStatus } from '@libs/novelStatus'; import dayjs from 'dayjs'; +const challengeTitles = new Set([ + 'bot verification', + 'you are being redirected...', + 'un instant...', + 'just a moment...', + 'redirecting...', +]); + +async function fetchCheckedHtml( + url: string, + init?: Parameters[1], +): Promise { + const response = await fetchApi(url, init); + if (!response.ok) + throw new Error(`HTTP ${response.status} while loading ${url}`); + const html = await response.text(); + const title = html + .match(/]*>(.*?)<\/title>/is)?.[1] + ?.replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + if (title && challengeTitles.has(title)) + throw new Error(`Bot challenge while loading ${url}`); + return html; +} + class WuxialnscantradPlugin implements Plugin.PluginBase { id = 'wuxialnscantrad'; name = 'WuxiaLnScantrad'; icon = 'src/fr/wuxialnscantrad/icon.png'; site = 'https://wuxialnscantrad.wordpress.com'; - version = '1.0.0'; + version = '1.0.4'; + + private async findMovedChapter(chapterPath: string): Promise { + const slug = chapterPath.split('/').filter(Boolean).pop() || ''; + const match = slug.match(/^(.*?)-chapitre-(\d+)/i); + if (!match) return null; + const series = match[1].split('-').filter(Boolean).slice(0, 3).join(' '); + const query = `${series} chapitre ${match[2]}`; + const response = await fetchApi( + `https://public-api.wordpress.com/wp/v2/sites/wuxialnscantrad.wordpress.com/search?search=${encodeURIComponent(query)}&type=post&subtype=post&per_page=20`, + ); + if (!response.ok) return null; + const results = (await response.json()) as { + title?: string; + url?: string; + }[]; + const replacement = results.find(result => + new RegExp(`chapitre\\D*${match[2]}(?:\\D|$)`, 'i').test( + result.title || '', + ), + )?.url; + if (!replacement) return null; + const url = new URL(replacement, this.site); + return url.origin === new URL(this.site).origin ? url.toString() : null; + } async getCheerio(url: string): Promise { - const r = await fetchApi(url, { - headers: { 'Accept-Encoding': 'deflate' }, - }); - const body = await r.text(); - const $ = load(body); - return $; + return load( + await fetchCheckedHtml(url, { + headers: { 'Accept-Encoding': 'deflate' }, + }), + ); } async popularNovels(pageNo: number): Promise { @@ -41,6 +91,15 @@ class WuxialnscantradPlugin implements Plugin.PluginBase { novels.push(novel); } }); + await Promise.all( + novels.map(async item => { + const detail = await this.getCheerio(this.site + item.path); + item.cover = + detail('.entry-content p strong img').first().attr('src') || + detail('.entry-content p img').first().attr('src') || + defaultCover; + }), + ); return novels; } @@ -66,16 +125,17 @@ class WuxialnscantradPlugin implements Plugin.PluginBase { const pathChapter = $('.entry-content ul').first().children('li'); const chapters: Plugin.ChapterItem[] = []; + const chapterPaths = new Set(); pathChapter.each((i, elem) => { const chapterName = $(elem).text().trim(); const chapterUrl = $(elem).find('a').attr('href'); if (chapterUrl && chapterUrl.includes(this.site) && chapterName) { const pathchapter = chapterUrl.replace(this.site, ''); - // we do not take the paths already present - if (!chapters.some(chap => chap.path === pathchapter)) { + if (!chapterPaths.has(pathchapter)) { + chapterPaths.add(pathchapter); const releaseDate = dayjs( chapterUrl?.substring(this.site.length + 1, this.site.length + 11), - ).format('DD MMMM YYYY'); + ).format('YYYY-MM-DD'); chapters.push({ name: chapterName, path: pathchapter, @@ -149,12 +209,21 @@ class WuxialnscantradPlugin implements Plugin.PluginBase { case 'Terminé': return NovelStatus.Completed; default: - return NovelStatus.Ongoing; + return NovelStatus.Unknown; } } async parseChapter(chapterPath: string): Promise { - const $ = await this.getCheerio(this.site + chapterPath); + const options = { headers: { 'Accept-Encoding': 'deflate' } }; + let body: string; + try { + body = await fetchCheckedHtml(this.site + chapterPath, options); + } catch (error) { + const replacement = await this.findMovedChapter(chapterPath); + if (!replacement) throw error; + body = await fetchCheckedHtml(replacement, options); + } + const $ = load(body); let contenuHtml = ''; $('.entry-content') @@ -172,6 +241,12 @@ class WuxialnscantradPlugin implements Plugin.PluginBase { contenuHtml += $.html(this); } }); + const parsedContent = load(contenuHtml); + if ( + parsedContent.text().replace(/\s+/g, ' ').trim().length < 200 && + !parsedContent('img').length + ) + throw new Error('No readable chapter content found'); return contenuHtml; } diff --git a/plugins/french/xiaowaz.ts b/plugins/french/xiaowaz.ts index 5d3a09e50..5563d68c1 100644 --- a/plugins/french/xiaowaz.ts +++ b/plugins/french/xiaowaz.ts @@ -5,12 +5,39 @@ import { Plugin } from '@/types/plugin'; import { defaultCover } from '@libs/defaultCover'; import { NovelStatus } from '@libs/novelStatus'; +const challengeTitles = new Set([ + 'bot verification', + 'you are being redirected...', + 'un instant...', + 'just a moment...', + 'redirecting...', +]); + +async function fetchCheckedHtml( + url: string, + init?: Parameters[1], +): Promise { + const response = await fetchApi(url, init); + if (!response.ok) + throw new Error(`HTTP ${response.status} while loading ${url}`); + const html = await response.text(); + const title = html + .match(/]*>(.*?)<\/title>/is)?.[1] + ?.replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + if (title && challengeTitles.has(title)) + throw new Error(`Bot challenge while loading ${url}`); + return html; +} + class XiaowazPlugin implements Plugin.PluginBase { id = 'xiaowaz'; name = 'Xiaowaz'; icon = 'src/fr/xiaowaz/icon.png'; site = 'https://xiaowaz.fr'; - version = '1.0.2'; + version = '1.0.5'; static novels: Plugin.NovelItem[] | undefined; async getCheerio(url: string): Promise { @@ -18,10 +45,7 @@ class XiaowazPlugin implements Plugin.PluginBase { let returnError: unknown; while (retries > 0) { try { - const r = await fetchApi(url); - const body = await r.text(); - const $ = load(body); - return $; + return load(await fetchCheckedHtml(url)); } catch (error) { console.error(error); returnError = error; @@ -171,13 +195,22 @@ class XiaowazPlugin implements Plugin.PluginBase { } const chapters: Plugin.ChapterItem[] = []; + const chapterPaths = new Set(); pathChapter.each((i, elem) => { const chapterName = $(elem).text().trim(); const chapterUrl = $(elem).attr('href'); - if (chapterUrl && chapterUrl.includes(this.site) && chapterName) { + if ( + chapterUrl && + chapterUrl.includes(this.site) && + !/\.pdf(?:$|[?#])/i.test(chapterUrl) && + chapterName + ) { + const path = chapterUrl.replace(this.site, ''); + if (chapterPaths.has(path)) return; + chapterPaths.add(path); chapters.push({ name: chapterName, - path: chapterUrl.replace(this.site, ''), + path, }); } }); @@ -229,15 +262,20 @@ class XiaowazPlugin implements Plugin.PluginBase { async parseChapter(chapterPath: string): Promise { const $ = await this.getCheerio(this.site + chapterPath); - const startTag = $('.wp-post-navigation'); - const endTag = $('.abh_box.abh_box_down.abh_box_business'); + const startTag = $('.entry-content .wp-post-navigation').first(); const elementsBetweenTags: string[] = []; let footnotesElement: string | null = null; - if (startTag.length > 0 && endTag.length > 0) { + if (startTag.length > 0) { let currentElement = startTag.next(); - while (currentElement.length > 0 && !currentElement.is(endTag)) { + while (currentElement.length > 0) { + if ( + currentElement.hasClass('wp-post-navigation') || + currentElement.is('.abh_box.abh_box_down.abh_box_business') + ) { + break; + } if ( currentElement.find('p > a[href="https://ko-fi.com/wazouille"]') .length > 0 diff --git a/public/static/src/fr/jgarden/icon.png b/public/static/src/fr/jgarden/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0fe9c5f14e38e26b2b4ecd9d2223318003b90460 GIT binary patch literal 15179 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

~JWTxLR4=FwoN_*5+P0eU4Z-&S|it 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 literal 0 HcmV?d00001 diff --git a/public/static/src/fr/phenixscans/icon.png b/public/static/src/fr/phenixscans/icon.png deleted file mode 100644 index e4a836c0b9dc26f18cef59c5722ef48608921bf4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5672 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 Ze7dAxmDtK?pa1SIKvPvmrCJ#g`acE-7*qfN diff --git a/public/static/src/fr/tradindex/icon.png b/public/static/src/fr/tradindex/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..36567fc68341c9a655332b37e1ac769eebfe8677 GIT binary patch literal 5513 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 LNkvXXu0mjfO!T+W literal 0 HcmV?d00001