Files
lnreader-plugins/plugins/chinese/ixdzs8.ts
T
2026-05-18 10:39:07 +05:00

314 lines
8.9 KiB
TypeScript

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<Plugin.NovelItem[]> {
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<string>();
$('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<Plugin.SourceNovel> {
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(/<br\s*\/?>/gi, '\n') // convert <br> to line breaks
.replace(/<[^>]+>/g, '') // remove remaining tags like <font>
.replace(/\u3000/g, ' ') // replace full-width spaces
.replace(/&nbsp;/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 <a> inside <em> and get their text
// Select <p> that contains <a.nsort>
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<Plugin.ChapterItem[]> {
// 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<string> {
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 <p>
$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 <font>
$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(`<div>${chapterText}</div>`).text();
return chapterText
.replace(/[\t ]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
async searchNovels(
searchTerm: string,
// pageNo: number,
): Promise<Plugin.NovelItem[]> {
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;
}[];
};