import { Cheerio, CheerioAPI, load } from 'cheerio'; import { Element } from 'domhandler'; import { fetchApi } from '@libs/fetch'; 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.5'; static novels: Plugin.NovelItem[] | undefined; async getCheerio(url: string): Promise { let retries = 5; // when fetching for images the sites sometimes terminates the connection let returnError: unknown; while (retries > 0) { try { return load(await fetchCheckedHtml(url)); } catch (error) { console.error(error); returnError = error; retries--; // wait 1 second before retrying await new Promise(resolve => setTimeout(resolve, 1000)); } } throw new Error( returnError ? String(returnError) : 'Error fetching the page', ); } async getAllNovels(): Promise { const novels: Plugin.NovelItem[] = []; let novel: Plugin.NovelItem; const $ = await this.getCheerio(this.site); const categories: Cheerio[] = [ $('li.page_item').find('a:contains("Séries")').parent(), $('li.page_item').find('a:contains("Créations")').parent(), $('li.page_item').find('a:contains("†")').parent(), ]; categories.forEach(cheerio => { cheerio.find('ul.children li').each((i, elem) => { const novelName = $(elem).first().text().trim().replace('✔', ''); const novelUrl = $(elem).find('a').attr('href'); // Douluo Dalu is no good if (novelUrl && novelName && novelName !== 'Douluo Dalu') { novel = { name: novelName, path: novelUrl.replace(this.site, ''), }; novels.push(novel); } }); }); return novels; } async getNovelsCovers( novels: Plugin.NovelItem[], ): Promise { await Promise.all( novels.map(async novel => { const $novel = await this.getCheerio(this.site + novel.path); novel.cover = $novel('img[fetchpriority = "high"]').first().attr('src') || $novel('img.aligncenter').first().attr('src') || defaultCover; }), ); return novels; } async popularNovels(pageNo: number): Promise { const PAGE_SIZE = 5; if (!XiaowazPlugin.novels) XiaowazPlugin.novels = await this.getAllNovels(); let novels: Plugin.NovelItem[] = XiaowazPlugin.novels; const totalPages = Math.ceil(novels.length / PAGE_SIZE); if (pageNo > totalPages) return []; // splitting novel list to make fewer requests for getting images novels = novels.slice(PAGE_SIZE * (pageNo - 1), PAGE_SIZE * pageNo); return await this.getNovelsCovers(novels); } async parseNovel(novelPath: string): Promise { const novel: Plugin.SourceNovel = { path: novelPath, name: 'Sans titre', }; const $ = await this.getCheerio(this.site + novelPath); novel.name = $('.card_title').text().trim(); novel.cover = $('img[fetchpriority = "high"]').first().attr('src') || $('img.aligncenter').first().attr('src') || defaultCover; novel.status = NovelStatus.Ongoing; if (novel.name.charAt(novel.name.length - 1) === '✔') { novel.status = NovelStatus.Completed; novel.name = novel.name.slice(0, -1); } else if (novelPath.startsWith('/series-abandonnees')) { novel.status = NovelStatus.Cancelled; } const entryContentText = $('.entry-content').text(); novel.author = this.getAuthor(entryContentText); novel.genres = this.getGenres(entryContentText); if (novelPath.startsWith('/oeuvres-originales')) { novel.genres += novel.genres ? ', Oeuvre originale' : 'Oeuvre originale'; } const listeParagraphe: string[] = []; const PARAGRAPH_EXCLUDE_LIST = [ 'Écrit par', 'Ecrit par', 'Sorties régulières', 'Auteur\u00A0:', 'Statut VO\u00A0:', 'Nom utilisé\u00A0:', 'Auteur original\u00A0:', 'Auteur original de l’oeuvre', '851 chapitres en', 'Index\u00A0:', ]; $('.entry-content > p').each((index, element) => { const balise = $(element); // remove chapter links if (balise.find('a[href*="xiaowaz.fr/articles"]').length !== 0) return false; const textbalise = balise.text(); if (PARAGRAPH_EXCLUDE_LIST.some(keyword => textbalise.includes(keyword))) return false; if ( !textbalise.includes('Genre') && !textbalise.includes('Synopsis') && //Managing the novel 'Rebirth of The Thief Who Roamed The World' to remove these three fields. !textbalise.includes('重生之賊行天下') && !textbalise.includes('Rebirth of The Thief Who Roamed The World') && !textbalise.includes( 'Romance, Comédie, Action, VRMMO, Réincarnation, Futuriste', ) ) listeParagraphe.push(textbalise); }); novel.summary = listeParagraphe.join('\n').trim(); //Search for chapter links within ul/li tags; otherwise, within p/a tags, //but not both at the same time because otherwise, on the TDG page, it redirects to a PDF download link. let pathChapter = $('.entry-content ul li a'); if (pathChapter.length === 0) { pathChapter = $('.entry-content p a'); } 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) && !/\.pdf(?:$|[?#])/i.test(chapterUrl) && chapterName ) { const path = chapterUrl.replace(this.site, ''); if (chapterPaths.has(path)) return; chapterPaths.add(path); chapters.push({ name: chapterName, path, }); } }); novel.chapters = chapters; return novel; } getAuthor(text: string) { const regexAuthors = [ /Écrit par([^\n]*). Traduction/i, /Écrit par([^\n]*)./i, /Auteur original de l’œuvre\u00A0:([^\n]*)VO/i, /Auteur\u00A0:([^\n]*)sur/, /Auteur\u00A0:([^\n]*)/, /Auteure\u00A0:\u00A0([^\n]*)/, /Auteur original de l’oeuvre\u00A0:([^\n]*)/i, /Auteur original\u00A0:([^\n]*)/i, ]; for (const regex of regexAuthors) { const match = regex.exec(text); if (match !== null) { return match[1].trim(); } } return ''; } getGenres(text: string) { // We handle several cases where there are multiple spellings for the word 'genre'. Genre, Genre :, Genres, Genres: const regex = /Genre((?:.*?\n)*?)\s*Synopsis\s*/; const match = regex.exec(text); let genre = ''; if (match !== null) { genre = match[1] .replace('\u00A0', ' ') .replace('s :', '') .replace(':', '') .trim(); if (genre.startsWith('s\n')) { genre = genre.replace(/^./, '').trim(); } } return genre; } async parseChapter(chapterPath: string): Promise { const $ = await this.getCheerio(this.site + chapterPath); const startTag = $('.entry-content .wp-post-navigation').first(); const elementsBetweenTags: string[] = []; let footnotesElement: string | null = null; if (startTag.length > 0) { let currentElement = startTag.next(); 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 ) { break; } if (currentElement.hasClass('footnote_container_prepare')) { footnotesElement = $.html(currentElement); } else { let htmlCurrentElement = $.html(currentElement); htmlCurrentElement = htmlCurrentElement === '

 

' ? '

' : htmlCurrentElement; elementsBetweenTags.push(htmlCurrentElement); } currentElement = currentElement.next(); } } // Place footnotes at the end of the chapter, not at the beginning if (footnotesElement) { elementsBetweenTags.push(footnotesElement); } return elementsBetweenTags.join('\n').trim(); } async searchNovels( searchTerm: string, pageNo: number, ): Promise { if (pageNo !== 1) return []; if (!XiaowazPlugin.novels) XiaowazPlugin.novels = await this.getAllNovels(); const popularNovels = XiaowazPlugin.novels; // Normalize the text to remove accents and other special characters // This ensures that the search term and novel names are compared accurately const novels = popularNovels.filter(novel => novel.name .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .trim() .includes( searchTerm .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .trim(), ), ); return await this.getNovelsCovers(novels); } } export default new XiaowazPlugin();