import { load as parseHTML } from 'cheerio'; import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { defaultCover } from '@libs/defaultCover'; import { NovelStatus } from '@libs/novelStatus'; const makeAbsolute = ( relativeUrl: string | undefined, baseUrl: string, ): string | undefined => { if (!relativeUrl) return undefined; try { if (relativeUrl.startsWith('//')) { return new URL(baseUrl).protocol + relativeUrl; } if ( relativeUrl.startsWith('http://') || relativeUrl.startsWith('https://') ) { return relativeUrl; } return new URL(relativeUrl, baseUrl).href; } catch { return undefined; } }; class ixdzs8Plugin implements Plugin.PluginBase { id = 'ixdzs8'; name = '爱下电子书'; site = 'https://ixdzs8.com/'; version = '2.2.9'; icon = 'src/cn/ixdzs8/favicon.png'; imageRequestInit = { headers: { Referer: this.site, }, }; async popularNovels(pageNo: number): Promise { const url = `${this.site}hot/?page=${pageNo}`; const result = await fetchApi(url); if (!result.ok) return []; const $ = parseHTML(await result.text()); const novels: Plugin.NovelItem[] = []; const processedPaths = new Set(); $('ul.u-list > li.burl').each((_i, el) => { const $el = $(el); const $link = $el.find('.l-info h3 a'); const novelPath: string | undefined = $link.attr('href')?.trim(); const novelName: string | undefined = ( $link.attr('title') || $link.text() || '' ).trim(); const novelCover: string | undefined = $el .find('.l-img img') .attr('src') ?.trim(); if (novelPath && novelName) { novels.push({ name: novelName, path: novelPath, cover: makeAbsolute(novelCover, this.site) || defaultCover, }); processedPaths.add(novelPath); } }); return novels; } async parseNovel(novelPath: string): Promise { const novelUrl = makeAbsolute(novelPath, this.site); if (!novelUrl) throw new Error('Invalid novel URL'); const result = await fetchApi(novelUrl); if (!result.ok) throw new Error('Failed to fetch novel'); const $ = parseHTML(await result.text()); const $novel = $('div.novel'); const $intro = $('p#intro.pintro'); // Remove any child elements you don't want, e.g., the "read more" icon $intro.find('span.icon').remove(); // Get text and normalize whitespace const summary = $intro .html() // get inner HTML ?.replace(//gi, '\n') // convert
to line breaks .replace(/<[^>]+>/g, '') // remove remaining tags like .replace(/\u3000/g, ' ') // replace full-width spaces .replace(/ /g, ' ') // replace spaces .split('\n') // split into lines .map(line => line.trim()) // trim each line .filter(line => line.length > 0) // remove empty lines .join(' '); // join into one paragraph const $tagsDiv = $('div.panel div.tags'); // find all inside and get their text // Select

that contains const statOngoing = $novel.find('.n-text p span.lz').text().trim(); const statEnd = $novel.find('.n-text p span.end').text().trim(); let detail: 'Ongoing' | 'Completed' | 'Unknown' = 'Unknown'; if (statEnd.length > 0) { detail = 'Completed'; } else if (statOngoing.length > 0) { detail = 'Ongoing'; } else { detail = 'Unknown'; } const novel: Plugin.SourceNovel = { path: novelPath, name: $novel.find('.n-text h1').text().trim() || 'Untitled', cover: makeAbsolute($novel.find('.n-img img').attr('src'), this.site) || defaultCover, summary: summary, author: $novel.find('.n-text p a.bauthor').text().trim() || undefined, genres: $tagsDiv .find('em a') .map((_i, el) => $(el).text().trim()) .get() .filter(tag => tag.length > 0) // remove empty strings .join(', '), status: detail === 'Ongoing' ? NovelStatus.Ongoing : detail === 'Completed' ? NovelStatus.Completed : NovelStatus.Unknown, chapters: [], }; const chapterListPath = $('#bid').attr('value'); // get bid from page if (chapterListPath) { novel.chapters = await this.parseChapterList(chapterListPath); } return novel; } async parseChapterList(bid: string | number): Promise { // Convert bid to string just in case const bookId = String(bid); // POST request to fetch chapter list JSON const url = `${this.site}novel/clist/`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: `bid=${bookId}`, }); if (!res.ok) { throw new Error(`Failed to fetch chapters for bid=${bookId}`); } const json: ChapterJSON = await res.json(); if (json.rs !== 200 || !Array.isArray(json.data)) { throw new Error('Invalid response format from chapter list'); } // Build chapters array const chapters: Plugin.ChapterItem[] = json.data.map(ch => { return { name: ch.title, path: ch.ctype === '0' ? `read/${bookId}/p${ch.ordernum}.html` : '', // only normal chapters get link releaseTime: undefined, // optional, not provided here }; }); return chapters; } async parseChapter(chapterPath: string): Promise { const chapterUrl = makeAbsolute(chapterPath, this.site); if (!chapterUrl) throw new Error('Invalid chapter URL'); // --- 1st request --- let result = await fetchApi(chapterUrl); if (!result.ok) throw new Error(`Failed to fetch chapter at ${chapterUrl}`); let html = await result.text(); // --- Check if we got challenge page --- if (html.includes('正在進行安全驗證') || html.includes('challenge')) { const tokenMatch = html.match(/let token\s*=\s*"([^"]+)"/); if (tokenMatch) { const challengeUrl = chapterUrl + '?challenge=' + encodeURIComponent(tokenMatch[1]); result = await fetchApi(challengeUrl); if (!result.ok) throw new Error(`Failed after challenge redirect: ${challengeUrl}`); html = await result.text(); } } // --- Parse content --- const $ = parseHTML(html); const $content = $('article section'); if (!$content.length) { return `Error: Could not find chapter content at ${chapterUrl}`; } // Remove ads & junk $content .find( 'script, style, ins, iframe, [class*="abg"], [class*="ads"], [id*="ads"], [class*="google"], [id*="google"], [class*="recommend"], div[align="center"], p:contains("推薦本書"), a[href*="javascript:"]', ) .remove(); // Drop empty

$content.find('p').each((_i, el) => { const $p = $(el); if (!$p.text().trim()) $p.remove(); }); // Remove HTML comments $content .contents() .filter(function () { return this.type === 'comment'; }) .remove(); // Unwrap $content.find('font').each((_i, el) => { const $el = $(el); $el.replaceWith($el.html() || ''); }); // Extract cleaned text let chapterText = $content.html(); if (!chapterText) return 'Error: Chapter content was empty'; chapterText = chapterText .replace(/<\s*p[^>]*>/gi, '\n\n') .replace(/<\s*br[^>]*>/gi, '\n'); chapterText = parseHTML(`

${chapterText}
`).text(); return chapterText .replace(/[\t ]+/g, ' ') .replace(/\n{3,}/g, '\n\n') .trim(); } async searchNovels( searchTerm: string, // pageNo: number, ): Promise { const searchUrl = `${this.site}bsearch?q=${encodeURIComponent(searchTerm)}`; let body = ''; try { const result = await fetchApi(searchUrl); if (!result.ok) { throw new Error( `Failed to fetch search results: HTTP ${result.status}`, ); } body = await result.text(); } catch (error) { if (error instanceof Error) { throw new Error(`Failed to fetch search results: ${error.message}`); } throw error; } const $ = parseHTML(body); const novels: Plugin.NovelItem[] = []; $('ul.u-list li.burl').each((_i, el) => { const $el = $(el); const novelPath = $el.attr('data-url')?.trim(); const novelName = $el.find('h3.bname a').text().trim(); const novelCover = $el.find('.l-img img').attr('src')?.trim(); if (novelPath && novelName) { novels.push({ name: novelName, path: novelPath, cover: makeAbsolute(novelCover, this.site) || defaultCover, }); } }); return novels; } } export default new ixdzs8Plugin(); type ChapterJSON = { rs: number; data?: { ctype: string; ordernum: string; title: string; }[]; };