refactor: Overhaul Plugin Architecture & Playground UI (#1846)

* refactor: Move Plugins Out and Consolidate with Multisrc

* feat: Overhaul Playground UI

* feat: Add Filters to New UI

* feat: Persist Selected Plugin

* feat: Add Direct Parse Novel/Chapter Options and Feedback Toasts

[skip ci]

* feat: UX Improvements

* chore: Update Plugin Path in Publish Workflow

* fix: Add Backward Compatibility

* chore: Update Plugin Publishing Script
This commit is contained in:
Rajarshee Chatterjee
2025-10-20 22:54:08 +05:30
committed by GitHub
parent 252beff0bf
commit 20c2b75b01
363 changed files with 6848 additions and 3007 deletions
+297
View File
@@ -0,0 +1,297 @@
import { load as parseHTML } from 'cheerio';
import { fetchText } from '@/lib/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
import { NovelStatus } from '@libs/novelStatus';
class Shu69 implements Plugin.PluginBase {
private fetchOptions = {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0',
'Accept':
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,us;q=0.5',
'Referer': 'https://www.69shu.xyz/', // Referer
'DNT': '1', // Do Not Track
'Upgrade-Insecure-Requests': '1', // Upgrade-Insecure-Requests
},
};
id = '69shu';
name = '69书吧';
icon = 'src/cn/69shu/icon.png';
site = 'https://www.69shu.xyz';
version = '0.2.2';
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let url: string;
if (showLatestNovels) {
url = `${this.site}/rank/lastupdate/${pageNo}.html`;
} else if (filters.sort.value === 'none') {
url = `${this.site}/rank/${filters.rank.value}/${pageNo}.html`;
} else {
url = `${this.site}/sort/${filters.sort.value}/${pageNo}.html`;
}
const body = await fetchText(url, this.fetchOptions);
if (body === '') throw Error('无法获取小说列表,请检查网络');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('div.book-coverlist').each((i, el) => {
const url = loadedCheerio(el).find('a.cover').attr('href');
const novelName = loadedCheerio(el).find('h4.name').text().trim();
const novelCover = loadedCheerio(el).find('a.cover > img').attr('src');
if (!url) return;
const novel = {
name: novelName,
cover: novelCover,
path: url.replace(this.site, ''),
};
novels.push(novel);
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const body = await fetchText(url, this.fetchOptions);
if (body === '') throw Error('无法获取小说内容,请检查网络');
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
chapters: [],
name: loadedCheerio('h1').text().trim(),
};
novel.cover = loadedCheerio('div.cover > img').attr('src');
novel.summary = loadedCheerio('#bookIntro').text().trim();
const bookInfo = loadedCheerio('div.caption-bookinfo > p');
novel.author = bookInfo.find('a').attr('title');
novel.artist = undefined;
novel.status = bookInfo.text().includes('连载')
? NovelStatus.Ongoing
: NovelStatus.Completed;
novel.genres = '';
// Table of Content is on a different page than the summary page
const chapters: Plugin.ChapterItem[] = [];
const allUrl = loadedCheerio('dd.all > a').attr('href');
if (allUrl) {
// --- Start: Fetch chapters with pagination (Sequential) ---
let currentChaptersUrl = new URL(allUrl, this.site).toString();
let hasMorePages = true;
while (hasMorePages) {
const chaptersBody = await fetchText(
currentChaptersUrl,
this.fetchOptions,
);
const chaptersLoadedCheerio = parseHTML(chaptersBody);
// Extract chapters from the current page
chaptersLoadedCheerio('dl.panel-chapterlist dd').each((i, el) => {
const chapterUrl = chaptersLoadedCheerio(el).find('a').attr('href');
const chapterName = chaptersLoadedCheerio(el).find('a').text().trim();
if (chapterUrl) {
// Ensure relative path, handle both absolute/relative cases
const relativeChapterUrl = chapterUrl.startsWith('http')
? chapterUrl.replace(this.site, '')
: chapterUrl;
// Avoid duplicates if the same chapter appears on multiple pages (unlikely but safe)
if (!chapters.some(chap => chap.path === relativeChapterUrl)) {
chapters.push({
name: chapterName,
path: relativeChapterUrl,
});
}
}
});
// Find the link to the next page using the text "下一页"
const nextPageLinkElement = chaptersLoadedCheerio(
'div.listpage a.onclick',
).filter((i, el) =>
chaptersLoadedCheerio(el).text().includes('下一页'),
);
const nextPageLink = nextPageLinkElement.attr('href');
if (nextPageLink && nextPageLink !== 'javascript:void(0);') {
// Check if it's a valid relative or absolute URL before creating the URL object
try {
const absoluteNextPageUrl = new URL(
nextPageLink,
this.site,
).toString();
if (absoluteNextPageUrl === currentChaptersUrl) {
// Break if the next page URL is the same as the current one (prevents infinite loops)
hasMorePages = false;
} else {
currentChaptersUrl = absoluteNextPageUrl;
}
} catch (e) {
// Handle cases where the link might be invalid or unexpected
console.warn(`Invalid next page link found: ${nextPageLink}`);
hasMorePages = false;
}
} else {
hasMorePages = false;
}
}
// --- End: Fetch chapters with pagination (Sequential) ---
} else {
// Fallback if no "all chapters" link is found
loadedCheerio(
'div.panel.hidden-xs > dl.panel-chapterlist:nth-child(2) > dd',
).each((i, el) => {
const chapterUrl = loadedCheerio(el).find('a').attr('href');
const chapterName = loadedCheerio(el).find('a').text().trim();
if (chapterUrl) {
const relativeChapterUrl = chapterUrl.startsWith('http')
? chapterUrl.replace(this.site, '')
: chapterUrl;
chapters.push({
name: chapterName,
path: relativeChapterUrl,
});
}
});
}
// Remove duplicates just in case (though less likely with sequential fetching)
const uniqueChapters = chapters.filter(
(chapter, index, self) =>
index === self.findIndex(c => c.path === chapter.path),
);
novel.chapters = uniqueChapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const chapterUrl = new URL(chapterPath, this.site).toString();
const body = await fetchText(chapterUrl, this.fetchOptions); // Header hinzugefügt
const loadedCheerio = parseHTML(body);
const chapterText = loadedCheerio('#chaptercontent p')
.map((i, el) => loadedCheerio(el).text())
.get()
// remove empty lines and 69shu ads
.map((line: string) => line.trim())
.filter((line: string) => line !== '' && !line.includes('69书吧'))
.map((line: string) => `<p>${line}</p>`)
.join('\n');
return chapterText;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo > 1) return [];
const searchUrl = `${this.site}/search`;
const formData = new FormData();
formData.append('searchkey', searchTerm);
const searchOptions = {
...this.fetchOptions,
method: 'post',
body: formData,
headers: {
...this.fetchOptions.headers,
},
};
const body = await fetchText(searchUrl, searchOptions);
if (body === '') throw Error('无法获取搜索结果,请检查网络');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('div.book-coverlist').each((i, el) => {
const url = loadedCheerio(el).find('a.cover').attr('href');
const novelName = loadedCheerio(el).find('h4.name').text().trim();
const novelCover = loadedCheerio(el).find('a.cover > img').attr('src');
if (!url) return;
const novel = {
name: novelName,
cover: novelCover,
path: url.replace(this.site, ''),
};
novels.push(novel);
});
return novels;
}
filters = {
rank: {
label: '排行榜',
value: 'allvisit',
options: [
{ label: '总排行榜', value: 'allvisit' },
{ label: '月排行榜', value: 'monthvisit' },
{ label: '周排行榜', value: 'weekvisit' },
{ label: '日排行榜', value: 'dayvisit' },
{ label: '收藏榜', value: 'goodnum' },
{ label: '字数榜', value: 'words' },
{ label: '推荐榜', value: 'allvote' },
{ label: '新书榜', value: 'postdate' },
{ label: '更新榜', value: 'lastupdate' },
],
type: FilterTypes.Picker,
},
sort: {
label: '分类',
value: 'none',
options: [
{ label: '无', value: 'none' },
{ label: '全部', value: 'all' },
{ label: '玄幻', value: 'xuanhuan' },
{ label: '仙侠', value: 'xianxia' },
{ label: '都市', value: 'dushi' },
{ label: '历史', value: 'lishi' },
{ label: '游戏', value: 'youxi' },
{ label: '科幻', value: 'kehuan' },
{ label: '灵异', value: 'kongbu' },
{ label: '言情', value: 'nvsheng' },
{ label: '其它', value: 'qita' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new Shu69();
+201
View File
@@ -0,0 +1,201 @@
import { load as parseHTML } from 'cheerio';
import { fetchText } from '@/lib/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
import { encode } from 'urlencode';
import { NovelStatus } from '@libs/novelStatus';
class XinShu69 implements Plugin.PluginBase {
id = '69xinshu';
name = '69书吧';
icon = 'src/cn/69xinshu/icon.png';
site = 'https://69shu.biz';
version = '0.1.2';
async popularNovels(
pageNo: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let url: string;
if (pageNo === 1) {
url = `${this.site}/novels/class/${filters.class.value}.htm`;
} else {
url = `${this.site}/ajax_novels/class/${filters.class.value}/${pageNo}.htm`;
}
const body = await fetchText(url, {}, 'gbk');
if (body === '') throw Error('无法获取小说列表,请检查网络');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
const novelsList =
pageNo === 1
? loadedCheerio('.newlistbox > ul > li')
: loadedCheerio('li');
novelsList.each((i, e) => {
const novelUrl = loadedCheerio(e).find('div > h3 > a').attr('href');
if (novelUrl) {
const novelName = loadedCheerio(e).find('div > h3 > a').text();
const novelCover = loadedCheerio(e).find('a > img').attr('data-src');
const novel = {
name: novelName,
cover: novelCover,
path: novelUrl.replace(this.site, ''),
};
novels.push(novel);
}
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const body = await fetchText(url, {}, 'gbk');
if (body === '') throw Error('无法获取小说内容,请检查网络');
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
chapters: [],
name: loadedCheerio('h1 > a').text(),
};
novel.cover = loadedCheerio('div.bookimg2 > img').attr('src');
novel.summary = loadedCheerio('div.navtxt').text().trim();
const bookInfo = loadedCheerio('div.booknav2').text();
const authorMatch = bookInfo.match(/作者:(.*)/);
if (authorMatch) {
novel.author = authorMatch[1];
}
novel.artist = undefined;
novel.status = bookInfo.includes('连载')
? NovelStatus.Ongoing
: NovelStatus.Completed;
const genresMatch = bookInfo.match(/分类:(.*)/);
if (genresMatch) {
novel.genres = genresMatch[1];
}
// Table of Content is on a different page than the summary page
const chapters: Plugin.ChapterItem[] = [];
const chaptersUrl = loadedCheerio('a.more-btn').attr('href');
if (chaptersUrl) {
const chaptersBody = await fetchText(chaptersUrl, {}, 'gbk');
const chaptersLoadedCheerio = parseHTML(chaptersBody);
chaptersLoadedCheerio('li').each((i, e) => {
const chapterUrl = chaptersLoadedCheerio(e).find('a').attr('href');
if (chapterUrl?.startsWith('https://')) {
const chapterName = chaptersLoadedCheerio(e).find('a').text().trim();
chapters.push({
name: chapterName,
path: chapterUrl.replace(this.site, ''),
});
}
});
}
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const body = await fetchText(this.site + chapterPath, {}, 'gbk');
const loadedCheerio = parseHTML(body);
const chapterText = (loadedCheerio('div.txtnav').prop('innerText') ?? '')
.split('\n')
// remove empty lines
.map((line: string) => line.trim())
.filter((line: string) => line !== '')
// remove the first two lines which are the chapter name and author name
.slice(2)
.map((line: string) => `<p>${line}</p>`)
.join('\n');
return chapterText;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
if (pageNo > 1) return [];
const searchUrl = `${this.site}/modules/article/search.php`;
const formData = new FormData();
formData.append('searchkey', encode(searchTerm, 'gbk'));
formData.append('searchtype', 'all');
const body = await fetchText(
searchUrl,
{ method: 'post', body: formData },
'gbk',
);
if (body === '') throw Error('无法获取搜索结果,请检查网络');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.newbox > ul > li').each((i, e) => {
const novelUrl = loadedCheerio(e).find('a').attr('href')!;
const novelName = loadedCheerio(e).find('div.newnav > h3').text();
const novelCover = loadedCheerio(e).find('img').attr('data-src');
const novel = {
name: novelName,
path: novelUrl.replace(this.site, ''),
cover: novelCover,
};
novels.push(novel);
});
return novels;
}
filters = {
class: {
label: '分类',
value: '0',
options: [
{ label: '全部分类', value: '0' },
{ label: '言情小说', value: '3' },
{ label: '玄幻魔法', value: '1' },
{ label: '修真武侠', value: '2' },
{ label: '穿越时空', value: '11' },
{ label: '都市小说', value: '9' },
{ label: '历史军事', value: '4' },
{ label: '游戏竞技', value: '5' },
{ label: '科幻空间', value: '6' },
{ label: '悬疑惊悚', value: '7' },
{ label: '同人小说', value: '8' },
{ label: '官场职场', value: '10' },
{ label: '青春校园', value: '12' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new XinShu69();
+415
View File
@@ -0,0 +1,415 @@
import { Plugin } from '@/types/plugin';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { fetchApi, fetchFile } from '@/lib/fetch';
import { NovelStatus } from '@libs/novelStatus';
import { load as parseHTML } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
const getStandardNovelPath = (url: string | undefined): string | undefined => {
if (!url) return undefined;
try {
const parsedUrl = new URL(url);
const match = parsedUrl.pathname.match(/^(\/amp)?(\/n\/[^\/]+\/)/);
return match?.[2];
} catch (error) {
return undefined;
}
};
const getChapterFileName = (url: string | undefined): string | undefined => {
if (!url) return undefined;
try {
const parsedUrl = new URL(url);
const pathParts = parsedUrl.pathname.split('/');
const fileName = pathParts[pathParts.length - 1];
if (fileName && /^\d+\.html$/.test(fileName)) {
return fileName;
}
return undefined;
} catch (error) {
return undefined;
}
};
const makeAbsolute = (
relativeUrl: string | undefined,
baseUrl: string,
): string | undefined => {
if (!relativeUrl) return undefined;
try {
if (relativeUrl.startsWith('//')) {
return 'https:' + relativeUrl;
}
if (
relativeUrl.startsWith('http://') ||
relativeUrl.startsWith('https://')
) {
return relativeUrl;
}
return new URL(relativeUrl, baseUrl).href;
} catch (e) {
return undefined;
}
};
class QuanbenPlugin implements Plugin.PluginBase {
id = 'quanben';
name = 'Quanben';
site = 'https://www.quanben.io/';
version = '1.0.0';
icon = 'src/cn/quanben/icon.png';
defaultCover = defaultCover;
filters = {} satisfies Filters;
async popularNovels(_pageNo: number): Promise<Plugin.NovelItem[]> {
const url = this.site + 'amp/';
const result = await fetchApi(url);
if (!result.ok) {
throw new Error(
`[Quanben] Failed to fetch AMP popular novels page: ${url} - Status: ${result.status}`,
);
}
const body = await result.text();
const $ = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
const processedAmpPaths = new Set<string>();
$('div.box').each((_i, box) => {
const $box = $(box);
// 1. Process the featured novel (div.list2) if it exists
const $featured = $box.find('div.list2');
if ($featured.length > 0) {
const $link = $featured.find('h3 > a');
const ampPath = $link.attr('href')?.trim();
const name = $link.text().trim();
const rawCoverSrc = $featured.find('amp-img').attr('src')?.trim();
const cover = makeAbsolute(rawCoverSrc, this.site) || this.defaultCover;
if (ampPath && name && !processedAmpPaths.has(ampPath)) {
novels.push({ name, path: ampPath, cover });
processedAmpPaths.add(ampPath);
}
}
// 2. Process novels in the list (ul.list)
$box.find('ul.list li').each((_j, listItem) => {
const $listItem = $(listItem);
const $link = $listItem.find('a');
const ampPath = $link.attr('href')?.trim();
const name = $link.find('span').first().text().trim(); // Name is inside a span within the link
if (ampPath && name && !processedAmpPaths.has(ampPath)) {
novels.push({ name, path: ampPath, cover: this.defaultCover }); // Use default cover for list items
processedAmpPaths.add(ampPath);
}
});
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
// ** Expects AMP path: /amp/n/novel-name/ **
if (
!novelPath ||
!novelPath.startsWith('/amp/n/') ||
!novelPath.endsWith('/')
) {
throw new Error(
`[Quanben parseNovel] Invalid novelPath received: "${novelPath}". Expected AMP format: "/amp/n/novel-name/"`,
);
}
// Construct full AMP novel URL
const fullNovelUrl = makeAbsolute(novelPath, this.site);
if (!fullNovelUrl) {
throw new Error(
`[Quanben parseNovel] Could not construct full AMP novel URL from path: ${novelPath}`,
);
}
// 1. Fetch AMP Novel Page HTML
const novelPageResult = await fetchApi(fullNovelUrl);
if (!novelPageResult.ok) {
throw new Error(
`[Quanben parseNovel] Failed to fetch AMP novel page: ${fullNovelUrl} - Status: ${novelPageResult.status}`,
);
}
const novelPageHtml = await novelPageResult.text();
const $ = parseHTML(novelPageHtml);
// 2. Parse Novel Details from AMP page - **Refined Selectors**
const $infoBox = $('div.list2'); // Base element for many details
const $descriptionBox = $('div.description'); // Separate element for summary
const novel: Plugin.SourceNovel = {
path: novelPath, // Use the input AMP path
name:
$infoBox.find('h3').text().trim() || // **Use h3 inside list2**
$('h1[itemprop="name headline"]').text().trim() || // Fallback H1
'Unknown Novel Name',
cover:
makeAbsolute($infoBox.find('amp-img').attr('src'), this.site) || // **amp-img inside list2**
this.defaultCover,
summary:
$descriptionBox.find('p').text().trim() || // **Use p inside div.description**
$descriptionBox.text().trim() || // Fallback to div.description text
undefined,
// Use :contains() for more robust selection within the info box
author:
$infoBox.find("p:contains('作者:') span").text().trim() || undefined,
status: NovelStatus.Unknown, // Parsed below
genres:
$infoBox.find("p:contains('类别:') span").text().trim() || undefined,
chapters: [], // Parsed below
};
// Parse Status from AMP page
const statusText =
$infoBox.find("p:contains('状态:') span").text().trim() || // Try specific span first
$infoBox.text(); // Fallback to full text
if (statusText.includes('完结') || statusText.includes('已完成')) {
novel.status = NovelStatus.Completed;
} else if (statusText.includes('连载中') || statusText.includes('进行中')) {
novel.status = NovelStatus.Ongoing;
}
// 3. Fetch and Parse Chapter List
novel.chapters = await this.parseChapterList(novelPath);
return novel;
}
// Separate function to handle chapter list parsing - **Using AMP**
async parseChapterList(novelPath: string): Promise<Plugin.ChapterItem[]> {
// ** Expects AMP path: /amp/n/novel-name/ **
if (
!novelPath ||
!novelPath.startsWith('/amp/n/') ||
!novelPath.endsWith('/')
) {
return [];
}
// Construct the AMP chapter list URL
const ampChapterListUrl = makeAbsolute(novelPath + 'list.html', this.site);
if (!ampChapterListUrl) {
return [];
}
const chapterListResult = await fetchApi(ampChapterListUrl);
if (!chapterListResult.ok) {
return [];
}
const chapterListHtml = await chapterListResult.text();
const $ = parseHTML(chapterListHtml);
const chapters: Plugin.ChapterItem[] = [];
// Extract standard novel name from AMP path for chapter path storage
const standardNovelPathMatch = novelPath.match(/(\/n\/[^\/]+\/)/);
if (!standardNovelPathMatch || !standardNovelPathMatch[1]) {
return [];
}
const novelNameOnly = standardNovelPathMatch[1].replace(/^\/n\/|\/$/g, '');
// 1. Parse chapters from ALL list3 uls in the AMP HTML
$('ul.list3 li a').each((_i, el) => {
const $el = $(el);
const chapterName = $el.text().trim();
const chapterHref = $el.attr('href');
if (chapterName && chapterHref) {
// URLs in AMP page usually point to the standard chapter URLs
const absoluteUrl = makeAbsolute(chapterHref, this.site); // Use base site URL
const chapterFileName = getChapterFileName(absoluteUrl);
if (chapterFileName) {
// ** Store chapter path in standard format for parseChapter **
const chapterPathForStorage = novelNameOnly + '/' + chapterFileName;
chapters.push({
name: chapterName,
path: chapterPathForStorage,
// chapterNumber assigned later
});
}
}
});
// 2. Deduplicate chapters based on path
const chapterMap = new Map<string, Plugin.ChapterItem>();
chapters.forEach(chapter => {
if (!chapterMap.has(chapter.path)) {
chapterMap.set(chapter.path, chapter);
}
});
const uniqueChapters = Array.from(chapterMap.values());
// 3. Sort chapters numerically based on filename
uniqueChapters.sort((a, b) => {
const numA = parseInt(a.path.match(/(\d+)\.html$/)?.[1] || '0', 10);
const numB = parseInt(b.path.match(/(\d+)\.html$/)?.[1] || '0', 10);
return numA - numB;
});
// 4. Assign chapter numbers
return uniqueChapters.map((chapter, index) => ({
...chapter,
chapterNumber: index + 1,
}));
}
async parseChapter(chapterPath: string): Promise<string> {
// ** Expects standard path: novel-name/chapterFileName.html **
if (
!chapterPath ||
!chapterPath.includes('/') ||
chapterPath.endsWith('/')
) {
throw new Error(
`[Quanben] Invalid chapterPath format received in parseChapter: "${chapterPath}". Expected format: "novel-name/chapterFileName.html"`,
);
}
// Construct the standard chapter URL using /n/ prefix
const chapterUrl = `${this.site}n/${chapterPath}`;
const result = await fetchApi(chapterUrl);
if (!result.ok) {
// Handle potential redirects (e.g., chapter moved) - check Location header if status is 3xx
if (result.status >= 300 && result.status < 400) {
const redirectUrl = result.headers.get('Location');
if (redirectUrl) {
const absoluteRedirectUrl = makeAbsolute(redirectUrl, chapterUrl);
if (!absoluteRedirectUrl) {
throw new Error(
`[Quanben] Failed to make redirected URL absolute: ${redirectUrl}`,
);
}
const redirectResult = await fetchApi(absoluteRedirectUrl);
if (!redirectResult.ok) {
throw new Error(
`[Quanben] Failed to fetch redirected chapter content: ${absoluteRedirectUrl} - Status: ${redirectResult.status}`,
);
}
return this.extractChapterContent(
await redirectResult.text(),
absoluteRedirectUrl,
);
}
}
throw new Error(
`[Quanben] Failed to fetch chapter content: ${chapterUrl} - Status: ${result.status}`,
);
}
const body = await result.text();
return this.extractChapterContent(body, chapterUrl);
}
// Helper function to extract and clean chapter content from HTML body
private extractChapterContent(body: string, urlForLog: string): string {
const $ = parseHTML(body);
let $content = $('#contentbody');
if (!$content.length) {
$content = $('#content');
}
if (!$content.length) {
$content = $('.content');
}
if (!$content.length) {
return 'Error: Could not find chapter content container.';
}
$content
.find(
'script, style, ins, iframe, [class*="ads"], [id*="ads"], [class*="google"], [id*="google"], [class*="recommend"], div[align="center"]',
)
.remove();
$content.find('p').each((_i, el) => {
const $p = $(el);
const pText = $p.text().trim();
if (
pText.includes('请记住本书首发域名') ||
pText.includes('手机版阅读网址') ||
pText.includes('quanben') ||
pText.includes('最新网址') ||
pText.includes('章节报错') ||
pText.match(/app|APP|下载|客户端/) ||
pText.length === 0 ||
($p
.html()
?.replace(/&nbsp;/g, '')
.trim() === '' &&
$p.find('img').length === 0)
) {
$p.remove();
}
});
$content
.contents()
.filter(function () {
return this.type === 'comment';
})
.remove();
let chapterText = $content.html();
if (!chapterText) {
return 'Error: Chapter content was empty after cleaning.';
}
chapterText = chapterText.replace(/<\s*p[^>]*>/gi, '\n\n');
chapterText = chapterText.replace(/<\s*br[^>]*>/gi, '\n');
chapterText = chapterText.replace(/<[^>]+>/g, '');
chapterText = parseHTML(`<div>${chapterText}</div>`).text();
chapterText = chapterText.replace(/[\t ]+/g, ' ');
chapterText = chapterText.replace(/\n{3,}/g, '\n\n');
chapterText = chapterText.trim();
return chapterText;
}
async searchNovels(
searchTerm: string,
_pageNo: number,
): Promise<Plugin.NovelItem[]> {
const searchUrl = `${this.site}index.php?c=book&a=search&keywords=${encodeURIComponent(searchTerm)}`;
const result = await fetchApi(searchUrl);
if (!result.ok) {
return [];
}
const body = await result.text();
const $ = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
$('div.list2').each((_i, element) => {
const $el = $(element);
const nameLink = $el.find('h3 > a').first();
const img = $el.find('img').first();
const novelName = nameLink.text().trim();
const novelHref = nameLink.attr('href');
const novelCover = img.attr('src') || img.attr('data-src');
if (novelHref && novelName) {
const absoluteUrl = makeAbsolute(novelHref, this.site);
const standardPath = getStandardNovelPath(absoluteUrl);
if (standardPath) {
// **Construct AMP Path for storage**
const ampPath = '/amp' + standardPath;
const absoluteCover = makeAbsolute(novelCover, this.site);
novels.push({
name: novelName,
path: ampPath, // **Store the AMP path**
cover: absoluteCover || this.defaultCover,
});
}
}
});
return novels;
}
// Use fetchApi for fetchImage as it handles potential errors and returns Response
async fetchImage(url: string): Promise<Response> {
return fetchApi(url);
}
}
export default new QuanbenPlugin();
+300
View File
@@ -0,0 +1,300 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi, fetchFile } from '@/lib/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.8';
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);
let novelPath: string | undefined;
let novelName: string | undefined;
let novelCover: string | undefined;
const $link = $el.find('.l-info h3 a');
novelPath = $link.attr('href')?.trim();
novelName = ($link.attr('title') || $link.text() || '').trim();
novelCover = $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 = 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: any) => {
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();
+166
View File
@@ -0,0 +1,166 @@
import { fetchText } from '@/lib/fetch';
import { Plugin } from '@/types/plugin';
import { Filters } from '@libs/filterInputs';
import { load as parseHTML } from 'cheerio';
import { NovelStatus } from '@libs/novelStatus';
class LinovelPlugin implements Plugin.PluginBase {
id = 'linovel';
name = 'linovel';
icon = 'src/cn/linovel/icon.png';
site = 'https://www.linovel.net';
version = '1.0.0';
filters: Filters | undefined = undefined;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//flag indicates whether access to LocalStorage, SesesionStorage is required.
webStorageUtilized?: boolean;
private userAgent =
'Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0';
private async fetchHTML(url: string) {
const body = await fetchText(url, {
headers: { 'User-Agent': this.userAgent },
});
if (body === '') throw Error('无法获取内容,请检查网络');
return parseHTML(body);
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
const loadedCheerio = await this.fetchHTML(this.site);
loadedCheerio('a.book-item-inner').each((i, elem) => {
const name = loadedCheerio(elem).find('.book-item-name').text().trim();
const path = loadedCheerio(elem).attr('href') ?? '';
const cover = (
loadedCheerio(elem).find('img').attr('data-original') ?? ''
).replace(/!min300jpg$/, '');
novels.push({ name, path, cover });
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novel: Plugin.SourceNovel = {
path: novelPath,
name: 'Untitled',
};
const loadedCheerio = await this.fetchHTML(this.site + novelPath);
loadedCheerio('div.name')
.find('a')
.each((_i, elem) => {
novel.author = loadedCheerio(elem).text().trim();
});
loadedCheerio('.book-title').each((_i, elem) => {
novel.name = loadedCheerio(elem).text().trim();
});
loadedCheerio('.book-data')
.find('span')
.each((_i, elem) => {
const text = loadedCheerio(elem).text().trim();
if (text.includes('字数')) {
// Handle word count
} else if (text.includes('热度')) {
// Handle popularity
} else if (text.includes('收藏')) {
// Handle favorites
} else if (text === '连载中') {
novel.status = NovelStatus.Ongoing;
} else if (text === '已完结') {
novel.status = NovelStatus.Completed;
}
});
loadedCheerio('.book-cover')
.find('img')
.each((_i, elem) => {
novel.cover = loadedCheerio(elem).attr('src') ?? '';
});
novel.genres = loadedCheerio('.book-cats')
.children('a')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(',');
novel.summary = loadedCheerio('.about-text').text().trim();
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio('div.chapter')
.find('a')
.each((i, elem) => {
const name = loadedCheerio(elem).text().trim();
const path = loadedCheerio(elem).attr('href') ?? '';
chapters.push({ name, path });
});
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const loadedCheerio = await this.fetchHTML(this.site + chapterPath);
let chapterText = '';
if (loadedCheerio('.fufei-app-download-hint').length) {
throw Error('本章节需订阅后才能阅览,请下载轻之文库App订阅');
}
loadedCheerio('.article-text')
.find('p, img')
.each((i, elem) => {
if (elem.tagName === 'img') {
const imgSrc = loadedCheerio(elem).attr('src');
chapterText += `<img src="${imgSrc}">\n`;
} else {
const text = loadedCheerio(elem).text().trim();
if (text === '&nbsp;') {
chapterText += '<br>\n';
} else {
chapterText += `<p>${text}</p>\n`;
}
}
});
return chapterText.trim();
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const loadedCheerio = await this.fetchHTML(
this.site + '/search/?kw=' + searchTerm,
);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.rank-book-list')
.find('a.search-book')
.each((i, elem) => {
const name = loadedCheerio(elem).find('.book-name').text().trim();
const path = loadedCheerio(elem).attr('href') ?? '';
const cover = (
loadedCheerio(elem).find('img').attr('src') ?? ''
).replace(/!min300jpg$/, '');
novels.push({ name, path, cover });
});
return novels;
}
}
export default new LinovelPlugin();
+548
View File
@@ -0,0 +1,548 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchText } from '@/lib/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
import { NovelStatus } from '@libs/novelStatus';
class Linovelib implements Plugin.PluginBase {
id = 'linovelib';
name = 'Linovelib';
icon = 'src/cn/linovelib/icon.png';
site = 'https://www.bilinovel.com';
version = '1.1.3';
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const rank = showLatestNovels ? 'lastupdate' : filters.rank.value;
const url = `${this.site}/top/${rank}/${pageNo}.html`;
const body = await fetchText(url);
if (body === '') throw Error('无法获取小说列表,请检查网络');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.module-rank-booklist .book-layout').each((i, el) => {
const url = loadedCheerio(el).attr('href');
const novelName = loadedCheerio(el).find('.book-title').text();
const novelCover = loadedCheerio(el)
.find('div.book-cover > img')
.attr('data-src');
if (!url) return;
const novel = {
name: novelName,
cover: novelCover,
path: url,
};
novels.push(novel);
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const body = await fetchText(url);
if (body === '') throw Error('无法获取小说内容,请检查网络');
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
chapters: [],
name: loadedCheerio('#bookDetailWrapper .book-title').text(),
};
novel.cover = loadedCheerio('#bookDetailWrapper img.book-cover').attr(
'src',
);
novel.summary = loadedCheerio('#bookSummary content').text();
novel.author = loadedCheerio('#bookDetailWrapper .book-rand-a a').text();
const meta = loadedCheerio('#bookDetailWrapper .book-meta').text();
novel.status = meta.includes('完结')
? NovelStatus.Completed
: NovelStatus.Ongoing;
novel.genres = loadedCheerio('.tag-small.red')
.children('a')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(',');
// Table of Content is on a different page than the summary page
const chapter: Plugin.ChapterItem[] = [];
const idPattern = /\/(\d+)\.html/;
const novelId = url.match(idPattern)?.[1];
const chaptersUrl = this.site + loadedCheerio('#btnReadBook').attr('href');
const chaptersBody = await fetchText(chaptersUrl);
const chaptersLoadedCheerio = parseHTML(chaptersBody);
let volumeName: string, chapterId: number;
chaptersLoadedCheerio('#volumes .chapter-li:not(.volume-cover)').each(
(i, el) => {
if (chaptersLoadedCheerio(el).hasClass('chapter-bar')) {
volumeName = chaptersLoadedCheerio(el).text();
return;
} else {
const urlPart = chaptersLoadedCheerio(el)
.find('.chapter-li-a')
.attr('href');
const chapterIdMatch = urlPart?.match(idPattern);
// Sometimes the href attribute does not contain the url, but javascript:cid(0).
// Increment the previous chapter ID should result in the right URL
if (chapterIdMatch) {
chapterId = +chapterIdMatch[1];
} else {
chapterId++;
}
}
const chapterUrl = `/novel/${novelId}/${chapterId}.html`;
const chapterName =
volumeName +
' — ' +
chaptersLoadedCheerio(el).find('.chapter-index').text().trim();
const releaseDate = null;
if (!chapterId) return;
chapter.push({
name: chapterName,
releaseTime: releaseDate,
path: chapterUrl,
});
},
);
novel.chapters = chapter;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
let chapterName,
chapterText = '',
hasNextPage,
pageHasNextPage,
pageText = '';
let pageNumber = 1;
/*
* TODO: Maybe there are other ways to get the translation table
* It is embed and encrypted inside readtool.js
* UPDATE: Decrypted, see skillgg
*/
// const mapping_dict = {
// '“': '「',
// '’': '』',
// '': '是',
// '': '不',
// '': '好',
// '': '个',
// '': '开',
// '': '样',
// '': '想',
// '': '说',
// '': '年',
// '': '那',
// '': '她',
// '': '美',
// '': '自',
// '': '家',
// '': '而',
// '': '去',
// '': '都',
// '': '于',
// '': '舔',
// '': '他',
// '': '只',
// '': '看',
// '': '来',
// '': '用',
// '': '道',
// '': '得',
// '': '乳',
// '': '茎',
// '': '肉',
// '': '胸',
// '': '淫',
// '': '性',
// '': '骚',
// '”': '」',
// '': '的',
// '': '当',
// '': '人',
// '': '有',
// '': '上',
// '': '到',
// '': '地',
// '': '中',
// '': '生',
// '': '着',
// '': '和',
// '': '起',
// '': '交',
// '': '以',
// '': '可',
// '': '过',
// '': '能',
// '': '多',
// '': '心',
// '': '小',
// '': '成',
// '': '了',
// '': '把',
// '': '发',
// '': '第',
// '': '子',
// '': '事',
// '': '阴',
// '': '欲',
// '': '里',
// '': '私',
// '': '臀',
// '': '脱',
// '': '唇',
// '‘': '『',
// '': '一',
// '': '我',
// '': '在',
// '': '这',
// '': '们',
// '': '时',
// '': '为',
// '': '你',
// '': '国',
// '': '就',
// '': '要',
// '': '也',
// '': '后',
// '': '没',
// '': '下',
// '': '天',
// '': '对',
// '': '然',
// '': '学',
// '': '之',
// '': '出',
// '': '没',
// '': '如',
// '': '还',
// '': '大',
// '': '作',
// '': '种',
// '': '液',
// '': '呻',
// '': '射',
// '': '穴',
// '': '么',
// '': '裸',
// };
const skillgg: Record<string, string> = {
'\u201c': '\u300c',
'\u201d': '\u300d',
'\u2018': '\u300e',
'\u2019': '\u300f',
'\ue82c': '\u7684',
'\ue852': '\u4e00',
'\ue82d': '\u662f',
'\ue819': '\u4e86',
'\ue856': '\u6211',
'\ue857': '\u4e0d',
'\ue816': '\u4eba',
'\ue83c': '\u5728',
'\ue830': '\u4ed6',
'\ue82e': '\u6709',
'\ue836': '\u8fd9',
'\ue859': '\u4e2a',
'\ue80a': '\u4e0a',
'\ue855': '\u4eec',
'\ue842': '\u6765',
'\ue858': '\u5230',
'\ue80b': '\u65f6',
'\ue81f': '\u5927',
'\ue84a': '\u5730',
'\ue853': '\u4e3a',
'\ue81e': '\u5b50',
'\ue822': '\u4e2d',
'\ue813': '\u4f60',
'\ue85b': '\u8bf4',
'\ue807': '\u751f',
'\ue818': '\u56fd',
'\ue810': '\u5e74',
'\ue812': '\u7740',
'\ue851': '\u5c31',
'\ue801': '\u90a3',
'\ue80c': '\u548c',
'\ue815': '\u8981',
'\ue84c': '\u5979',
'\ue840': '\u51fa',
'\ue848': '\u4e5f',
'\ue835': '\u5f97',
'\ue800': '\u91cc',
'\ue826': '\u540e',
'\ue863': '\u81ea',
'\ue861': '\u4ee5',
'\ue854': '\u4f1a',
'\ue827': '\u5bb6',
'\ue83b': '\u53ef',
'\ue85d': '\u4e0b',
'\ue84d': '\u800c',
'\ue862': '\u8fc7',
'\ue81c': '\u5929',
'\ue81d': '\u53bb',
'\ue860': '\u80fd',
'\ue843': '\u5bf9',
'\ue82f': '\u5c0f',
'\ue802': '\u591a',
'\ue831': '\u7136',
'\ue84b': '\u4e8e',
'\ue837': '\u5fc3',
'\ue829': '\u5b66',
'\ue85e': '\u4e48',
'\ue83a': '\u4e4b',
'\ue832': '\u90fd',
'\ue808': '\u597d',
'\ue841': '\u770b',
'\ue821': '\u8d77',
'\ue845': '\u53d1',
'\ue803': '\u5f53',
'\ue828': '\u6ca1',
'\ue81b': '\u6210',
'\ue83e': '\u53ea',
'\ue820': '\u5982',
'\ue84e': '\u4e8b',
'\ue85a': '\u628a',
'\ue806': '\u8fd8',
'\ue83f': '\u7528',
'\ue833': '\u7b2c',
'\ue811': '\u6837',
'\ue804': '\u9053',
'\ue814': '\u60f3',
'\ue80f': '\u4f5c',
'\ue84f': '\u79cd',
'\ue80e': '\u5f00',
'\ue823': '\u7f8e',
'\ue849': '\u4e73',
'\ue805': '\u9634',
'\ue809': '\u6db2',
'\ue81a': '\u830e',
'\ue844': '\u6b32',
'\ue847': '\u547b',
'\ue850': '\u8089',
'\ue824': '\u4ea4',
'\ue85f': '\u6027',
'\ue817': '\u80f8',
'\ue85c': '\u79c1',
'\ue838': '\u7a74',
'\ue82a': '\u6deb',
'\ue83d': '\u81c0',
'\ue82b': '\u8214',
'\ue80d': '\u5c04',
'\ue839': '\u8131',
'\ue834': '\u88f8',
'\ue846': '\u9a9a',
'\ue825': '\u5507',
};
const addPage = async (pageCheerio: CheerioAPI) => {
const formatPage = async () => {
// Remove JS and notice of the website
pageCheerio(
'#acontent .adsbygoogle, #acontent script, center',
).remove();
// Load lazyloaded images
pageCheerio('#acontent img.imagecontent').each((i, el) => {
// Sometimes images are either in data-src or src
const imgSrc =
pageCheerio(el).attr('data-src') || pageCheerio(el).attr('src');
if (imgSrc) {
// Clean up img element
pageCheerio(el)
.attr('src', imgSrc)
.removeAttr('data-src')
.removeClass('lazyload');
}
});
// Recover the original character
pageText = pageCheerio('#acontent').html() || '';
pageText = pageText.replace(/./g, char => skillgg[char] || char);
return Promise.resolve();
};
await formatPage();
chapterName =
pageCheerio('#atitle + h3').text() +
' — ' +
pageCheerio('#atitle').text();
if (chapterText === '') {
chapterText = '<h2>' + chapterName + '</h2>';
}
chapterText += pageText;
};
const loadPage = async (url: string) => {
const headers = {
'Accept':
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language':
'zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en;q=0.6,en-GB;q=0.5,en-US;q=0.4',
'Cache-Control': 'no-cache',
};
const body = await fetchText(url, { headers });
const pageCheerio = parseHTML(body);
await addPage(pageCheerio);
pageHasNextPage =
pageCheerio('#footlink a:last').text() === '下一页' ||
pageCheerio('#footlink a:last').text() === '下一頁'
? true
: false;
return { pageCheerio, pageHasNextPage };
};
let url = this.site + chapterPath;
const baseUrl = url;
do {
const page = await loadPage(url);
hasNextPage = page.pageHasNextPage;
if (hasNextPage === true) {
pageNumber++;
url = baseUrl.replace(/\.html/gi, `_${pageNumber}` + '.html');
}
} while (hasNextPage === true);
return chapterText;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}/search/${encodeURI(searchTerm)}_${pageNo}.html`;
const body = await fetchText(url);
if (body === '') throw Error('无法获取搜索结果,请检查网络');
const pageCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
// const addPage = async (pageCheerio: CheerioAPI, redirect: string) => {
// const loadSearchResults = () => {
// pageCheerio(".book-ol .book-layout").each((i, el) => {
// let nUrl = pageCheerio(el).attr("href");
// const novelName = pageCheerio(el)
// .find(".book-title")
// .text();
// const novelCover = pageCheerio(el)
// .find("div.book-cover > img")
// .attr("data-src");
// const novelUrl = this.site + nUrl;
// if (!nUrl) return;
// novels.push({
// name: novelName,
// url: novelUrl,
// cover: novelCover,
// });
// });
// };
// const novelResults = pageCheerio(".book-ol a.book-layout");
// if (novelResults.length === 0) {
// } else {
// loadSearchResults();
// }
// if (redirect.length) {
// novels.length = 0;
// const novelName = pageCheerio(
// "#bookDetailWrapper .book-title"
// ).text();
// const novelCover = pageCheerio(
// "#bookDetailWrapper div.book-cover > img"
// ).attr("src");
// const novelUrl =
// this.site +
// pageCheerio("#btnReadBook").attr("href")?.slice(0, -8) +
// ".html";
// novels.push({
// name: novelName,
// url: novelUrl,
// cover: novelCover,
// });
// }
// };
// NOTE: don't know redirect is for what, comment out for now
// const redirect = pageCheerio("div.book-layout").text();
// await addPage(pageCheerio, redirect);
pageCheerio('.book-ol .book-layout').each((i, el) => {
const nUrl = pageCheerio(el).attr('href');
const novelName = pageCheerio(el).find('.book-title').text();
const novelCover = pageCheerio(el)
.find('div.book-cover > img')
.attr('data-src');
if (!nUrl) return;
novels.push({
name: novelName,
path: nUrl,
cover: novelCover,
});
});
return novels;
}
filters = {
rank: {
label: '排行榜',
value: 'monthvisit',
options: [
{ label: '月点击榜', value: 'monthvisit' },
{ label: '周点击榜', value: 'weekvisit' },
{ label: '月推荐榜', value: 'monthvote' },
{ label: '周推荐榜', value: 'weekvote' },
{ label: '月鲜花榜', value: 'monthflower' },
{ label: '周鲜花榜', value: 'weekflower' },
{ label: '月鸡蛋榜', value: 'monthegg' },
{ label: '周鸡蛋榜', value: 'weekegg' },
{ label: '最近更新', value: 'lastupdate' },
{ label: '最新入库', value: 'postdate' },
{ label: '收藏榜', value: 'goodnum' },
{ label: '新书榜', value: 'newhot' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new Linovelib();
+546
View File
@@ -0,0 +1,546 @@
import { CheerioAPI, load as parseHTML } from 'cheerio';
import { fetchText } from '@/lib/fetch';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { Plugin } from '@/types/plugin';
import { NovelStatus } from '@libs/novelStatus';
class Linovelib_tw implements Plugin.PluginBase {
id = 'linovelib_tw';
name = 'Linovelib(繁體)';
icon = 'src/cn/linovelib/icon.png';
site = 'https://tw.linovelib.com/';
version = '1.0.0';
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const rank = showLatestNovels ? 'lastupdate' : filters.rank.value;
const url = `${this.site}/top/${rank}/${pageNo}.html`;
const body = await fetchText(url);
if (body === '') throw Error('無法獲取小説列表, 請檢查網絡');
const loadedCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.module-rank-booklist .book-layout').each((i, el) => {
const url = loadedCheerio(el).attr('href');
const novelName = loadedCheerio(el).find('.book-title').text();
const novelCover = loadedCheerio(el)
.find('div.book-cover > img')
.attr('data-src');
if (!url) return;
const novel = {
name: novelName,
cover: novelCover,
path: url,
};
novels.push(novel);
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = this.site + novelPath;
const body = await fetchText(url);
if (body === '') throw Error('無法獲取小説信息, 請檢查網絡');
const loadedCheerio = parseHTML(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
chapters: [],
name: loadedCheerio('#bookDetailWrapper .book-title').text(),
};
novel.cover = loadedCheerio('#bookDetailWrapper img.book-cover').attr(
'src',
);
novel.summary = loadedCheerio('#bookSummary content').text();
novel.author = loadedCheerio('#bookDetailWrapper .book-rand-a a').text();
const meta = loadedCheerio('#bookDetailWrapper .book-meta').text();
novel.status = meta.includes('完結')
? NovelStatus.Completed
: NovelStatus.Ongoing;
novel.genres = loadedCheerio('.tag-small.red')
.children('a')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(',');
// Table of Content is on a different page than the summary page
const chapter: Plugin.ChapterItem[] = [];
const idPattern = /\/(\d+)\.html/;
const novelId = url.match(idPattern)?.[1];
const chaptersUrl = this.site + loadedCheerio('#btnReadBook').attr('href');
const chaptersBody = await fetchText(chaptersUrl);
const chaptersLoadedCheerio = parseHTML(chaptersBody);
let volumeName: string, chapterId: number;
chaptersLoadedCheerio('#volumes .chapter-li:not(.volume-cover)').each(
(i, el) => {
if (chaptersLoadedCheerio(el).hasClass('chapter-bar')) {
volumeName = chaptersLoadedCheerio(el).text();
return;
} else {
const urlPart = chaptersLoadedCheerio(el)
.find('.chapter-li-a')
.attr('href');
const chapterIdMatch = urlPart?.match(idPattern);
// Sometimes the href attribute does not contain the url, but javascript:cid(0).
// Increment the previous chapter ID should result in the right URL
if (chapterIdMatch) {
chapterId = +chapterIdMatch[1];
} else {
chapterId++;
}
}
const chapterUrl = `/novel/${novelId}/${chapterId}.html`;
const chapterName =
volumeName +
' — ' +
chaptersLoadedCheerio(el).find('.chapter-index').text().trim();
const releaseDate = null;
if (!chapterId) return;
chapter.push({
name: chapterName,
releaseTime: releaseDate,
path: chapterUrl,
});
},
);
novel.chapters = chapter;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
let chapterName,
chapterText = '',
hasNextPage,
pageHasNextPage,
pageText = '';
let pageNumber = 1;
/*
* TODO: Maybe there are other ways to get the translation table
* It is embed and encrypted inside readtool.js
* UPDATE: Decrypted, see skillgg
*/
// const mapping_dict = {
// '“': '「',
// '’': '』',
// '': '是',
// '': '不',
// '': '好',
// '': '个',
// '': '开',
// '': '样',
// '': '想',
// '': '说',
// '': '年',
// '': '那',
// '': '她',
// '': '美',
// '': '自',
// '': '家',
// '': '而',
// '': '去',
// '': '都',
// '': '于',
// '': '舔',
// '': '他',
// '': '只',
// '': '看',
// '': '来',
// '': '用',
// '': '道',
// '': '得',
// '': '乳',
// '': '茎',
// '': '肉',
// '': '胸',
// '': '淫',
// '': '性',
// '': '骚',
// '”': '」',
// '': '的',
// '': '当',
// '': '人',
// '': '有',
// '': '上',
// '': '到',
// '': '地',
// '': '中',
// '': '生',
// '': '着',
// '': '和',
// '': '起',
// '': '交',
// '': '以',
// '': '可',
// '': '过',
// '': '能',
// '': '多',
// '': '心',
// '': '小',
// '': '成',
// '': '了',
// '': '把',
// '': '发',
// '': '第',
// '': '子',
// '': '事',
// '': '阴',
// '': '欲',
// '': '里',
// '': '私',
// '': '臀',
// '': '脱',
// '': '唇',
// '‘': '『',
// '': '一',
// '': '我',
// '': '在',
// '': '这',
// '': '们',
// '': '时',
// '': '为',
// '': '你',
// '': '国',
// '': '就',
// '': '要',
// '': '也',
// '': '后',
// '': '没',
// '': '下',
// '': '天',
// '': '对',
// '': '然',
// '': '学',
// '': '之',
// '': '出',
// '': '没',
// '': '如',
// '': '还',
// '': '大',
// '': '作',
// '': '种',
// '': '液',
// '': '呻',
// '': '射',
// '': '穴',
// '': '么',
// '': '裸',
// };
const skillgg: Record<string, string> = {
'\u201c': '\u300c',
'\u201d': '\u300d',
'\u2018': '\u300e',
'\u2019': '\u300f',
'\ue82c': '\u7684',
'\ue852': '\u4e00',
'\ue82d': '\u662f',
'\ue819': '\u4e86',
'\ue856': '\u6211',
'\ue857': '\u4e0d',
'\ue816': '\u4eba',
'\ue83c': '\u5728',
'\ue830': '\u4ed6',
'\ue82e': '\u6709',
'\ue836': '\u8fd9',
'\ue859': '\u4e2a',
'\ue80a': '\u4e0a',
'\ue855': '\u4eec',
'\ue842': '\u6765',
'\ue858': '\u5230',
'\ue80b': '\u65f6',
'\ue81f': '\u5927',
'\ue84a': '\u5730',
'\ue853': '\u4e3a',
'\ue81e': '\u5b50',
'\ue822': '\u4e2d',
'\ue813': '\u4f60',
'\ue85b': '\u8bf4',
'\ue807': '\u751f',
'\ue818': '\u56fd',
'\ue810': '\u5e74',
'\ue812': '\u7740',
'\ue851': '\u5c31',
'\ue801': '\u90a3',
'\ue80c': '\u548c',
'\ue815': '\u8981',
'\ue84c': '\u5979',
'\ue840': '\u51fa',
'\ue848': '\u4e5f',
'\ue835': '\u5f97',
'\ue800': '\u91cc',
'\ue826': '\u540e',
'\ue863': '\u81ea',
'\ue861': '\u4ee5',
'\ue854': '\u4f1a',
'\ue827': '\u5bb6',
'\ue83b': '\u53ef',
'\ue85d': '\u4e0b',
'\ue84d': '\u800c',
'\ue862': '\u8fc7',
'\ue81c': '\u5929',
'\ue81d': '\u53bb',
'\ue860': '\u80fd',
'\ue843': '\u5bf9',
'\ue82f': '\u5c0f',
'\ue802': '\u591a',
'\ue831': '\u7136',
'\ue84b': '\u4e8e',
'\ue837': '\u5fc3',
'\ue829': '\u5b66',
'\ue85e': '\u4e48',
'\ue83a': '\u4e4b',
'\ue832': '\u90fd',
'\ue808': '\u597d',
'\ue841': '\u770b',
'\ue821': '\u8d77',
'\ue845': '\u53d1',
'\ue803': '\u5f53',
'\ue828': '\u6ca1',
'\ue81b': '\u6210',
'\ue83e': '\u53ea',
'\ue820': '\u5982',
'\ue84e': '\u4e8b',
'\ue85a': '\u628a',
'\ue806': '\u8fd8',
'\ue83f': '\u7528',
'\ue833': '\u7b2c',
'\ue811': '\u6837',
'\ue804': '\u9053',
'\ue814': '\u60f3',
'\ue80f': '\u4f5c',
'\ue84f': '\u79cd',
'\ue80e': '\u5f00',
'\ue823': '\u7f8e',
'\ue849': '\u4e73',
'\ue805': '\u9634',
'\ue809': '\u6db2',
'\ue81a': '\u830e',
'\ue844': '\u6b32',
'\ue847': '\u547b',
'\ue850': '\u8089',
'\ue824': '\u4ea4',
'\ue85f': '\u6027',
'\ue817': '\u80f8',
'\ue85c': '\u79c1',
'\ue838': '\u7a74',
'\ue82a': '\u6deb',
'\ue83d': '\u81c0',
'\ue82b': '\u8214',
'\ue80d': '\u5c04',
'\ue839': '\u8131',
'\ue834': '\u88f8',
'\ue846': '\u9a9a',
'\ue825': '\u5507',
};
const addPage = async (pageCheerio: CheerioAPI) => {
const formatPage = async () => {
// Remove JS and notice of the website
pageCheerio('div.cgo, center').remove();
// Load lazyloaded images
pageCheerio('#acontentl img.imagecontent').each((i, el) => {
// Sometimes images are either in data-src or src
const imgSrc =
pageCheerio(el).attr('data-src') || pageCheerio(el).attr('src');
if (imgSrc) {
// Clean up img element
pageCheerio(el)
.attr('src', imgSrc)
.removeAttr('data-src')
.removeClass('lazyload');
}
});
// Recover the original character
pageText = pageCheerio('#acontentl').html() || '';
pageText = pageText.replace(/./g, char => skillgg[char] || char);
return Promise.resolve();
};
await formatPage();
chapterName =
pageCheerio('#atitle + h3').text() +
' — ' +
pageCheerio('#atitle').text();
if (chapterText === '') {
chapterText = '<h2>' + chapterName + '</h2>';
}
chapterText += pageText;
};
const loadPage = async (url: string) => {
const headers = {
'Accept':
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language':
'zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en;q=0.6,en-GB;q=0.5,en-US;q=0.4',
'Cache-Control': 'no-cache',
};
const body = await fetchText(url, { headers });
const pageCheerio = parseHTML(body);
await addPage(pageCheerio);
pageHasNextPage =
pageCheerio('#footlink a:last').text() === '下一页' ||
pageCheerio('#footlink a:last').text() === '下一頁'
? true
: false;
return { pageCheerio, pageHasNextPage };
};
let url = this.site + chapterPath;
const baseUrl = url;
do {
const page = await loadPage(url);
hasNextPage = page.pageHasNextPage;
if (hasNextPage === true) {
pageNumber++;
url = baseUrl.replace(/\.html/gi, `_${pageNumber}` + '.html');
}
} while (hasNextPage === true);
return chapterText;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const url = `${this.site}/search/${encodeURI(searchTerm)}_${pageNo}.html`;
const body = await fetchText(url);
if (body === '') throw Error('無法獲取搜索結果, 請檢查網絡');
const pageCheerio = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
// const addPage = async (pageCheerio: CheerioAPI, redirect: string) => {
// const loadSearchResults = () => {
// pageCheerio(".book-ol .book-layout").each((i, el) => {
// let nUrl = pageCheerio(el).attr("href");
// const novelName = pageCheerio(el)
// .find(".book-title")
// .text();
// const novelCover = pageCheerio(el)
// .find("div.book-cover > img")
// .attr("data-src");
// const novelUrl = this.site + nUrl;
// if (!nUrl) return;
// novels.push({
// name: novelName,
// url: novelUrl,
// cover: novelCover,
// });
// });
// };
// const novelResults = pageCheerio(".book-ol a.book-layout");
// if (novelResults.length === 0) {
// } else {
// loadSearchResults();
// }
// if (redirect.length) {
// novels.length = 0;
// const novelName = pageCheerio(
// "#bookDetailWrapper .book-title"
// ).text();
// const novelCover = pageCheerio(
// "#bookDetailWrapper div.book-cover > img"
// ).attr("src");
// const novelUrl =
// this.site +
// pageCheerio("#btnReadBook").attr("href")?.slice(0, -8) +
// ".html";
// novels.push({
// name: novelName,
// url: novelUrl,
// cover: novelCover,
// });
// }
// };
// NOTE: don't know redirect is for what, comment out for now
// const redirect = pageCheerio("div.book-layout").text();
// await addPage(pageCheerio, redirect);
pageCheerio('.book-ol .book-layout').each((i, el) => {
const nUrl = pageCheerio(el).attr('href');
const novelName = pageCheerio(el).find('.book-title').text();
const novelCover = pageCheerio(el)
.find('div.book-cover > img')
.attr('data-src');
if (!nUrl) return;
novels.push({
name: novelName,
path: nUrl,
cover: novelCover,
});
});
return novels;
}
filters = {
rank: {
label: '排行榜',
value: 'monthvisit',
options: [
{ label: '月點擊榜', value: 'monthvisit' },
{ label: '周點擊榜', value: 'weekvisit' },
{ label: '月推薦榜', value: 'monthvote' },
{ label: '周推薦榜', value: 'weekvote' },
{ label: '月鮮花榜', value: 'monthflower' },
{ label: '周鮮花榜', value: 'weekflower' },
{ label: '月鷄蛋榜', value: 'monthegg' },
{ label: '周鷄蛋榜', value: 'weekegg' },
{ label: '最近更新', value: 'lastupdate' },
{ label: '最新入庫', value: 'postdate' },
{ label: '收藏榜', value: 'goodnum' },
{ label: '新書榜', value: 'newhot' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
}
export default new Linovelib_tw();
+328
View File
@@ -0,0 +1,328 @@
import { load as parseHTML } from 'cheerio';
import { fetchApi, fetchFile } from '@/lib/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 Novel543Plugin implements Plugin.PluginBase {
id = 'novel543';
name = 'Novel543';
site = 'https://www.novel543.com/';
version = '1.0.0';
icon = 'src/cn/novel543/icon.png';
imageRequestInit = {
headers: {
Referer: this.site,
},
};
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
if (pageNo > 1) return [];
const result = await fetchApi(this.site);
if (!result.ok) return [];
const $ = parseHTML(await result.text());
const novels: Plugin.NovelItem[] = [];
const processedPaths = new Set<string>();
$('ul.list > li.media, ul.list li > a[href^="/"][href$="/"]').each(
(_i, el) => {
const $el = $(el);
let novelPath: string | undefined;
let novelName: string | undefined;
let novelCover: string | undefined;
if ($el.is('li.media')) {
const $link = $el.find('.media-content h3 a');
novelPath = $link.attr('href')?.trim();
novelName = $link.text().trim();
novelCover = $el.find('.media-left img').attr('src')?.trim();
} else if ($el.is('a')) {
novelPath = $el.attr('href')?.trim();
novelName =
$el.find('h3, b, span').first().text().trim() ||
$el.parent().find('h3').text().trim() ||
$el.text().trim();
novelCover =
$el.find('img').attr('src')?.trim() ||
$el.parent().find('img').attr('src')?.trim();
}
if (
novelPath &&
novelName &&
novelPath.match(/^\/\d+\/$/) &&
!processedPaths.has(novelPath)
) {
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 $infoSection = $('section#detail div.media-content.info');
const $modSection = $('section#detail div.mod');
const novel: Plugin.SourceNovel = {
path: novelPath,
name: $infoSection.find('h1.title').text().trim() || 'Untitled',
cover:
makeAbsolute(
$('section#detail div.cover img').attr('src'),
this.site,
) || defaultCover,
summary: $modSection.find('div.intro').text().trim() || undefined,
author:
$infoSection.find('p.meta span.author').text().trim() || undefined,
genres:
$infoSection
.find('p.meta a[href*="/bookstack/"]')
.map((_i, el) => $(el).text().trim())
.get()
.join(', ') || undefined,
status: NovelStatus.Unknown,
chapters: [],
};
const chapterListPath =
$modSection
.find('p.action.buttons a.button.is-info[href$="/dir"]')
.attr('href') ||
$infoSection.find('a.button.is-info[href$="/dir"]').attr('href');
if (chapterListPath) {
novel.chapters = await this.parseChapterList(chapterListPath);
}
return novel;
}
async parseChapterList(
chapterListPath: string,
): Promise<Plugin.ChapterItem[]> {
const chapterListUrl = makeAbsolute(chapterListPath, this.site);
if (!chapterListUrl) return [];
const result = await fetchApi(chapterListUrl);
if (!result.ok) return [];
const $ = parseHTML(await result.text());
const chapters: Plugin.ChapterItem[] = [];
$('div.chaplist ul.all li a').each((index, el) => {
const $el = $(el);
const chapterName = $el.text().trim();
const chapterUrl = $el.attr('href')?.trim();
if (chapterName && chapterUrl) {
chapters.push({
name: chapterName,
path: chapterUrl,
chapterNumber: index + 1,
});
}
});
const sortButtonText = $('div.chaplist .header button.reverse span')
.last()
.text()
.trim();
if (sortButtonText === '倒序') {
chapters.reverse();
chapters.forEach((chap, index) => (chap.chapterNumber = index + 1));
}
return chapters;
}
async parseChapter(chapterPath: string): Promise<string> {
const chapterUrl = makeAbsolute(chapterPath, this.site);
if (!chapterUrl) throw new Error('Invalid chapter URL');
const result = await fetchApi(chapterUrl);
if (!result.ok) throw new Error('Failed to fetch chapter');
const $ = parseHTML(await result.text());
const $content = $('div.content.py-5');
if (!$content.length) return 'Error: Could not find chapter content';
$content
.find(
'script, style, ins, iframe, [class*="ads"], [id*="ads"], [class*="google"], [id*="google"], [class*="recommend"], div[align="center"], p:contains("推薦本書"), a[href*="javascript:"]',
)
.remove();
$content.find('p').each((_i, el) => {
const $p = $(el);
const pText = $p.text().trim();
if (
pText.includes('請記住本站域名') ||
pText.includes('手機版閱讀網址') ||
pText.includes('novel543') ||
pText.includes('稷下書院') ||
pText.includes('最快更新') ||
pText.includes('最新章節') ||
pText.includes('章節報錯') ||
pText.match(/app|APP|下載|客户端|关注微信|公众号/i) ||
pText.length === 0 ||
($p
.html()
?.replace(/&nbsp;/g, '')
.trim() === '' &&
$p.find('img').length === 0) ||
pText.includes('溫馨提示')
) {
$p.remove();
}
});
$content
.contents()
.filter(function () {
return this.type === 'comment';
})
.remove();
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[]> {
if (pageNo > 1) return [];
if (/^\d+$/.test(searchTerm)) {
try {
const novelPath = `/${searchTerm}/`;
const novel = await this.parseNovel(novelPath);
return [
{
name: novel.name,
path: novelPath,
cover: novel.cover,
},
];
} catch {
return [];
}
}
const searchUrl = `${this.site}search/${encodeURIComponent(searchTerm)}`;
let body = '';
try {
const result = await fetchApi(searchUrl);
if (!result.ok) {
if (result.status === 403 || result.status === 503) {
throw new Error(
'Cloudflare protection detected (HTTP error). Please try opening the plugin in WebView first to solve the challenge.',
);
}
return [];
}
body = await result.text();
const $ = parseHTML(body);
const pageTitle = $('title').text().toLowerCase();
// Check for various Cloudflare challenge indicators
if (
pageTitle.includes('attention required') ||
pageTitle.includes('just a moment') ||
pageTitle.includes('please wait') ||
pageTitle.includes('verifying') ||
body.includes('Verifying you are human') ||
body.includes('cf-browser-verification') ||
body.includes('cf_captcha_container')
) {
throw new Error(
'Cloudflare protection detected. Please try opening the plugin in WebView first to solve the challenge.',
);
}
} catch (error) {
if (error instanceof Error) {
// If it's already our custom error, re-throw it
if (error.message.includes('Cloudflare protection detected')) {
throw error;
}
// For other errors, throw a generic error
throw new Error(`Failed to fetch search results: ${error.message}`);
}
throw error;
}
const $ = parseHTML(body);
const novels: Plugin.NovelItem[] = [];
$('div.search-list ul.list > li.media').each((_i, el) => {
const $el = $(el);
const $link = $el.find('.media-content h3 a');
const novelPath = $link.attr('href')?.trim();
const novelName = $link.text().trim();
const novelCover = $el.find('.media-left img').attr('src')?.trim();
if (novelPath && novelName && novelPath.match(/^\/\d+\/$/)) {
novels.push({
name: novelName,
path: novelPath,
cover: makeAbsolute(novelCover, this.site) || defaultCover,
});
}
});
return novels;
}
}
export default new Novel543Plugin();