Files
lnreader-plugins/plugins/chinese/Quanben.ts
T
Mousica e2d53b7832 fix: Quanben and Add Filters (#1877)
* fix quanben +added filters

* remove https://69shu.biz
2025-11-13 17:30:23 +05:30

280 lines
8.9 KiB
TypeScript

import { Plugin } from '@/types/plugin';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { fetchApi } from '@/lib/fetch';
import { NovelStatus } from '@libs/novelStatus';
import { load as parseHTML } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
const parseUrl = (url?: string): URL | undefined => {
if (!url) return undefined;
try {
return new URL(url, 'https://www.quanben.io');
} catch {
return undefined;
}
};
const getStandardNovelPath = (url?: string): string | undefined => {
const parsedUrl = parseUrl(url);
if (!parsedUrl) return undefined;
const match = parsedUrl.pathname.match(/^(\/amp)?(\/n\/[^\/]+\/)/);
return match?.[2];
};
const getChapterFileName = (url?: string): string | undefined => {
const parsedUrl = parseUrl(url);
if (!parsedUrl) return undefined;
const fileName = parsedUrl.pathname.split('/').pop();
if (fileName && /^\d+\.html$/.test(fileName)) return fileName;
return undefined;
};
const makeAbsolute = (
relativeUrl?: string,
baseUrl?: string,
): string | undefined => {
if (!relativeUrl || !baseUrl) return undefined;
try {
if (relativeUrl.startsWith('//')) return 'https:' + relativeUrl;
if (/^https?:\/\//.test(relativeUrl)) return relativeUrl;
return new URL(relativeUrl, baseUrl).href;
} catch {
return undefined;
}
};
class QuanbenPlugin implements Plugin.PluginBase {
id = 'quanben';
name = 'Quanben';
site = 'https://www.quanben.io/';
version = '1.0.1';
icon = 'src/cn/quanben/icon.png';
defaultCover = defaultCover;
// filters
filters = {
genre: {
label: '分类',
value: 'all',
options: [
{ label: '全部', value: 'all' },
{ label: '玄幻', value: 'xuanhuan' },
{ label: '都市', value: 'dushi' },
{ label: '言情', value: 'yanqing' },
{ label: '穿越', value: 'chuanyue' },
{ label: '青春', value: 'qingchun' },
{ label: '仙侠', value: 'xianxia' },
{ label: '灵异', value: 'lingyi' },
{ label: '悬疑', value: 'xuanyi' },
{ label: '历史', value: 'lishi' },
{ label: '军事', value: 'junshi' },
{ label: '游戏', value: 'youxi' },
{ label: '竞技', value: 'jingji' },
{ label: '科幻', value: 'kehuan' },
{ label: '职场', value: 'zhichang' },
{ label: '官场', value: 'guanchang' },
{ label: '现言', value: 'xianyan' },
{ label: '耽美', value: 'danmei' },
{ label: '其它', value: 'qita' },
],
type: FilterTypes.Picker,
},
} satisfies Filters;
// homepage, when you first open the extension (with the applied filters if any)
async popularNovels(
_pageNo: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const url =
filters.genre.value === 'all'
? this.site
: `${this.site}c/${filters.genre.value}.html`;
const res = await fetchApi(url);
if (!res.ok)
throw new Error(`[Quanben] Failed to fetch: ${url} - ${res.status}`);
const $ = parseHTML(await res.text());
const novels: Plugin.NovelItem[] = [];
$('div.list2').each((_i, list2) => {
const $list2 = $(list2);
const $link = $list2.find('h3 > a').first();
const href = $link.attr('href')?.trim();
const name = $link.text().trim();
const rawCover =
$list2.find('img').attr('src')?.trim() ||
$list2.find('img').attr('data-src')?.trim();
const cover = makeAbsolute(rawCover, this.site) || this.defaultCover;
if (href && name) {
const path = getStandardNovelPath(href);
if (path) novels.push({ name, path, cover });
}
});
// only first entry bcs the others dont have an image
$('ul.list').each((_i, ul) => {
const $firstLi = $(ul).find('li').first();
const $a = $firstLi.find('a').first();
const href = $a.attr('href')?.trim();
const name =
$a.text().trim() || $firstLi.find('span.author').text().trim();
const cover = this.defaultCover;
if (href && name) {
const path = getStandardNovelPath(href);
if (path) novels.push({ name, path, cover });
}
});
return novels;
}
// novel details and metadata
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const standardPath = novelPath.replace(/^\/amp/, '');
if (!standardPath.startsWith('/n/') || !standardPath.endsWith('/'))
throw new Error(`[Quanben parseNovel] Invalid path: ${novelPath}`);
const fullUrl = makeAbsolute(standardPath, this.site);
if (!fullUrl)
throw new Error(`[Quanben parseNovel] Could not construct full URL`);
const res = await fetchApi(fullUrl);
if (!res.ok)
throw new Error(`[Quanben parseNovel] Failed to fetch: ${fullUrl}`);
const $ = parseHTML(await res.text());
const $info = $('div.list2').first();
const $desc = $('div.description').first();
const novel: Plugin.SourceNovel = {
path: standardPath,
name: $info.find('h3').text().trim() || 'Unknown Novel',
cover:
makeAbsolute($info.find('img').attr('src'), this.site) ||
this.defaultCover,
summary:
$desc.find('p').text().trim() || $desc.text().trim() || undefined,
author: $info.find("p:contains('作者:') span").text().trim() || undefined,
status: NovelStatus.Unknown,
genres: $info.find("p:contains('类别:') span").text().trim() || undefined,
chapters: await this.parseChapterList(standardPath),
};
novel.status = NovelStatus.Completed;
return novel;
}
async parseChapterList(novelPath: string): Promise<Plugin.ChapterItem[]> {
if (!novelPath.startsWith('/n/') || !novelPath.endsWith('/')) return [];
const url = makeAbsolute(novelPath + 'list.html', this.site);
if (!url) return [];
const res = await fetchApi(url);
if (!res.ok) return [];
const $ = parseHTML(await res.text());
const chapters: Plugin.ChapterItem[] = [];
const novelName = novelPath.match(/\/n\/([^\/]+)\//)?.[1];
if (!novelName) return [];
$('ul.list3 li a').each((_i, el) => {
const $el = $(el);
const name = $el.text().trim();
const href = $el.attr('href');
if (!name || !href) return;
const fileName = getChapterFileName(makeAbsolute(href, this.site));
if (!fileName) return;
chapters.push({
name,
path: `${novelName}/${fileName}`,
});
});
const uniqueChapters = Array.from(
new Map(chapters.map(c => [c.path, c])).values(),
);
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;
});
return uniqueChapters.map((c, idx) => ({ ...c, chapterNumber: idx + 1 }));
}
async parseChapter(chapterPath: string): Promise<string> {
if (!chapterPath.includes('/') || chapterPath.endsWith('/'))
throw new Error(`[Quanben] Invalid chapter path: "${chapterPath}"`);
const url = `${this.site}n/${chapterPath}`;
const res = await fetchApi(url);
if (!res.ok) throw new Error(`[Quanben] Failed to fetch chapter: ${url}`);
return this.extractChapterContent(await res.text());
}
// Helper function to extract and clean chapter content from HTML body
private extractChapterContent(body: string): string {
const $ = parseHTML(body);
let $content = $('#contentbody, #content, .content').first();
if (!$content.length) return 'Error: Chapter content not found.';
$content
.find(
'script, style, ins, iframe, [class*="ads"], [id*="ads"], [class*="google"], [id*="google"], [class*="recommend"], div[align="center"]',
)
.remove();
return (
($content.html() || '').replace(/[\t ]+/g, ' ').trim() ||
'Error: Chapter content empty.'
);
}
// add search
async searchNovels(searchTerm: string): Promise<Plugin.NovelItem[]> {
const url = `${this.site}index.php?c=book&a=search&keywords=${encodeURIComponent(searchTerm)}`;
const res = await fetchApi(url);
if (!res.ok) return [];
const $ = parseHTML(await res.text());
const novels: Plugin.NovelItem[] = [];
$('div.list2').each((_i, el) => {
const $el = $(el);
const $link = $el.find('h3 > a').first();
const href = $link.attr('href');
const name = $link.text().trim();
const cover = makeAbsolute(
$el.find('img').attr('src') || $el.find('img').attr('data-src'),
this.site,
);
if (href && name) {
const path = getStandardNovelPath(makeAbsolute(href, this.site));
if (path)
novels.push({
name,
path: '/amp' + path,
cover: cover || this.defaultCover,
});
}
});
return novels;
}
async fetchImage(url: string): Promise<Response> {
return fetchApi(url);
}
}
export default new QuanbenPlugin();