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:
committed by
GitHub
parent
252beff0bf
commit
20c2b75b01
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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(/ /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();
|
||||
@@ -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(/ /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();
|
||||
@@ -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 === ' ') {
|
||||
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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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(/ /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();
|
||||
Reference in New Issue
Block a user