fix(french): update French sources, add J-Garden/Trad-Index/LightNovelVF, drop Phenix Scans (#2428)
Co-authored-by: Balrog57 <193098123+Balrog57@users.noreply.github.com>
This commit is contained in:
+258
-199
@@ -2,252 +2,311 @@ import { CheerioAPI, load } from 'cheerio';
|
|||||||
import { fetchApi } from '@libs/fetch';
|
import { fetchApi } from '@libs/fetch';
|
||||||
import { Plugin } from '@/types/plugin';
|
import { Plugin } from '@/types/plugin';
|
||||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { defaultCover } from '@libs/defaultCover';
|
import { defaultCover } from '@libs/defaultCover';
|
||||||
import { NovelStatus } from '@libs/novelStatus';
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
|
|
||||||
|
type WordPressCategory = {
|
||||||
|
name: string;
|
||||||
|
link: string;
|
||||||
|
};
|
||||||
|
|
||||||
class ChireadsPlugin implements Plugin.PluginBase {
|
class ChireadsPlugin implements Plugin.PluginBase {
|
||||||
id = 'chireads';
|
id = 'chireads';
|
||||||
name = 'Chireads';
|
name = 'Chireads';
|
||||||
icon = 'src/fr/chireads/icon.png';
|
icon = 'src/fr/chireads/icon.png';
|
||||||
site = 'https://chireads.com';
|
site = 'https://chireads.com';
|
||||||
version = '1.0.2';
|
version = '2.3.4';
|
||||||
|
|
||||||
|
// The site is fronted by Cloudflare, which serves different HTML/JSON to a
|
||||||
|
// plain device User-Agent (the mobile app injects its own UA via fetchApi)
|
||||||
|
// than to a desktop browser. Send the same desktop Chrome UA on every
|
||||||
|
// request — HTML pages and the wp-json REST endpoints alike — so a novel's
|
||||||
|
// chapter list survives on the app.
|
||||||
|
private readonly browserHeaders = {
|
||||||
|
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36',
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly restHeaders = {
|
||||||
|
Accept: 'application/json, */*;q=0.8',
|
||||||
|
'User-Agent': this.browserHeaders['User-Agent'],
|
||||||
|
};
|
||||||
|
|
||||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||||
const r = await fetchApi(url, {
|
const r = await fetchApi(url, { headers: this.browserHeaders });
|
||||||
headers: { 'Accept-Encoding': 'deflate' },
|
if (!r.ok) throw new Error(`HTTP ${r.status} while loading ${url}`);
|
||||||
});
|
|
||||||
const body = await r.text();
|
const body = await r.text();
|
||||||
const $ = load(body);
|
return load(body);
|
||||||
return $;
|
}
|
||||||
|
|
||||||
|
private absoluteUrl(url?: string): string {
|
||||||
|
if (!url) return defaultCover;
|
||||||
|
try {
|
||||||
|
const absolute = new URL(url, this.site);
|
||||||
|
return /^https?:$/.test(absolute.protocol) ? absolute.href : defaultCover;
|
||||||
|
} catch {
|
||||||
|
return defaultCover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPath(url?: string): string {
|
||||||
|
if (!url) return '';
|
||||||
|
const parsed = new URL(url, this.site);
|
||||||
|
if (!/(?:^|\.)chireads\.com$/i.test(parsed.hostname)) return '';
|
||||||
|
return `${parsed.pathname}${parsed.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private compactChapterPath(url?: string): string {
|
||||||
|
if (!url) return '';
|
||||||
|
const parsed = new URL(url, this.site);
|
||||||
|
if (!/(?:^|\.)chireads\.com$/i.test(parsed.hostname)) return '';
|
||||||
|
const segments = parsed.pathname.split('/').filter(Boolean);
|
||||||
|
if (segments[0] === 'c' && segments[1]) return `/c/${segments[1]}/`;
|
||||||
|
|
||||||
|
const last = (offset: number) => segments[segments.length - offset];
|
||||||
|
const hasDateSuffix =
|
||||||
|
/^\d{4}$/.test(last(3) || '') &&
|
||||||
|
/^\d{1,2}$/.test(last(2) || '') &&
|
||||||
|
/^\d{1,2}$/.test(last(1) || '');
|
||||||
|
const chapterSlug = last(hasDateSuffix ? 4 : 1);
|
||||||
|
return chapterSlug ? `/c/${chapterSlug}/` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCards($: CheerioAPI): Plugin.NovelItem[] {
|
||||||
|
const novels: Plugin.NovelItem[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
$('ul.refresh-card-grid li.refresh-card').each((i, el) => {
|
||||||
|
const novelUrl = $(el).find('.refresh-card-title a').attr('href');
|
||||||
|
if (!novelUrl || seen.has(novelUrl)) return;
|
||||||
|
seen.add(novelUrl);
|
||||||
|
novels.push({
|
||||||
|
name: $(el).find('.refresh-card-title a').text().trim(),
|
||||||
|
cover: this.absoluteUrl(
|
||||||
|
$(el).find('.refresh-card-cover img').attr('src'),
|
||||||
|
),
|
||||||
|
path: this.toPath(novelUrl),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return novels;
|
||||||
}
|
}
|
||||||
|
|
||||||
async popularNovels(
|
async popularNovels(
|
||||||
pageNo: number,
|
pageNo: number,
|
||||||
{ filters, showLatestNovels }: Plugin.PopularNovelsOptions,
|
{ filters, showLatestNovels }: Plugin.PopularNovelsOptions,
|
||||||
): Promise<Plugin.NovelItem[]> {
|
): Promise<Plugin.NovelItem[]> {
|
||||||
let url = this.site;
|
if (showLatestNovels) {
|
||||||
let tag = 'all';
|
if (pageNo !== 1) return [];
|
||||||
if (showLatestNovels) url += '/category/translatedtales/page/' + pageNo;
|
const $ = await this.getCheerio(this.site);
|
||||||
else {
|
const novels: Plugin.NovelItem[] = [];
|
||||||
if (
|
const seen = new Set<string>();
|
||||||
filters &&
|
$('.dernieres-tabel tbody tr').each((i, el) => {
|
||||||
typeof filters.tag.value === 'string' &&
|
const novelUrl = $(el).find('td').first().find('a').attr('href');
|
||||||
filters.tag.value !== 'all'
|
if (!novelUrl || seen.has(novelUrl)) return;
|
||||||
)
|
seen.add(novelUrl);
|
||||||
tag = filters.tag.value;
|
novels.push({
|
||||||
if (tag !== 'all') url += '/tag/' + tag + '/page/' + pageNo;
|
name: $(el)
|
||||||
else if (pageNo > 1) return [];
|
.find('td')
|
||||||
|
.first()
|
||||||
|
.find('a')
|
||||||
|
.text()
|
||||||
|
.trim()
|
||||||
|
.replace(/^\[[TO]\]\s*/, ''),
|
||||||
|
cover: defaultCover,
|
||||||
|
path: this.toPath(novelUrl),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The homepage "latest" table carries no cover images, only links to
|
||||||
|
// each novel's page. Resolve covers from the detail pages so the list
|
||||||
|
// shows a real cover instead of the "not available" placeholder.
|
||||||
|
const covers = await Promise.allSettled(
|
||||||
|
novels.map(async novel => {
|
||||||
|
const page = await this.getCheerio(this.site + novel.path);
|
||||||
|
const cover =
|
||||||
|
page('.refresh-detail-cover img').attr('src') ||
|
||||||
|
page('.refresh-detail-cover img').attr('data-src');
|
||||||
|
return cover ? this.absoluteUrl(cover) : defaultCover;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return novels.map((novel, index) => ({
|
||||||
|
...novel,
|
||||||
|
cover:
|
||||||
|
covers[index]?.status === 'fulfilled'
|
||||||
|
? covers[index].value
|
||||||
|
: defaultCover,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = filters?.tag?.value;
|
||||||
|
const isAll = typeof tag !== 'string' || tag === '' || tag === 'all';
|
||||||
|
const bases = isAll
|
||||||
|
? ['/category/translatedtales', '/category/original']
|
||||||
|
: [`/tag/${tag}`];
|
||||||
|
|
||||||
|
const catalogues = isAll
|
||||||
|
? await Promise.allSettled(
|
||||||
|
bases.map(base =>
|
||||||
|
this.getCheerio(`${this.site}${base}/page/${pageNo}`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
status: 'fulfilled' as const,
|
||||||
|
value: await this.getCheerio(
|
||||||
|
`${this.site}${bases[0]}/page/${pageNo}`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
isAll &&
|
||||||
|
catalogues.every(catalogue => catalogue.status === 'rejected')
|
||||||
|
) {
|
||||||
|
throw new Error('All catalogue pages failed');
|
||||||
}
|
}
|
||||||
let $ = await this.getCheerio(url);
|
|
||||||
|
|
||||||
const novels: Plugin.NovelItem[] = [];
|
const novels: Plugin.NovelItem[] = [];
|
||||||
let novel: Plugin.NovelItem;
|
const seen = new Set<string>();
|
||||||
|
for (const catalogue of catalogues) {
|
||||||
if (showLatestNovels || tag !== 'all') {
|
if (catalogue.status !== 'fulfilled') continue;
|
||||||
let loop = 1;
|
const $ = catalogue.value;
|
||||||
if (showLatestNovels) loop = 2;
|
for (const novel of this.parseCards($)) {
|
||||||
for (let i = 0; i < loop; i++) {
|
if (seen.has(novel.path)) continue;
|
||||||
if (i === 1)
|
seen.add(novel.path);
|
||||||
$ = await this.getCheerio(
|
novels.push(novel);
|
||||||
this.site + '/category/original/page/' + pageNo,
|
|
||||||
);
|
|
||||||
let romans = $('.romans-content li');
|
|
||||||
if (!romans.length) romans = $('#content li');
|
|
||||||
romans.each((i, elem) => {
|
|
||||||
const novelName = $(elem)
|
|
||||||
.contents()
|
|
||||||
.find('div')
|
|
||||||
.first()
|
|
||||||
.text()
|
|
||||||
.trim();
|
|
||||||
const novelCover = $(elem)
|
|
||||||
.find('div')
|
|
||||||
.first()
|
|
||||||
.find('img')
|
|
||||||
.attr('src');
|
|
||||||
const novelUrl = $(elem).find('div').first().find('a').attr('href');
|
|
||||||
|
|
||||||
if (novelUrl) {
|
|
||||||
novel = {
|
|
||||||
name: novelName,
|
|
||||||
cover: novelCover,
|
|
||||||
path: novelUrl.replace(this.site, ''),
|
|
||||||
};
|
|
||||||
novels.push(novel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const populaire = $(':contains("Populaire")')
|
|
||||||
.last()
|
|
||||||
.parent()
|
|
||||||
.next()
|
|
||||||
.find('li > div');
|
|
||||||
if (populaire.length === 12) {
|
|
||||||
// pc
|
|
||||||
let novelCover: string | undefined;
|
|
||||||
let novelName: string | undefined;
|
|
||||||
let novelUrl: string | undefined;
|
|
||||||
populaire.each((i, elem) => {
|
|
||||||
if (i % 2 === 0) novelCover = $(elem).find('img').attr('src');
|
|
||||||
else {
|
|
||||||
novelName = $(elem).text().trim();
|
|
||||||
novelUrl = $(elem).find('a').attr('href');
|
|
||||||
|
|
||||||
if (!novelUrl) return;
|
|
||||||
|
|
||||||
novel = {
|
|
||||||
name: novelName,
|
|
||||||
cover: novelCover || defaultCover,
|
|
||||||
path: novelUrl.replace(this.site, ''),
|
|
||||||
};
|
|
||||||
|
|
||||||
novels.push(novel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} // mobile
|
|
||||||
else {
|
|
||||||
const imgs = populaire.find('div.popular-list-img img');
|
|
||||||
const txts = populaire.find('div.popular-list-name');
|
|
||||||
|
|
||||||
txts.each((i, elem) => {
|
|
||||||
const novelName = $(elem).text().trim();
|
|
||||||
const novelCover = $(imgs[i]).attr('src');
|
|
||||||
const novelUrl = $(elem).find('a').attr('href');
|
|
||||||
|
|
||||||
if (novelUrl) {
|
|
||||||
novel = {
|
|
||||||
name: novelName,
|
|
||||||
cover: novelCover,
|
|
||||||
path: novelUrl.replace(this.site, ''),
|
|
||||||
};
|
|
||||||
novels.push(novel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return novels;
|
return novels;
|
||||||
}
|
}
|
||||||
|
|
||||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||||
const novel: Plugin.SourceNovel = { path: novelPath, name: 'Sans titre' };
|
const novel: Plugin.SourceNovel = {
|
||||||
|
path: this.toPath(novelPath),
|
||||||
|
name: '',
|
||||||
|
};
|
||||||
|
|
||||||
const $ = await this.getCheerio(this.site + novelPath);
|
const $ = await this.getCheerio(this.site + novel.path);
|
||||||
|
|
||||||
novel.name =
|
novel.name = $('h1.refresh-detail-title').first().text().trim();
|
||||||
$('.inform-product-txt').first().text().trim() ||
|
novel.cover = this.absoluteUrl(
|
||||||
$('.inform-title').text().trim();
|
$('.refresh-detail-cover img').attr('src') ||
|
||||||
novel.cover =
|
$('.refresh-detail-cover img').attr('data-src'),
|
||||||
$('.inform-product img').attr('src') ||
|
);
|
||||||
$('.inform-product-img img').attr('src') ||
|
novel.summary = $('.refresh-detail-summary-content').text().trim();
|
||||||
defaultCover;
|
|
||||||
novel.summary =
|
|
||||||
$('.inform-inform-txt').text().trim() ||
|
|
||||||
$('.inform-intr-txt').text().trim();
|
|
||||||
|
|
||||||
const infos =
|
$('.refresh-detail-meta > div').each((i, el) => {
|
||||||
$('div.inform-product-txt > div.inform-intr-col').text().trim() ||
|
const label = $(el).find('dt').text().trim();
|
||||||
$('div.inform-inform-data > h6').text().trim();
|
const value = $(el).find('dd').text().trim();
|
||||||
if (infos.includes('Auteur : '))
|
if (label.includes('Auteur')) novel.author = value;
|
||||||
novel.author = infos
|
else if (label.includes('Statut')) {
|
||||||
.substring(
|
const status = value.toLowerCase();
|
||||||
infos.indexOf('Auteur : ') + 9,
|
if (status.includes('en pause') || status.includes('hiatus'))
|
||||||
infos.indexOf('Statut de Parution : '),
|
novel.status = NovelStatus.OnHiatus;
|
||||||
)
|
else if (status.includes('complet') || status.includes('termin'))
|
||||||
.trim();
|
novel.status = NovelStatus.Completed;
|
||||||
else if (infos.includes('Fantrad : '))
|
else novel.status = NovelStatus.Ongoing;
|
||||||
novel.author = infos
|
|
||||||
.substring(
|
|
||||||
infos.indexOf('Fantrad : ') + 10,
|
|
||||||
infos.indexOf('Statut de Parution : '),
|
|
||||||
)
|
|
||||||
.trim();
|
|
||||||
else novel.author = 'Inconnu';
|
|
||||||
switch (
|
|
||||||
infos.substring(infos.indexOf('Statut de Parution : ') + 21).toLowerCase()
|
|
||||||
) {
|
|
||||||
case 'en pause':
|
|
||||||
novel.status = NovelStatus.OnHiatus;
|
|
||||||
break;
|
|
||||||
case 'complet':
|
|
||||||
novel.status = NovelStatus.Completed;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
novel.status = NovelStatus.Ongoing;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chapters: Plugin.ChapterItem[] = [];
|
|
||||||
|
|
||||||
let chapterList = $('.chapitre-table a');
|
|
||||||
if (!chapterList.length) {
|
|
||||||
$('div.inform-annexe-list').first().remove();
|
|
||||||
chapterList = $('.inform-annexe-list').find('a');
|
|
||||||
}
|
|
||||||
chapterList.each((i, elem) => {
|
|
||||||
const chapterName = $(elem).text().trim();
|
|
||||||
const chapterUrl = $(elem).attr('href');
|
|
||||||
const releaseDate = dayjs(
|
|
||||||
chapterUrl?.substring(chapterUrl.length - 11, chapterUrl.length - 1),
|
|
||||||
).format('DD MMMM YYYY');
|
|
||||||
|
|
||||||
if (chapterUrl) {
|
|
||||||
chapters.push({
|
|
||||||
name: chapterName,
|
|
||||||
releaseTime: releaseDate,
|
|
||||||
path: chapterUrl.replace(this.site, ''),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
novel.chapters = chapters;
|
const chapters = new Map<string, Plugin.ChapterItem>();
|
||||||
|
$('.refresh-detail-chapter-list a').each((i, el) => {
|
||||||
|
const chapterUrl = $(el).attr('href');
|
||||||
|
const path = this.compactChapterPath(chapterUrl);
|
||||||
|
if (!path || chapters.has(path)) return;
|
||||||
|
|
||||||
|
const title = $(el).text().trim();
|
||||||
|
const match = title.match(
|
||||||
|
/^Chapitre\s+(\d+(?:[.,]\d+)?)\s*(?:(?:–|-|:)\s*)?(.*)$/i,
|
||||||
|
);
|
||||||
|
const segments = new URL(chapterUrl!, this.site).pathname
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean);
|
||||||
|
const date = segments.slice(-3);
|
||||||
|
const hasDate =
|
||||||
|
/^\d{4}$/.test(date[0] || '') &&
|
||||||
|
/^\d{1,2}$/.test(date[1] || '') &&
|
||||||
|
/^\d{1,2}$/.test(date[2] || '');
|
||||||
|
|
||||||
|
chapters.set(path, {
|
||||||
|
name: match
|
||||||
|
? `${match[1]}${match[2] ? ` - ${match[2].trim()}` : ''}`
|
||||||
|
: title,
|
||||||
|
path,
|
||||||
|
...(match ? { chapterNumber: Number(match[1].replace(',', '.')) } : {}),
|
||||||
|
...(hasDate
|
||||||
|
? {
|
||||||
|
releaseTime: `${date[0]}-${date[1].padStart(2, '0')}-${date[2].padStart(2, '0')}`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
novel.chapters = Array.from(chapters.values());
|
||||||
|
|
||||||
return novel;
|
return novel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async parseChapter(chapterUrl: string): Promise<string> {
|
async parseChapter(chapterUrl: string): Promise<string> {
|
||||||
const $ = await this.getCheerio(this.site + chapterUrl);
|
const $ = await this.getCheerio(
|
||||||
|
this.site + this.compactChapterPath(chapterUrl),
|
||||||
|
);
|
||||||
|
|
||||||
const chapterText = $('#content').html() || '';
|
const content = $('#content').first();
|
||||||
|
content
|
||||||
|
.find('script, style, iframe, form, nav, footer, .sharedaddy, .ads')
|
||||||
|
.remove();
|
||||||
|
if (content.text().replace(/\s+/g, ' ').trim().length < 200) {
|
||||||
|
throw new Error('Chapter content is not readable');
|
||||||
|
}
|
||||||
|
|
||||||
return chapterText;
|
return content.html() || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchNovels(
|
async searchNovels(
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
pageNo: number,
|
pageNo: number,
|
||||||
): Promise<Plugin.NovelItem[]> {
|
): Promise<Plugin.NovelItem[]> {
|
||||||
if (pageNo !== 1) return [];
|
const parents = await Promise.allSettled(
|
||||||
let novels: Plugin.NovelItem[] = [];
|
[2, 811].map(async parent => {
|
||||||
|
const response = await fetchApi(
|
||||||
let i = 1;
|
`${this.site}/wp-json/wp/v2/categories?parent=${parent}&search=${encodeURIComponent(searchTerm)}&per_page=100&page=${pageNo}`,
|
||||||
let finised = false;
|
{ headers: this.restHeaders },
|
||||||
while (!finised) {
|
);
|
||||||
await this.popularNovels(i, {
|
if (!response.ok) throw new Error(`Search parent ${parent} failed`);
|
||||||
showLatestNovels: true,
|
const categories: unknown = await response.json();
|
||||||
filters: undefined,
|
if (
|
||||||
}).then(res => {
|
!Array.isArray(categories) ||
|
||||||
if (res.length === 0) finised = true;
|
!categories.every(
|
||||||
novels.push(...res);
|
category =>
|
||||||
});
|
category !== null &&
|
||||||
i++;
|
typeof category === 'object' &&
|
||||||
}
|
typeof (category as WordPressCategory).name === 'string' &&
|
||||||
|
typeof (category as WordPressCategory).link === 'string',
|
||||||
novels = novels.filter(novel =>
|
)
|
||||||
novel.name
|
)
|
||||||
.toLowerCase()
|
throw new Error(`Search parent ${parent} returned invalid data`);
|
||||||
.normalize('NFD')
|
return categories as WordPressCategory[];
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
}),
|
||||||
.includes(
|
|
||||||
searchTerm
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, ''),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
const categories = parents.flatMap(parent =>
|
||||||
|
parent.status === 'fulfilled' ? parent.value : [],
|
||||||
|
);
|
||||||
|
if (parents.every(parent => parent.status === 'rejected'))
|
||||||
|
throw new Error('Chireads search failed for all category parents');
|
||||||
|
|
||||||
return novels;
|
const seen = new Set<string>();
|
||||||
|
return categories
|
||||||
|
.map(category => ({
|
||||||
|
name: category.name,
|
||||||
|
cover: defaultCover,
|
||||||
|
path: this.toPath(category.link),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
novel =>
|
||||||
|
(novel.path.startsWith('/category/translatedtales/') ||
|
||||||
|
novel.path.startsWith('/category/original/')) &&
|
||||||
|
!seen.has(novel.path) &&
|
||||||
|
Boolean(seen.add(novel.path)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
filters = {
|
filters = {
|
||||||
|
|||||||
@@ -5,20 +5,46 @@ import { defaultCover } from '@libs/defaultCover';
|
|||||||
import { NovelStatus } from '@libs/novelStatus';
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const challengeTitles = new Set([
|
||||||
|
'bot verification',
|
||||||
|
'you are being redirected...',
|
||||||
|
'un instant...',
|
||||||
|
'just a moment...',
|
||||||
|
'redirecting...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function fetchCheckedHtml(
|
||||||
|
url: string,
|
||||||
|
init?: Parameters<typeof fetchApi>[1],
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetchApi(url, init);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status} while loading ${url}`);
|
||||||
|
const html = await response.text();
|
||||||
|
const title = html
|
||||||
|
.match(/<title[^>]*>(.*?)<\/title>/is)?.[1]
|
||||||
|
?.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (title && challengeTitles.has(title))
|
||||||
|
throw new Error(`Bot challenge while loading ${url}`);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
class HarkenEliwoodPlugin implements Plugin.PluginBase {
|
class HarkenEliwoodPlugin implements Plugin.PluginBase {
|
||||||
id = 'harkeneliwood';
|
id = 'harkeneliwood';
|
||||||
name = 'HarkenEliwood';
|
name = 'HarkenEliwood';
|
||||||
icon = 'src/fr/harkeneliwood/icon.png';
|
icon = 'src/fr/harkeneliwood/icon.png';
|
||||||
site = 'https://harkeneliwood.wordpress.com';
|
site = 'https://harkeneliwood.wordpress.com';
|
||||||
version = '1.0.0';
|
version = '1.0.3';
|
||||||
|
|
||||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||||
const r = await fetchApi(url, {
|
return load(
|
||||||
headers: { 'Accept-Encoding': 'deflate' },
|
await fetchCheckedHtml(url, {
|
||||||
});
|
headers: { 'Accept-Encoding': 'deflate' },
|
||||||
const body = await r.text();
|
}),
|
||||||
const $ = load(body);
|
);
|
||||||
return $;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||||
@@ -43,6 +69,14 @@ class HarkenEliwoodPlugin implements Plugin.PluginBase {
|
|||||||
novels.push(novel);
|
novels.push(novel);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await Promise.all(
|
||||||
|
novels.map(async item => {
|
||||||
|
const detail = await this.getCheerio(this.site + item.path);
|
||||||
|
item.cover =
|
||||||
|
detail('#content .entry-content p img').first().attr('src') ||
|
||||||
|
defaultCover;
|
||||||
|
}),
|
||||||
|
);
|
||||||
return novels;
|
return novels;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +92,7 @@ class HarkenEliwoodPlugin implements Plugin.PluginBase {
|
|||||||
$('#content .entry-content p img').first().attr('src') || defaultCover;
|
$('#content .entry-content p img').first().attr('src') || defaultCover;
|
||||||
novel.summary = this.getSummary($('#content .entry-content').text());
|
novel.summary = this.getSummary($('#content .entry-content').text());
|
||||||
novel.author = this.getAuthor($('#content .entry-content').text());
|
novel.author = this.getAuthor($('#content .entry-content').text());
|
||||||
novel.status = NovelStatus.Ongoing;
|
novel.status = NovelStatus.Unknown;
|
||||||
const chapters: Plugin.ChapterItem[] = [];
|
const chapters: Plugin.ChapterItem[] = [];
|
||||||
$('#content .entry-content p a').each((i, elem) => {
|
$('#content .entry-content p a').each((i, elem) => {
|
||||||
const chapterName = $(elem).text().trim();
|
const chapterName = $(elem).text().trim();
|
||||||
@@ -67,7 +101,7 @@ class HarkenEliwoodPlugin implements Plugin.PluginBase {
|
|||||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||||
const releaseDate = dayjs(
|
const releaseDate = dayjs(
|
||||||
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
||||||
).format('DD MMMM YYYY');
|
).format('YYYY-MM-DD');
|
||||||
chapters.push({
|
chapters.push({
|
||||||
name: chapterName,
|
name: chapterName,
|
||||||
path: chapterUrl.replace(this.site, ''),
|
path: chapterUrl.replace(this.site, ''),
|
||||||
@@ -133,6 +167,7 @@ class HarkenEliwoodPlugin implements Plugin.PluginBase {
|
|||||||
const $ = await this.getCheerio(this.site + chapterPath);
|
const $ = await this.getCheerio(this.site + chapterPath);
|
||||||
const title = $('h1.entry-title');
|
const title = $('h1.entry-title');
|
||||||
const chapter = $('div.entry-content');
|
const chapter = $('div.entry-content');
|
||||||
|
chapter.find('script, style, ins, iframe, .ads').remove();
|
||||||
return (title.html() || '') + (chapter.html() || '');
|
return (title.html() || '') + (chapter.html() || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
import { defaultCover } from '@libs/defaultCover';
|
||||||
|
import { fetchApi } from '@libs/fetch';
|
||||||
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
|
import { Plugin } from '@/types/plugin';
|
||||||
|
|
||||||
|
type WordPressPage = {
|
||||||
|
slug: string;
|
||||||
|
link: string;
|
||||||
|
title: { rendered: string };
|
||||||
|
content: { rendered: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const chapterSlug =
|
||||||
|
/(?:chapitre|prologue|epilogue|interlude|bonus|postface|preface)/i;
|
||||||
|
|
||||||
|
class JGardenPlugin implements Plugin.PluginBase {
|
||||||
|
id = 'jgarden';
|
||||||
|
name = 'J-Garden';
|
||||||
|
icon = 'src/fr/jgarden/icon.png';
|
||||||
|
site = 'https://j-garden.fr/';
|
||||||
|
version = '1.0.5';
|
||||||
|
|
||||||
|
resolveUrl(path: string): string {
|
||||||
|
const url = new URL(path, this.site);
|
||||||
|
if (url.origin !== new URL(this.site).origin)
|
||||||
|
throw new Error('Cannot resolve a foreign origin');
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private slugFromLink(link: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const url = new URL(link, this.site);
|
||||||
|
if (url.origin !== new URL(this.site).origin) return undefined;
|
||||||
|
const parts = url.pathname.split('/').filter(Boolean);
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getJson<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetchApi(this.resolveUrl(path));
|
||||||
|
if (!response.ok) throw new Error(`Failed to load ${path}`);
|
||||||
|
if (!/[/+]json\b/i.test(response.headers.get('content-type') || ''))
|
||||||
|
throw new Error(`Expected a JSON response for ${path}`);
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCatalogue(html: string): Plugin.NovelItem[] {
|
||||||
|
const $ = load(html);
|
||||||
|
const novels = new Map<string, Plugin.NovelItem>();
|
||||||
|
$('a[href]').each((_, element) => {
|
||||||
|
const href = $(element).attr('href');
|
||||||
|
const path = href ? this.slugFromLink(href) : undefined;
|
||||||
|
const image = $(element).find('img').first();
|
||||||
|
const name =
|
||||||
|
$(element).text().trim() ||
|
||||||
|
image.attr('alt')?.trim() ||
|
||||||
|
this.nameFromSlug(path || '');
|
||||||
|
const coverSrc = image.attr('src');
|
||||||
|
const width = Number(image.attr('width'));
|
||||||
|
const height = Number(image.attr('height'));
|
||||||
|
// The catalogue renders wide series banners (e.g. 2567×487), not the
|
||||||
|
// portrait book covers. A landscape image zoomed into a portrait card
|
||||||
|
// looks broken, so fall back to the default cover instead.
|
||||||
|
const isBanner =
|
||||||
|
Number.isFinite(width) && Number.isFinite(height) && width > height;
|
||||||
|
const cover =
|
||||||
|
coverSrc && !isBanner ? this.resolveUrl(coverSrc) : defaultCover;
|
||||||
|
if (path && name) novels.set(path, { name, path, cover });
|
||||||
|
});
|
||||||
|
return Array.from(novels.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalogue only carries wide series banners; the actual portrait book
|
||||||
|
// cover lives on each novel's own page. Fetch it there so the list shows a
|
||||||
|
// real cover instead of a zoomed banner or the fallback placeholder.
|
||||||
|
private async fetchCover(slug: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const pages = await this.getJson<{ content: { rendered: string } }[]>(
|
||||||
|
`/wp-json/wp/v2/pages?slug=${encodeURIComponent(slug)}&_fields=content`,
|
||||||
|
);
|
||||||
|
const content = pages[0]?.content?.rendered;
|
||||||
|
if (!content) return defaultCover;
|
||||||
|
const $ = load(content);
|
||||||
|
let cover = '';
|
||||||
|
$('img').each((_, element) => {
|
||||||
|
if (cover) return;
|
||||||
|
const src = $(element).attr('src');
|
||||||
|
if (!src) return;
|
||||||
|
const width = Number($(element).attr('width'));
|
||||||
|
const height = Number($(element).attr('height'));
|
||||||
|
const portrait =
|
||||||
|
!Number.isFinite(width) ||
|
||||||
|
!Number.isFinite(height) ||
|
||||||
|
height >= width;
|
||||||
|
if (portrait) cover = this.resolveUrl(src);
|
||||||
|
});
|
||||||
|
return cover || defaultCover;
|
||||||
|
} catch {
|
||||||
|
return defaultCover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nameFromSlug(slug: string): string {
|
||||||
|
return slug
|
||||||
|
.split('-')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private chapterSequence(name: string, path: string) {
|
||||||
|
const source = `${name} ${path}`;
|
||||||
|
const volume = Number(
|
||||||
|
source.match(/(?:tome|volume|vol\.?|v|t)[\s_-]*(\d+)/i)?.[1] || 0,
|
||||||
|
);
|
||||||
|
// Within each volume: preface, prologue, numbered chapters, interlude,
|
||||||
|
// bonus, epilogue, then postface. Unrecognised links retain DOM order.
|
||||||
|
const specialKinds = [
|
||||||
|
['preface', 0],
|
||||||
|
['prologue', 1],
|
||||||
|
['interlude', 3],
|
||||||
|
['bonus', 4],
|
||||||
|
['epilogue', 5],
|
||||||
|
['postface', 6],
|
||||||
|
] as const;
|
||||||
|
const special = specialKinds.find(([label]) =>
|
||||||
|
new RegExp(`(?:^|[\\s_-])${label}(?:$|[\\s_-])`, 'i').test(source),
|
||||||
|
);
|
||||||
|
const chapter = source.match(
|
||||||
|
/(?:chapitre|chapter|ch\.?)[\s_-]*(\d+(?:[.,]\d+)?)/i,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
volume,
|
||||||
|
kind: special?.[1] ?? (chapter ? 2 : 7),
|
||||||
|
chapter: Number(chapter?.[1].replace(',', '.') || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||||
|
if (pageNo > 1) return [];
|
||||||
|
const sections = await Promise.allSettled(
|
||||||
|
['jg-ln', 'jg-web-novel'].map(async section => {
|
||||||
|
const pages = await this.getJson<unknown>(
|
||||||
|
`/wp-json/wp/v2/pages?slug=${section}&_fields=content`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Array.isArray(pages) ||
|
||||||
|
!pages.every(
|
||||||
|
page =>
|
||||||
|
page !== null &&
|
||||||
|
typeof page === 'object' &&
|
||||||
|
(page as Record<string, unknown>).content !== null &&
|
||||||
|
typeof (page as Record<string, unknown>).content === 'object' &&
|
||||||
|
typeof (page as { content: Record<string, unknown> }).content
|
||||||
|
.rendered === 'string',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error(`Invalid catalogue section: ${section}`);
|
||||||
|
return pages as Pick<WordPressPage, 'content'>[];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const catalogues = sections.flatMap(section =>
|
||||||
|
section.status === 'fulfilled' ? [section.value] : [],
|
||||||
|
);
|
||||||
|
if (!catalogues.length) throw new Error('Failed to load catalogue');
|
||||||
|
const novels = new Map<string, Plugin.NovelItem>();
|
||||||
|
for (const section of catalogues.flat()) {
|
||||||
|
for (const novel of this.parseCatalogue(section.content.rendered)) {
|
||||||
|
novels.set(novel.path, novel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const list = Array.from(novels.values());
|
||||||
|
const covers = await Promise.allSettled(
|
||||||
|
list.map(novel => this.fetchCover(novel.path)),
|
||||||
|
);
|
||||||
|
return list.map((novel, index) => ({
|
||||||
|
...novel,
|
||||||
|
cover:
|
||||||
|
covers[index]?.status === 'fulfilled'
|
||||||
|
? covers[index].value
|
||||||
|
: defaultCover,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||||
|
const novelUrl = this.resolveUrl(novelPath);
|
||||||
|
const slug = this.slugFromLink(novelUrl) || novelPath;
|
||||||
|
const pages = await this.getJson<WordPressPage[]>(
|
||||||
|
`/wp-json/wp/v2/pages?slug=${encodeURIComponent(slug)}&_fields=slug,link,title,content`,
|
||||||
|
);
|
||||||
|
const page = pages[0];
|
||||||
|
if (!page) throw new Error('Novel not found');
|
||||||
|
|
||||||
|
const $ = load(page.content.rendered);
|
||||||
|
const chapters = new Map<
|
||||||
|
string,
|
||||||
|
{ chapter: Plugin.ChapterItem; index: number }
|
||||||
|
>();
|
||||||
|
const firstChapter = $('a[href]')
|
||||||
|
.filter((_, element) => {
|
||||||
|
const href = $(element).attr('href');
|
||||||
|
const chapterPath = href ? this.slugFromLink(href) : undefined;
|
||||||
|
return Boolean(chapterPath && chapterSlug.test(chapterPath));
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
|
||||||
|
$('a[href]').each((index, element) => {
|
||||||
|
const href = $(element).attr('href');
|
||||||
|
const path = href ? this.slugFromLink(href) : undefined;
|
||||||
|
const name = $(element).text().trim();
|
||||||
|
if (path && name && chapterSlug.test(path) && !chapters.has(path)) {
|
||||||
|
chapters.set(path, { chapter: { name, path }, index });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const details = $('body *')
|
||||||
|
.map((_, element) => $(element).text().trim())
|
||||||
|
.get()
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
const status =
|
||||||
|
/\b(?:termin(?:é|ée|e)|completed|complete|fini)(?![A-Za-zÀ-ÖØ-öø-ÿ0-9_])/i.test(
|
||||||
|
details,
|
||||||
|
)
|
||||||
|
? NovelStatus.Completed
|
||||||
|
: /\b(?:hiatus|en pause|pause)\b/i.test(details)
|
||||||
|
? NovelStatus.OnHiatus
|
||||||
|
: /\b(?:en cours|ongoing|publication)\b/i.test(details)
|
||||||
|
? NovelStatus.Ongoing
|
||||||
|
: NovelStatus.Unknown;
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: page.slug,
|
||||||
|
name: load(page.title.rendered).text().trim(),
|
||||||
|
cover: $('img').first().attr('src')
|
||||||
|
? this.resolveUrl($('img').first().attr('src')!)
|
||||||
|
: defaultCover,
|
||||||
|
summary: firstChapter.prevAll().text().trim(),
|
||||||
|
status,
|
||||||
|
chapters: (() => {
|
||||||
|
const items = Array.from(chapters.values()).map(entry => ({
|
||||||
|
...entry,
|
||||||
|
sequence: this.chapterSequence(
|
||||||
|
entry.chapter.name,
|
||||||
|
entry.chapter.path,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
// When only some chapters carry a volume marker (e.g. v1/v2 books
|
||||||
|
// alongside untagged side stories), the site's DOM order is the
|
||||||
|
// reading order; keep it instead of interleaving by chapter number.
|
||||||
|
const mixedVolumes =
|
||||||
|
items.some(item => item.sequence.volume > 0) &&
|
||||||
|
items.some(item => item.sequence.volume === 0);
|
||||||
|
items.sort((left, right) => {
|
||||||
|
if (mixedVolumes) return left.index - right.index;
|
||||||
|
return (
|
||||||
|
left.sequence.volume - right.sequence.volume ||
|
||||||
|
left.sequence.kind - right.sequence.kind ||
|
||||||
|
left.sequence.chapter - right.sequence.chapter ||
|
||||||
|
left.index - right.index
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return items.map(({ chapter }, index) => ({
|
||||||
|
...chapter,
|
||||||
|
chapterNumber: index + 1,
|
||||||
|
}));
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseChapter(chapterPath: string): Promise<string> {
|
||||||
|
const chapterUrl = this.resolveUrl(chapterPath);
|
||||||
|
const slug = this.slugFromLink(chapterUrl) || chapterPath;
|
||||||
|
let posts = await this.getJson<
|
||||||
|
Pick<WordPressPage, 'content' | 'title' | 'link'>[]
|
||||||
|
>(
|
||||||
|
`/wp-json/wp/v2/posts?slug=${encodeURIComponent(slug)}&_fields=content,title,link`,
|
||||||
|
);
|
||||||
|
if (posts.length === 0) {
|
||||||
|
const resolved = await fetchApi(this.resolveUrl(chapterPath));
|
||||||
|
const canonicalSlug = this.slugFromLink(resolved.url);
|
||||||
|
if (canonicalSlug && canonicalSlug !== slug) {
|
||||||
|
posts = await this.getJson(
|
||||||
|
`/wp-json/wp/v2/posts?slug=${encodeURIComponent(canonicalSlug)}&_fields=content,title,link`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const post = posts[0];
|
||||||
|
if (!post) throw new Error('Chapter not found');
|
||||||
|
|
||||||
|
const $ = load(post.content.rendered);
|
||||||
|
const content = $('.elementor-widget-theme-post-content').first();
|
||||||
|
const chapter = content.length ? content : $('body');
|
||||||
|
chapter
|
||||||
|
.find(
|
||||||
|
'script, style, nav, .sharedaddy, .share, [class*="share"], [id*="share"]',
|
||||||
|
)
|
||||||
|
.remove();
|
||||||
|
chapter
|
||||||
|
.find('*')
|
||||||
|
.filter((_, element) => !$(element).text().trim())
|
||||||
|
.remove();
|
||||||
|
const html = chapter.html()?.trim() || '';
|
||||||
|
if (chapter.text().trim().length < 200)
|
||||||
|
throw new Error('No readable chapter content found');
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchNovels(
|
||||||
|
searchTerm: string,
|
||||||
|
pageNo: number,
|
||||||
|
): Promise<Plugin.NovelItem[]> {
|
||||||
|
if (pageNo !== 1) return [];
|
||||||
|
const normalize = (value: string) =>
|
||||||
|
value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
const query = normalize(searchTerm);
|
||||||
|
return (await this.popularNovels(1)).filter(novel =>
|
||||||
|
normalize(novel.name).includes(query),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new JGardenPlugin();
|
||||||
+110
-38
@@ -4,18 +4,65 @@ import { Plugin } from '@/types/plugin';
|
|||||||
import { defaultCover } from '@libs/defaultCover';
|
import { defaultCover } from '@libs/defaultCover';
|
||||||
import { NovelStatus } from '@libs/novelStatus';
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
|
|
||||||
|
const challengeTitles = new Set([
|
||||||
|
'bot verification',
|
||||||
|
'you are being redirected...',
|
||||||
|
'un instant...',
|
||||||
|
'just a moment...',
|
||||||
|
'redirecting...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function fetchCheckedHtml(
|
||||||
|
url: string,
|
||||||
|
init?: Parameters<typeof fetchApi>[1],
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetchApi(url, init);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status} while loading ${url}`);
|
||||||
|
const html = await response.text();
|
||||||
|
const title = html
|
||||||
|
.match(/<title[^>]*>(.*?)<\/title>/is)?.[1]
|
||||||
|
?.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (title && challengeTitles.has(title))
|
||||||
|
throw new Error(`Bot challenge while loading ${url}`);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
class KissWoodPlugin implements Plugin.PluginBase {
|
class KissWoodPlugin implements Plugin.PluginBase {
|
||||||
id = 'kisswood';
|
id = 'kisswood';
|
||||||
name = 'KissWood';
|
name = 'KissWood';
|
||||||
icon = 'src/fr/kisswood/icon.png';
|
icon = 'src/fr/kisswood/icon.png';
|
||||||
site = 'https://kisswood.eu';
|
site = 'https://kisswood.eu';
|
||||||
version = '1.0.0';
|
version = '1.0.3';
|
||||||
|
|
||||||
|
private async findMovedChapter(chapterPath: string): Promise<string | null> {
|
||||||
|
const slug = chapterPath.split('/').filter(Boolean).pop() || '';
|
||||||
|
const match = slug.match(/^(.*?)-chapitre-(\d+)/i);
|
||||||
|
if (!match) return null;
|
||||||
|
const query = `${match[1].replace(/-/g, ' ')} chapitre ${match[2]}`;
|
||||||
|
const response = await fetchApi(
|
||||||
|
`${this.site}/wp-json/wp/v2/search?search=${encodeURIComponent(query)}&type=post&subtype=post&per_page=20`,
|
||||||
|
);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const results = (await response.json()) as {
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
}[];
|
||||||
|
const replacement = results.find(result =>
|
||||||
|
new RegExp(`chapitre\\D*${match[2]}(?:\\D|$)`, 'i').test(
|
||||||
|
result.title || '',
|
||||||
|
),
|
||||||
|
)?.url;
|
||||||
|
if (!replacement) return null;
|
||||||
|
const url = new URL(replacement, this.site);
|
||||||
|
return url.origin === new URL(this.site).origin ? url.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||||
const r = await fetchApi(url);
|
return load(await fetchCheckedHtml(url));
|
||||||
const body = await r.text();
|
|
||||||
const $ = load(body);
|
|
||||||
return $;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNovelsCovers(
|
async getNovelsCovers(
|
||||||
@@ -126,7 +173,7 @@ class KissWoodPlugin implements Plugin.PluginBase {
|
|||||||
let novel: Plugin.SourceNovel = {
|
let novel: Plugin.SourceNovel = {
|
||||||
path: novelPath,
|
path: novelPath,
|
||||||
name: 'Sans titre',
|
name: 'Sans titre',
|
||||||
status: NovelStatus.Ongoing,
|
status: NovelStatus.Unknown,
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = await this.getCheerio(this.site + novelPath);
|
const $ = await this.getCheerio(this.site + novelPath);
|
||||||
@@ -153,6 +200,7 @@ class KissWoodPlugin implements Plugin.PluginBase {
|
|||||||
].join(', ');
|
].join(', ');
|
||||||
|
|
||||||
const chapters: Plugin.ChapterItem[] = [];
|
const chapters: Plugin.ChapterItem[] = [];
|
||||||
|
const chapterPaths = new Set<string>();
|
||||||
$(chapterSelectors).each((i, elem) => {
|
$(chapterSelectors).each((i, elem) => {
|
||||||
const chapterName = $(elem).text().trim();
|
const chapterName = $(elem).text().trim();
|
||||||
const chapterUrl = $(elem).attr('href')?.replace('http://', 'https://');
|
const chapterUrl = $(elem).attr('href')?.replace('http://', 'https://');
|
||||||
@@ -164,13 +212,14 @@ class KissWoodPlugin implements Plugin.PluginBase {
|
|||||||
!chapterUrl.includes('share=facebook') &&
|
!chapterUrl.includes('share=facebook') &&
|
||||||
!chapterUrl.includes('share=x') &&
|
!chapterUrl.includes('share=x') &&
|
||||||
!chapterUrl.includes('/category/traductions/') &&
|
!chapterUrl.includes('/category/traductions/') &&
|
||||||
!chapterUrl.includes('/category/tour-des-mondes/') &&
|
!chapterUrl.includes('/category/tour-des-mondes/')
|
||||||
// Removal of duplicates
|
|
||||||
!chapters.some(chapter => this.site + chapter.path === chapterUrl)
|
|
||||||
) {
|
) {
|
||||||
|
const path = chapterUrl.replace(this.site, '');
|
||||||
|
if (chapterPaths.has(path)) return;
|
||||||
|
chapterPaths.add(path);
|
||||||
chapters.push({
|
chapters.push({
|
||||||
name: chapterName,
|
name: chapterName,
|
||||||
path: chapterUrl.replace(this.site, ''),
|
path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -179,35 +228,58 @@ class KissWoodPlugin implements Plugin.PluginBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async parseChapter(chapterPath: string): Promise<string> {
|
async parseChapter(chapterPath: string): Promise<string> {
|
||||||
const $ = await this.getCheerio(this.site + chapterPath);
|
let body: string;
|
||||||
|
try {
|
||||||
const elements: string[] = $('.entry-content')
|
body = await fetchCheckedHtml(this.site + chapterPath);
|
||||||
.contents()
|
} catch (error) {
|
||||||
.map((_, el) => $.html(el))
|
const replacement = await this.findMovedChapter(chapterPath);
|
||||||
.get();
|
if (!replacement) throw error;
|
||||||
|
body = await fetchCheckedHtml(replacement);
|
||||||
let hrIndexes: number[] = elements
|
|
||||||
.map((elem, index) => (elem.includes('<hr>') ? index : -1))
|
|
||||||
.filter(index => index !== -1);
|
|
||||||
|
|
||||||
if (hrIndexes.length === 0) {
|
|
||||||
hrIndexes = [
|
|
||||||
0,
|
|
||||||
elements.findIndex(
|
|
||||||
element =>
|
|
||||||
element.includes('https://fr.tipeee.com/kisswood/') ||
|
|
||||||
element.includes('>Sommaire</a>') ||
|
|
||||||
element.includes('>Chapitre Suivant</a>') ||
|
|
||||||
element.includes('———————————————————————————-') ||
|
|
||||||
element.includes('share=facebook'),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
} else if (hrIndexes.length === 1) {
|
|
||||||
hrIndexes.unshift(0);
|
|
||||||
} else {
|
|
||||||
hrIndexes[0] += 1;
|
|
||||||
}
|
}
|
||||||
return elements.slice(hrIndexes[0], hrIndexes[1]).join('\n');
|
|
||||||
|
const $ = load(body);
|
||||||
|
const chapter = $('.entry-content').first().clone();
|
||||||
|
chapter
|
||||||
|
.find(
|
||||||
|
'script, style, ins, iframe, .ads, .sharedaddy, [class*="sharing"], [id*="sharing"]',
|
||||||
|
)
|
||||||
|
.remove();
|
||||||
|
|
||||||
|
const elements = chapter
|
||||||
|
.contents()
|
||||||
|
.map((_, element) => $.html(element))
|
||||||
|
.get();
|
||||||
|
const separators = elements
|
||||||
|
.map((element, index) => (/<hr\b/i.test(element) ? index : -1))
|
||||||
|
.filter(index => index !== -1);
|
||||||
|
const markerIndex = elements.findIndex(
|
||||||
|
element =>
|
||||||
|
!/<img\b/i.test(element) &&
|
||||||
|
[
|
||||||
|
'https://fr.tipeee.com/kisswood/',
|
||||||
|
'>Sommaire</a>',
|
||||||
|
'>Chapitre Suivant</a>',
|
||||||
|
'———————————————————————————-',
|
||||||
|
'share=facebook',
|
||||||
|
].some(marker => element.includes(marker)),
|
||||||
|
);
|
||||||
|
const start = separators.length > 1 ? separators[0] + 1 : 0;
|
||||||
|
const end =
|
||||||
|
separators.length > 1
|
||||||
|
? separators[1]
|
||||||
|
: separators.length === 1
|
||||||
|
? separators[0]
|
||||||
|
: markerIndex >= 0
|
||||||
|
? markerIndex
|
||||||
|
: elements.length;
|
||||||
|
const content = elements.slice(start, end).join('\n');
|
||||||
|
const parsedContent = load(content);
|
||||||
|
if (
|
||||||
|
parsedContent.text().replace(/\s+/g, ' ').trim().length < 200 &&
|
||||||
|
!parsedContent('img').length
|
||||||
|
)
|
||||||
|
throw new Error('No readable chapter content found');
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchNovels(
|
async searchNovels(
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
import { defaultCover } from '@libs/defaultCover';
|
||||||
|
import { fetchApi } from '@libs/fetch';
|
||||||
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
|
import { Plugin } from '@/types/plugin';
|
||||||
|
|
||||||
|
type ChapterEntry = {
|
||||||
|
number: string;
|
||||||
|
slug: string;
|
||||||
|
name_fr?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChapterPage = {
|
||||||
|
chapters: ChapterEntry[];
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class LightNovelVFPlugin implements Plugin.PagePlugin {
|
||||||
|
id = 'lightnovelvf';
|
||||||
|
name = 'LightNovelVF';
|
||||||
|
icon = 'src/fr/lightnovelvf/icon.png';
|
||||||
|
site = 'https://www.lightnovelvf.com/';
|
||||||
|
version = '1.0.4';
|
||||||
|
|
||||||
|
resolveUrl(path: string, _isNovel = false): string {
|
||||||
|
void _isNovel;
|
||||||
|
const url = new URL(path, this.site);
|
||||||
|
if (url.origin !== new URL(this.site).origin)
|
||||||
|
throw new Error('Cannot resolve a foreign origin');
|
||||||
|
const cleanPath = url.pathname
|
||||||
|
.replace(/^\/+|\/+$/g, '')
|
||||||
|
.replace(/^novel\//, '');
|
||||||
|
return new URL(`/novel/${cleanPath}`, this.site).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchHtml(url: string): Promise<string> {
|
||||||
|
const response = await fetchApi(url);
|
||||||
|
if (!response.ok) throw new Error(`Failed to load ${url}`);
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
private retryDelay(response: Response | null, attempt: number): number {
|
||||||
|
const retryAfter = response?.headers.get('retry-after');
|
||||||
|
if (retryAfter) {
|
||||||
|
const seconds = Number(retryAfter);
|
||||||
|
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
||||||
|
const date = Date.parse(retryAfter);
|
||||||
|
if (!Number.isNaN(date)) return Math.max(date - Date.now(), 0);
|
||||||
|
}
|
||||||
|
return 1000 * 2 ** attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isChapterPageMetadata(
|
||||||
|
value: unknown,
|
||||||
|
requestedPage: number,
|
||||||
|
): value is Omit<ChapterPage, 'chapters'> & { chapters: unknown[] } {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const page = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
Array.isArray(page.chapters) &&
|
||||||
|
typeof page.current_page === 'number' &&
|
||||||
|
Number.isInteger(page.current_page) &&
|
||||||
|
page.current_page > 0 &&
|
||||||
|
page.current_page === requestedPage &&
|
||||||
|
typeof page.last_page === 'number' &&
|
||||||
|
Number.isInteger(page.last_page) &&
|
||||||
|
page.last_page >= page.current_page &&
|
||||||
|
typeof page.total === 'number' &&
|
||||||
|
Number.isInteger(page.total) &&
|
||||||
|
page.total >= 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isChapterEntry(value: unknown): value is ChapterEntry {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const chapter = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof chapter.number === 'string' &&
|
||||||
|
chapter.number.trim().length > 0 &&
|
||||||
|
Number.isFinite(Number(chapter.number)) &&
|
||||||
|
typeof chapter.slug === 'string' &&
|
||||||
|
chapter.slug.trim().length > 0 &&
|
||||||
|
(chapter.name_fr === undefined ||
|
||||||
|
chapter.name_fr === null ||
|
||||||
|
typeof chapter.name_fr === 'string') &&
|
||||||
|
(chapter.name === undefined ||
|
||||||
|
chapter.name === null ||
|
||||||
|
typeof chapter.name === 'string') &&
|
||||||
|
(chapter.created_at === undefined ||
|
||||||
|
chapter.created_at === null ||
|
||||||
|
typeof chapter.created_at === 'string')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchChapterPage(
|
||||||
|
url: string,
|
||||||
|
requestedPage: number,
|
||||||
|
): Promise<ChapterPage> {
|
||||||
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetchApi(url);
|
||||||
|
} catch {
|
||||||
|
if (attempt === 5) throw new Error(`Failed to load ${url}`);
|
||||||
|
await new Promise(resolve =>
|
||||||
|
setTimeout(resolve, this.retryDelay(null, attempt)),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(response.status === 429 ||
|
||||||
|
(response.status >= 500 && response.status < 600)) &&
|
||||||
|
attempt < 5
|
||||||
|
) {
|
||||||
|
await new Promise(resolve =>
|
||||||
|
setTimeout(resolve, this.retryDelay(response, attempt)),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error(`Failed to load ${url}`);
|
||||||
|
if (!response.headers.get('content-type')?.includes('application/json'))
|
||||||
|
throw new Error(`Expected JSON chapter page from ${url}`);
|
||||||
|
const page: unknown = await response.json();
|
||||||
|
if (!this.isChapterPageMetadata(page, requestedPage))
|
||||||
|
throw new Error(`Invalid chapter page from ${url}`);
|
||||||
|
if (!page.chapters.every(chapter => this.isChapterEntry(chapter)))
|
||||||
|
throw new Error(`Invalid chapter entry from ${url}`);
|
||||||
|
return page as ChapterPage;
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to load ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCards(html: string): Plugin.NovelItem[] {
|
||||||
|
const $ = load(html);
|
||||||
|
const novels = new Map<string, Plugin.NovelItem>();
|
||||||
|
$('a[href^="/novel/"]').each((_, element) => {
|
||||||
|
const href = $(element).attr('href') || '';
|
||||||
|
const match = href.match(/^\/novel\/([^/?#]+)\/?$/);
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
|
const card = $(element).clone();
|
||||||
|
card.find('img').remove();
|
||||||
|
card
|
||||||
|
.find('*')
|
||||||
|
.filter((_, child) => {
|
||||||
|
const text = $(child).text().trim();
|
||||||
|
return (
|
||||||
|
/\b\d[\d\s,.]*\s*(?:chapitres?|chapters?|ch\.?)(?:\s|$)/i.test(
|
||||||
|
text,
|
||||||
|
) ||
|
||||||
|
/^(?:note|rating\s*:?)?\s*\d(?:[.,]\d+)?\s*(?:\/\s*5)?$/i.test(text)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.remove();
|
||||||
|
const name = card.text().replace(/\s+/g, ' ').trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const cover =
|
||||||
|
$(element).find('img').first().attr('data-src') ||
|
||||||
|
$(element).find('img').first().attr('src');
|
||||||
|
novels.set(match[1], {
|
||||||
|
name,
|
||||||
|
path: match[1],
|
||||||
|
cover: cover ? new URL(cover, this.site).toString() : defaultCover,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(novels.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private catalogueUrl(
|
||||||
|
pageNo: number,
|
||||||
|
searchTerm?: string,
|
||||||
|
latest = false,
|
||||||
|
): string {
|
||||||
|
const parts = [`page=${pageNo}`];
|
||||||
|
if (searchTerm) parts.push(`search=${encodeURIComponent(searchTerm)}`);
|
||||||
|
if (latest) parts.push('sort=update', 'sort_dir=desc');
|
||||||
|
return new URL(`/novels-list?${parts.join('&')}`, this.site).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private labelledValue(html: string, label: string): string | undefined {
|
||||||
|
const $ = load(html);
|
||||||
|
let value: string | undefined;
|
||||||
|
$('dt, [class*="label" i]').each((_, element) => {
|
||||||
|
if (value || $(element).text().trim().toLowerCase() !== label) return;
|
||||||
|
value = $(element).next('dd').first().text().trim() || undefined;
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async popularNovels(
|
||||||
|
pageNo: number,
|
||||||
|
{ showLatestNovels }: Plugin.PopularNovelsOptions,
|
||||||
|
): Promise<Plugin.NovelItem[]> {
|
||||||
|
return this.parseCards(
|
||||||
|
await this.fetchHtml(
|
||||||
|
this.catalogueUrl(pageNo, undefined, Boolean(showLatestNovels)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchNovels(
|
||||||
|
searchTerm: string,
|
||||||
|
pageNo: number,
|
||||||
|
): Promise<Plugin.NovelItem[]> {
|
||||||
|
const normalizedSearchTerm = searchTerm.replace(/\\(?=%)/g, '');
|
||||||
|
return this.parseCards(
|
||||||
|
await this.fetchHtml(this.catalogueUrl(pageNo, normalizedSearchTerm)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private chapterItems(page: ChapterPage, slug: string): Plugin.ChapterItem[] {
|
||||||
|
const chapters = new Map<string, Plugin.ChapterItem>();
|
||||||
|
for (const chapter of page.chapters) {
|
||||||
|
const chapterNumber = Number(chapter.number);
|
||||||
|
if (!chapter.slug || !Number.isFinite(chapterNumber)) continue;
|
||||||
|
chapters.set(chapter.slug, {
|
||||||
|
name: chapter.name_fr || chapter.name || `Chapitre ${chapter.number}`,
|
||||||
|
path: `${slug}/${chapter.slug}`,
|
||||||
|
chapterNumber,
|
||||||
|
releaseTime: chapter.created_at || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from(chapters.values()).sort(
|
||||||
|
(left, right) => (left.chapterNumber || 0) - (right.chapterNumber || 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private chapterPageUrl(slug: string, pageNo: number): string {
|
||||||
|
return `${this.resolveUrl(`${slug}/chapitres`, true)}?p=${pageNo}&order=asc&q=`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseNovel(
|
||||||
|
novelPath: string,
|
||||||
|
): Promise<Plugin.SourceNovel & { totalPages: number }> {
|
||||||
|
const novelUrl = this.resolveUrl(novelPath, true);
|
||||||
|
const slug = new URL(novelUrl).pathname.replace(/^\/novel\//, '');
|
||||||
|
const html = await this.fetchHtml(novelUrl);
|
||||||
|
const $ = load(html);
|
||||||
|
const statusText =
|
||||||
|
this.labelledValue(html, 'statut') ||
|
||||||
|
$('span')
|
||||||
|
.filter((_, element) =>
|
||||||
|
/^(?:en\s+cours|terminé|complete|hiatus|pause)$/i.test(
|
||||||
|
$(element).text().trim(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
.text()
|
||||||
|
.trim();
|
||||||
|
const firstPage = await this.fetchChapterPage(
|
||||||
|
this.chapterPageUrl(slug, 1),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cover =
|
||||||
|
$('.lnv-novel-cover, .lnv-novel__cover, .lnv-hero img, .hero img')
|
||||||
|
.first()
|
||||||
|
.attr('src') || $('img').first().attr('src');
|
||||||
|
return {
|
||||||
|
path: slug,
|
||||||
|
name: $('h1').first().text().trim(),
|
||||||
|
cover: cover ? new URL(cover, this.site).toString() : defaultCover,
|
||||||
|
summary: $('.lnv-synopsis__body').first().text().trim() || undefined,
|
||||||
|
author:
|
||||||
|
this.labelledValue(html, 'auteur') ||
|
||||||
|
$('[itemprop="author"]').first().text().trim() ||
|
||||||
|
undefined,
|
||||||
|
genres:
|
||||||
|
this.labelledValue(html, 'catégories') ||
|
||||||
|
this.labelledValue(html, 'categories') ||
|
||||||
|
$('[itemprop="genre"]')
|
||||||
|
.map((_, element) => $(element).text().trim())
|
||||||
|
.get()
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ') ||
|
||||||
|
undefined,
|
||||||
|
status: /termin|complet/i.test(statusText)
|
||||||
|
? NovelStatus.Completed
|
||||||
|
: /hiatus|pause/i.test(statusText)
|
||||||
|
? NovelStatus.OnHiatus
|
||||||
|
: /cours|ongoing/i.test(statusText)
|
||||||
|
? NovelStatus.Ongoing
|
||||||
|
: NovelStatus.Unknown,
|
||||||
|
chapters: this.chapterItems(firstPage, slug),
|
||||||
|
totalPages: Math.max(firstPage.last_page, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async parsePage(novelPath: string, page: string): Promise<Plugin.SourcePage> {
|
||||||
|
const pageNo = Number(page);
|
||||||
|
if (!Number.isInteger(pageNo) || pageNo < 1)
|
||||||
|
throw new Error('Invalid page');
|
||||||
|
const novelUrl = this.resolveUrl(novelPath, true);
|
||||||
|
const slug = new URL(novelUrl).pathname.replace(/^\/novel\//, '');
|
||||||
|
const pageData = await this.fetchChapterPage(
|
||||||
|
this.chapterPageUrl(slug, pageNo),
|
||||||
|
pageNo,
|
||||||
|
);
|
||||||
|
return { chapters: this.chapterItems(pageData, slug) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseChapter(chapterPath: string): Promise<string> {
|
||||||
|
const html = await this.fetchHtml(this.resolveUrl(chapterPath));
|
||||||
|
const $ = load(html);
|
||||||
|
const content = $('.lnv-reader-content').first();
|
||||||
|
content
|
||||||
|
.find(
|
||||||
|
'script, style, header, footer, nav, form, [class*="nav" i], [id*="nav" i], [class*="advert" i], [id*="advert" i], [class*="share" i], [class*="control" i]',
|
||||||
|
)
|
||||||
|
.remove();
|
||||||
|
const chapter = content.html()?.trim() || '';
|
||||||
|
if (content.text().replace(/\s+/g, ' ').trim().length < 200)
|
||||||
|
throw new Error('No readable chapter content found');
|
||||||
|
return chapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new LightNovelVFPlugin();
|
||||||
@@ -5,21 +5,46 @@ import { NovelStatus } from '@libs/novelStatus';
|
|||||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
import { Filters, FilterTypes } from '@libs/filterInputs';
|
||||||
import { defaultCover } from '@libs/defaultCover';
|
import { defaultCover } from '@libs/defaultCover';
|
||||||
|
|
||||||
|
const challengeTitles = new Set([
|
||||||
|
'bot verification',
|
||||||
|
'you are being redirected...',
|
||||||
|
'un instant...',
|
||||||
|
'just a moment...',
|
||||||
|
'redirecting...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function fetchCheckedHtml(
|
||||||
|
url: string,
|
||||||
|
init?: Parameters<typeof fetchApi>[1],
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetchApi(url, init);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status} while loading ${url}`);
|
||||||
|
const html = await response.text();
|
||||||
|
const title = html
|
||||||
|
.match(/<title[^>]*>(.*?)<\/title>/is)?.[1]
|
||||||
|
?.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (title && challengeTitles.has(title))
|
||||||
|
throw new Error(`Bot challenge while loading ${url}`);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
class NovelDeGlacePlugin implements Plugin.PluginBase {
|
class NovelDeGlacePlugin implements Plugin.PluginBase {
|
||||||
id = 'noveldeglace';
|
id = 'noveldeglace';
|
||||||
name = 'NovelDeGlace';
|
name = 'NovelDeGlace';
|
||||||
icon = 'src/fr/noveldeglace/icon.png';
|
icon = 'src/fr/noveldeglace/icon.png';
|
||||||
site = 'https://noveldeglace.com/';
|
site = 'https://noveldeglace.com/';
|
||||||
version = '1.0.5';
|
version = '1.0.8';
|
||||||
|
|
||||||
async getCheerio(url: string): Promise<CheerioAPI | undefined> {
|
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||||
const r = await fetchApi(url, {
|
return load(
|
||||||
headers: { 'Accept-Encoding': 'deflate' },
|
await fetchCheckedHtml(url, {
|
||||||
});
|
headers: { 'Accept-Encoding': 'deflate' },
|
||||||
if (!r.ok) return undefined;
|
}),
|
||||||
const body = await r.text();
|
);
|
||||||
const loadedCheerio = load(body);
|
|
||||||
return loadedCheerio;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parseNovels(
|
parseNovels(
|
||||||
@@ -76,14 +101,11 @@ class NovelDeGlacePlugin implements Plugin.PluginBase {
|
|||||||
}
|
}
|
||||||
url += '/page/' + pageNo;
|
url += '/page/' + pageNo;
|
||||||
const $ = await this.getCheerio(url);
|
const $ = await this.getCheerio(url);
|
||||||
if (!$) return [];
|
|
||||||
return this.parseNovels($, showLatestNovels);
|
return this.parseNovels($, showLatestNovels);
|
||||||
}
|
}
|
||||||
|
|
||||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||||
const $ = await this.getCheerio(this.site + novelPath);
|
const $ = await this.getCheerio(this.site + novelPath);
|
||||||
if (!$) throw new Error('Failed to load page (open in web view)');
|
|
||||||
|
|
||||||
const novel: Plugin.SourceNovel = { path: novelPath, name: 'Untitled' };
|
const novel: Plugin.SourceNovel = { path: novelPath, name: 'Untitled' };
|
||||||
|
|
||||||
novel.name = $('span.current').text().trim();
|
novel.name = $('span.current').text().trim();
|
||||||
@@ -214,19 +236,26 @@ class NovelDeGlacePlugin implements Plugin.PluginBase {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
novel.chapters = novelChapters;
|
novel.chapters = novelChapters.map((chapter, index) => ({
|
||||||
|
...chapter,
|
||||||
|
chapterNumber: index,
|
||||||
|
}));
|
||||||
|
|
||||||
return novel;
|
return novel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async parseChapter(chapterPath: string): Promise<string> {
|
async parseChapter(chapterPath: string): Promise<string> {
|
||||||
const $ = await this.getCheerio(this.site + chapterPath);
|
const $ = await this.getCheerio(this.site + chapterPath);
|
||||||
if (!$) throw new Error('Failed to load page (open in web view)');
|
const chapter = $('.chapter-content').first().length
|
||||||
|
? $('.chapter-content').first()
|
||||||
$('.mistape_caption').remove();
|
: $('.entry-content').first();
|
||||||
const chapterText =
|
chapter.find('script, style, ins, iframe, .ads, .mistape_caption').remove();
|
||||||
$('.chapter-content').html() || $('.entry-content').html() || '';
|
if (
|
||||||
return chapterText;
|
chapter.text().replace(/\s+/g, ' ').trim().length < 200 &&
|
||||||
|
!chapter.find('img').length
|
||||||
|
)
|
||||||
|
throw new Error('No readable chapter content found');
|
||||||
|
return chapter.html() || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchNovels(
|
async searchNovels(
|
||||||
@@ -236,8 +265,6 @@ class NovelDeGlacePlugin implements Plugin.PluginBase {
|
|||||||
if (num !== 1) return []; // only 1 page of results
|
if (num !== 1) return []; // only 1 page of results
|
||||||
const url = this.site + 'roman';
|
const url = this.site + 'roman';
|
||||||
const $ = await this.getCheerio(url);
|
const $ = await this.getCheerio(url);
|
||||||
if (!$) throw new Error('Failed to load page (open in web view)');
|
|
||||||
|
|
||||||
let novels = this.parseNovels($, false);
|
let novels = this.parseNovels($, false);
|
||||||
|
|
||||||
novels = novels.filter(novel =>
|
novels = novels.filter(novel =>
|
||||||
|
|||||||
+47
-11
@@ -4,20 +4,46 @@ import { Plugin } from '@/types/plugin';
|
|||||||
import { defaultCover } from '@libs/defaultCover';
|
import { defaultCover } from '@libs/defaultCover';
|
||||||
import { NovelStatus } from '@libs/novelStatus';
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
|
|
||||||
|
const challengeTitles = new Set([
|
||||||
|
'bot verification',
|
||||||
|
'you are being redirected...',
|
||||||
|
'un instant...',
|
||||||
|
'just a moment...',
|
||||||
|
'redirecting...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function fetchCheckedHtml(
|
||||||
|
url: string,
|
||||||
|
init?: Parameters<typeof fetchApi>[1],
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetchApi(url, init);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status} while loading ${url}`);
|
||||||
|
const html = await response.text();
|
||||||
|
const title = html
|
||||||
|
.match(/<title[^>]*>(.*?)<\/title>/is)?.[1]
|
||||||
|
?.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (title && challengeTitles.has(title))
|
||||||
|
throw new Error(`Bot challenge while loading ${url}`);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
class NovhellPlugin implements Plugin.PluginBase {
|
class NovhellPlugin implements Plugin.PluginBase {
|
||||||
id = 'novhell';
|
id = 'novhell';
|
||||||
name = 'Novhell';
|
name = 'Novhell';
|
||||||
icon = 'src/fr/novhell/icon.png';
|
icon = 'src/fr/novhell/icon.png';
|
||||||
site = 'https://novhell.org';
|
site = 'https://novhell.org';
|
||||||
version = '1.0.1';
|
version = '1.0.4';
|
||||||
|
|
||||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||||
const r = await fetchApi(url, {
|
return load(
|
||||||
headers: { 'Accept-Encoding': 'deflate' },
|
await fetchCheckedHtml(url, {
|
||||||
});
|
headers: { 'Accept-Encoding': 'deflate' },
|
||||||
const body = await r.text();
|
}),
|
||||||
const $ = load(body);
|
);
|
||||||
return $;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||||
@@ -65,7 +91,7 @@ class NovhellPlugin implements Plugin.PluginBase {
|
|||||||
?.replace('- NovHell', '') || '';
|
?.replace('- NovHell', '') || '';
|
||||||
novel.cover =
|
novel.cover =
|
||||||
$('section div div div div div img').first().attr('src') || defaultCover;
|
$('section div div div div div img').first().attr('src') || defaultCover;
|
||||||
novel.status = NovelStatus.Ongoing;
|
novel.status = NovelStatus.Unknown;
|
||||||
novel.author = $("strong:contains('Ecrit par ')")
|
novel.author = $("strong:contains('Ecrit par ')")
|
||||||
.parent()
|
.parent()
|
||||||
.text()
|
.text()
|
||||||
@@ -107,6 +133,7 @@ class NovhellPlugin implements Plugin.PluginBase {
|
|||||||
.replace(':', '')
|
.replace(':', '')
|
||||||
.trim();
|
.trim();
|
||||||
const chapters: Plugin.ChapterItem[] = [];
|
const chapters: Plugin.ChapterItem[] = [];
|
||||||
|
const chapterPaths = new Set<string>();
|
||||||
|
|
||||||
$('main div article div div section div div div div div p a').each(
|
$('main div article div div section div div div div div p a').each(
|
||||||
(i, elem) => {
|
(i, elem) => {
|
||||||
@@ -118,6 +145,9 @@ class NovhellPlugin implements Plugin.PluginBase {
|
|||||||
const chapterUrl = $(elem).attr('href');
|
const chapterUrl = $(elem).attr('href');
|
||||||
// Check if the chapter URL exists and contains the site name.
|
// Check if the chapter URL exists and contains the site name.
|
||||||
if (chapterUrl && chapterUrl.includes(this.site)) {
|
if (chapterUrl && chapterUrl.includes(this.site)) {
|
||||||
|
const path = chapterUrl.replace(this.site, '');
|
||||||
|
if (chapterPaths.has(path)) return;
|
||||||
|
chapterPaths.add(path);
|
||||||
const regex = /Chapitre (\d+)/g;
|
const regex = /Chapitre (\d+)/g;
|
||||||
let chapterNumber = 0;
|
let chapterNumber = 0;
|
||||||
let match;
|
let match;
|
||||||
@@ -127,7 +157,7 @@ class NovhellPlugin implements Plugin.PluginBase {
|
|||||||
}
|
}
|
||||||
chapters.push({
|
chapters.push({
|
||||||
name: chapterName,
|
name: chapterName,
|
||||||
path: chapterUrl.replace(this.site, ''),
|
path,
|
||||||
chapterNumber: chapterNumber,
|
chapterNumber: chapterNumber,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -170,10 +200,16 @@ class NovhellPlugin implements Plugin.PluginBase {
|
|||||||
const chapter = sections.eq(numberOfSection - positionChapter);
|
const chapter = sections.eq(numberOfSection - positionChapter);
|
||||||
|
|
||||||
if (title && chapter) {
|
if (title && chapter) {
|
||||||
return (title.html() || '') + (chapter.html() || '');
|
const content = (title.html() || '') + (chapter.html() || '');
|
||||||
|
const parsedContent = load(content);
|
||||||
|
if (
|
||||||
|
parsedContent.text().replace(/\s+/g, ' ').trim().length >= 200 ||
|
||||||
|
parsedContent('img').length
|
||||||
|
)
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '';
|
throw new Error('No readable chapter content found');
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchNovels(
|
async searchNovels(
|
||||||
|
|||||||
@@ -1,275 +0,0 @@
|
|||||||
import { CheerioAPI, load } from 'cheerio';
|
|
||||||
import { fetchApi } from '@libs/fetch';
|
|
||||||
import { Plugin } from '@/types/plugin';
|
|
||||||
import { Filters, FilterTypes } from '@libs/filterInputs';
|
|
||||||
import { defaultCover } from '@libs/defaultCover';
|
|
||||||
import { NovelStatus } from '@libs/novelStatus';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
class PhenixScansTradPlugin implements Plugin.PluginBase {
|
|
||||||
id = 'phenixscans';
|
|
||||||
name = 'PhenixScans';
|
|
||||||
icon = 'src/fr/phenixscans/icon.png';
|
|
||||||
site = 'https://phenixscans.fr';
|
|
||||||
version = '1.0.1';
|
|
||||||
|
|
||||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
|
||||||
const r = await fetchApi(url);
|
|
||||||
const body = await r.text();
|
|
||||||
const $ = load(body);
|
|
||||||
return $;
|
|
||||||
}
|
|
||||||
|
|
||||||
async popularNovels(
|
|
||||||
pageNo: number,
|
|
||||||
{ showLatestNovels, filters }: Plugin.PopularNovelsOptions,
|
|
||||||
): Promise<Plugin.NovelItem[]> {
|
|
||||||
const novels: Plugin.NovelItem[] = [];
|
|
||||||
let novel: Plugin.NovelItem;
|
|
||||||
|
|
||||||
let filter = '';
|
|
||||||
for (const key in filters) {
|
|
||||||
if (typeof filters[key].value === 'object') {
|
|
||||||
for (const value of filters[key].value as string[]) {
|
|
||||||
filter += `&genre%5B%5D=${value}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = showLatestNovels ? 'update' : 'popular';
|
|
||||||
const url = `${this.site}/manga/?page=${pageNo}${filter}&status=&type=novel&order=${order}`;
|
|
||||||
|
|
||||||
const $ = await this.getCheerio(url);
|
|
||||||
$('div div div div a').each((i, elem) => {
|
|
||||||
const novelName = $(elem).attr('title')?.trim();
|
|
||||||
const novelUrl = $(elem).attr('href');
|
|
||||||
const novelCover = $(elem).find('div img').attr('src') || defaultCover;
|
|
||||||
|
|
||||||
if (novelUrl && novelName) {
|
|
||||||
novel = {
|
|
||||||
name: novelName,
|
|
||||||
cover: novelCover,
|
|
||||||
path: novelUrl.replace(this.site, ''),
|
|
||||||
};
|
|
||||||
novels.push(novel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return novels;
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
|
||||||
const novel: Plugin.SourceNovel = {
|
|
||||||
path: novelPath,
|
|
||||||
name: 'Sans titre',
|
|
||||||
};
|
|
||||||
|
|
||||||
const $ = await this.getCheerio(this.site + novelPath);
|
|
||||||
|
|
||||||
novel.name = $('h1[itemprop=name]').text().replace('– Novel', '').trim();
|
|
||||||
novel.cover =
|
|
||||||
$('div[itemprop=image] img').first().attr('src') || defaultCover;
|
|
||||||
novel.author = $('.fmed b:contains(Auteur)+span').text().trim();
|
|
||||||
novel.genres = $('.mgen a')
|
|
||||||
.map(function () {
|
|
||||||
return $(this).text();
|
|
||||||
})
|
|
||||||
.get()
|
|
||||||
.join(', ');
|
|
||||||
novel.summary = $('.entry-content[itemprop=description]').text().trim();
|
|
||||||
novel.status = this.getStatus(
|
|
||||||
$('.tsinfo .imptdt:contains(Statut)').text().replace('Statut', '').trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const chapters: Plugin.ChapterItem[] = [];
|
|
||||||
$('ul li:has(div.chbox):has(div.eph-num)').each((i, elem) => {
|
|
||||||
const chapterName = $(elem).find('a .chapternum').text().trim();
|
|
||||||
const chapterUrl = $(elem).find('a').attr('href');
|
|
||||||
const releaseDate = this.parseDate($(elem).find('a .chapterdate').text());
|
|
||||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
|
||||||
chapters.push({
|
|
||||||
name: chapterName,
|
|
||||||
path: chapterUrl.replace(this.site, ''),
|
|
||||||
releaseTime: releaseDate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
novel.chapters = chapters;
|
|
||||||
return novel;
|
|
||||||
}
|
|
||||||
|
|
||||||
parseDate(date: string): string {
|
|
||||||
const monthMapping: Record<string, number> = {
|
|
||||||
janvier: 1,
|
|
||||||
fevrier: 2,
|
|
||||||
mars: 3,
|
|
||||||
avril: 4,
|
|
||||||
mai: 5,
|
|
||||||
juin: 6,
|
|
||||||
juillet: 7,
|
|
||||||
aout: 8,
|
|
||||||
septembre: 9,
|
|
||||||
octobre: 10,
|
|
||||||
novembre: 11,
|
|
||||||
decembre: 12,
|
|
||||||
};
|
|
||||||
|
|
||||||
const [day, month, year] = date.split(' ');
|
|
||||||
return dayjs(
|
|
||||||
`${day} ${monthMapping[month.normalize('NFD').replace(/[\u0300-\u036f]/g, '')]} ${year}`,
|
|
||||||
'D MMMM YYYY',
|
|
||||||
).format('DD MMMM YYYY');
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatus(status: string) {
|
|
||||||
const lowerCaseStatus = status.toLowerCase();
|
|
||||||
const ongoing = ['en cours', 'en cours de publication'];
|
|
||||||
const onhiatus = ['en pause', 'en attente'];
|
|
||||||
const completed = ['complété', 'fini', 'achevé', 'terminé'];
|
|
||||||
const cancelled = ['abandonné'];
|
|
||||||
|
|
||||||
if (ongoing.includes(lowerCaseStatus)) {
|
|
||||||
return NovelStatus.Ongoing;
|
|
||||||
} else if (onhiatus.includes(lowerCaseStatus)) {
|
|
||||||
return NovelStatus.OnHiatus;
|
|
||||||
} else if (completed.includes(lowerCaseStatus)) {
|
|
||||||
return NovelStatus.Completed;
|
|
||||||
} else if (cancelled.includes(lowerCaseStatus)) {
|
|
||||||
return NovelStatus.Cancelled;
|
|
||||||
}
|
|
||||||
return NovelStatus.Unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseChapter(chapterPath: string): Promise<string> {
|
|
||||||
const $ = await this.getCheerio(this.site + chapterPath);
|
|
||||||
return $('#readerarea').html() || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchNovels(
|
|
||||||
searchTerm: string,
|
|
||||||
pageNo: number,
|
|
||||||
): Promise<Plugin.NovelItem[]> {
|
|
||||||
if (pageNo !== 1) return [];
|
|
||||||
|
|
||||||
const popularNovels = this.popularNovels(1, {
|
|
||||||
showLatestNovels: true,
|
|
||||||
filters: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const novels = (await popularNovels).filter(novel =>
|
|
||||||
novel.name
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.trim()
|
|
||||||
.includes(
|
|
||||||
searchTerm
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.trim(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return novels;
|
|
||||||
}
|
|
||||||
|
|
||||||
filters = {
|
|
||||||
genre: {
|
|
||||||
type: FilterTypes.CheckboxGroup,
|
|
||||||
label: 'Genre',
|
|
||||||
value: [],
|
|
||||||
options: [
|
|
||||||
{ label: 'Action', value: 'action' },
|
|
||||||
{
|
|
||||||
label: 'Action Adventure Fantaisie Psychologique',
|
|
||||||
value: 'action-adventure-fantaisie-psychologique',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Action Arts martiaux Aventure Fantastique Surnaturel',
|
|
||||||
value: 'action-arts-martiaux-aventure-fantastique-surnaturel',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Action Aventure Fantastique',
|
|
||||||
value: 'action-aventure-fantastique',
|
|
||||||
},
|
|
||||||
{ label: 'Action Drame Shônen', value: 'action-drame-shonen' },
|
|
||||||
{ label: 'Adult', value: 'adult' },
|
|
||||||
{ label: 'Adventure', value: 'adventure' },
|
|
||||||
{ label: 'amitié', value: 'amitie' },
|
|
||||||
{ label: 'amour', value: 'amour' },
|
|
||||||
{ label: 'Art-martiaux', value: 'art-martiaux' },
|
|
||||||
{ label: 'Arts-martiaux', value: 'arts-martiaux' },
|
|
||||||
{ label: 'Aventure', value: 'aventure' },
|
|
||||||
{ label: 'Combat', value: 'combat' },
|
|
||||||
{ label: 'Comedie', value: 'comedie' },
|
|
||||||
{ label: 'Comedy', value: 'comedy' },
|
|
||||||
{ label: 'Demons', value: 'demons' },
|
|
||||||
{ label: 'Dragon', value: 'dragon' },
|
|
||||||
{ label: 'Drama', value: 'drama' },
|
|
||||||
{ label: 'Drame', value: 'drame' },
|
|
||||||
{ label: 'Ecchi', value: 'ecchi' },
|
|
||||||
{ label: 'Fantaisie', value: 'fantaisie' },
|
|
||||||
{ label: 'fantastique', value: 'fantastique' },
|
|
||||||
{ label: 'Fantasy', value: 'fantasy' },
|
|
||||||
{ label: 'Ghosts', value: 'ghosts' },
|
|
||||||
{ label: 'Harem', value: 'harem' },
|
|
||||||
{ label: 'Historical', value: 'historical' },
|
|
||||||
{ label: 'Historique', value: 'historique' },
|
|
||||||
{ label: 'Horreur', value: 'horreur' },
|
|
||||||
{ label: 'Horror', value: 'horror' },
|
|
||||||
{ label: 'Isekai', value: 'isekai' },
|
|
||||||
{ label: 'Josei', value: 'josei' },
|
|
||||||
{ label: 'longstrip art', value: 'longstrip-art' },
|
|
||||||
{ label: 'Magic', value: 'magic' },
|
|
||||||
{ label: 'Magie', value: 'magie' },
|
|
||||||
{ label: 'Male Protagonist', value: 'male-protagonist' },
|
|
||||||
{ label: 'manga', value: 'manga' },
|
|
||||||
{ label: 'Manhwa', value: 'manhwa' },
|
|
||||||
{ label: 'Manhwa Player', value: 'manhwa-player' },
|
|
||||||
{ label: 'Manwha', value: 'manwha' },
|
|
||||||
{ label: 'Martial Arts', value: 'martial-arts' },
|
|
||||||
{ label: 'Mature', value: 'mature' },
|
|
||||||
{ label: 'Monstre', value: 'monstre' },
|
|
||||||
{ label: 'Murim', value: 'murim' },
|
|
||||||
{ label: 'mystère', value: 'mystere' },
|
|
||||||
{ label: 'Mystery', value: 'mystery' },
|
|
||||||
{ label: 'Necromancien', value: 'necromancien' },
|
|
||||||
{ label: 'necromencer', value: 'necromencer' },
|
|
||||||
{ label: 'Novel', value: 'novel' },
|
|
||||||
{ label: 'One Shot', value: 'one-shot' },
|
|
||||||
{ label: 'Over Power MC', value: 'over-power-mc' },
|
|
||||||
{ label: 'Partenaire', value: 'partenaire' },
|
|
||||||
{ label: 'Player', value: 'player' },
|
|
||||||
{ label: 'Player Manhwa', value: 'player-manhwa' },
|
|
||||||
{ label: 'Portail', value: 'portail' },
|
|
||||||
{ label: 'Psychological', value: 'psychological' },
|
|
||||||
{ label: 'Psychologique', value: 'psychologique' },
|
|
||||||
{ label: 'regresseur', value: 'regresseur' },
|
|
||||||
{ label: 'régression', value: 'regression' },
|
|
||||||
{ label: 'Réincarnation', value: 'reincarnation' },
|
|
||||||
{ label: 'Returner', value: 'returner' },
|
|
||||||
{ label: 'Romance', value: 'romance' },
|
|
||||||
{ label: 'School Life', value: 'school-life' },
|
|
||||||
{ label: 'Sci-fi', value: 'sci-fi' },
|
|
||||||
{ label: 'Seinen', value: 'seinen' },
|
|
||||||
{ label: 'Shôjo', value: 'shojo' },
|
|
||||||
{ label: 'Shônen', value: 'shonen' },
|
|
||||||
{ label: 'Shotacon', value: 'shotacon' },
|
|
||||||
{ label: 'Shoujo', value: 'shoujo' },
|
|
||||||
{ label: 'Shounen', value: 'shounen' },
|
|
||||||
{ label: 'Slice of Life', value: 'slice-of-life' },
|
|
||||||
{ label: 'Slide of Life', value: 'slide-of-life' },
|
|
||||||
{ label: 'Smut', value: 'smut' },
|
|
||||||
{ label: 'Sports', value: 'sports' },
|
|
||||||
{ label: 'Supernatural', value: 'supernatural' },
|
|
||||||
{ label: 'Surnaturel', value: 'surnaturel' },
|
|
||||||
{ label: 'Système', value: 'systeme' },
|
|
||||||
{ label: 'Tragédie', value: 'tragedie' },
|
|
||||||
{ label: 'Tragedy', value: 'tragedy' },
|
|
||||||
{ label: 'Webtoons', value: 'webtoons' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
} satisfies Filters;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new PhenixScansTradPlugin();
|
|
||||||
@@ -3,6 +3,33 @@ import { fetchApi } from '@libs/fetch';
|
|||||||
import { Plugin } from '@/types/plugin';
|
import { Plugin } from '@/types/plugin';
|
||||||
import { NovelStatus } from '@libs/novelStatus';
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
|
|
||||||
|
const challengeTitles = new Set([
|
||||||
|
'bot verification',
|
||||||
|
'you are being redirected...',
|
||||||
|
'un instant...',
|
||||||
|
'just a moment...',
|
||||||
|
'redirecting...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function fetchCheckedHtml(
|
||||||
|
url: string,
|
||||||
|
init?: Parameters<typeof fetchApi>[1],
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetchApi(url, init);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status} while loading ${url}`);
|
||||||
|
const html = await response.text();
|
||||||
|
const title = html
|
||||||
|
.match(/<title[^>]*>(.*?)<\/title>/is)?.[1]
|
||||||
|
?.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (title && challengeTitles.has(title))
|
||||||
|
throw new Error(`Bot challenge while loading ${url}`);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
const NOVEL_METADATA: Record<string, { name: string; summary: string }> = {
|
const NOVEL_METADATA: Record<string, { name: string; summary: string }> = {
|
||||||
'/histoire-principale/': {
|
'/histoire-principale/': {
|
||||||
name: 'Re:Zero - Histoire Principale',
|
name: 'Re:Zero - Histoire Principale',
|
||||||
@@ -626,15 +653,14 @@ class ReZeroWebNovelFrPlugin implements Plugin.PluginBase {
|
|||||||
name = 'Re:Zero Web Novel FR';
|
name = 'Re:Zero Web Novel FR';
|
||||||
icon = 'src/fr/rezerowebnovelfr/icon.png';
|
icon = 'src/fr/rezerowebnovelfr/icon.png';
|
||||||
site = 'https://rezerowebnovelfr.wordpress.com';
|
site = 'https://rezerowebnovelfr.wordpress.com';
|
||||||
version = '1.0.1';
|
version = '1.0.3';
|
||||||
|
|
||||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||||
const r = await fetchApi(url, {
|
return load(
|
||||||
headers: { 'Accept-Encoding': 'deflate' },
|
await fetchCheckedHtml(url, {
|
||||||
});
|
headers: { 'Accept-Encoding': 'deflate' },
|
||||||
const body = await r.text();
|
}),
|
||||||
const $ = load(body);
|
);
|
||||||
return $;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||||
@@ -656,7 +682,7 @@ class ReZeroWebNovelFrPlugin implements Plugin.PluginBase {
|
|||||||
const $ = await this.getCheerio(this.site + novelPath);
|
const $ = await this.getCheerio(this.site + novelPath);
|
||||||
novel.name = $('h1.entry-title').text().trim();
|
novel.name = $('h1.entry-title').text().trim();
|
||||||
novel.author = 'Tappei Nagatsuki';
|
novel.author = 'Tappei Nagatsuki';
|
||||||
novel.status = NovelStatus.Ongoing;
|
novel.status = NovelStatus.Unknown;
|
||||||
|
|
||||||
const meta = NOVEL_METADATA[novelPath];
|
const meta = NOVEL_METADATA[novelPath];
|
||||||
if (meta) {
|
if (meta) {
|
||||||
@@ -666,6 +692,7 @@ class ReZeroWebNovelFrPlugin implements Plugin.PluginBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chapters: Plugin.ChapterItem[] = [];
|
const chapters: Plugin.ChapterItem[] = [];
|
||||||
|
const chapterPaths = new Set<string>();
|
||||||
|
|
||||||
const tryAddChapter = (
|
const tryAddChapter = (
|
||||||
href: string | undefined,
|
href: string | undefined,
|
||||||
@@ -677,7 +704,8 @@ class ReZeroWebNovelFrPlugin implements Plugin.PluginBase {
|
|||||||
const dateMatch = cleanHref.match(/\/(\d{4})\/(\d{2})\/(\d{2})\//);
|
const dateMatch = cleanHref.match(/\/(\d{4})\/(\d{2})\/(\d{2})\//);
|
||||||
if (dateMatch && name) {
|
if (dateMatch && name) {
|
||||||
const path = cleanHref.replace(this.site, '');
|
const path = cleanHref.replace(this.site, '');
|
||||||
if (!chapters.some(c => c.path === path)) {
|
if (!chapterPaths.has(path)) {
|
||||||
|
chapterPaths.add(path);
|
||||||
const releaseDate = `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`;
|
const releaseDate = `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`;
|
||||||
let cleanName = name;
|
let cleanName = name;
|
||||||
if (novelPath === '/if-stories/') {
|
if (novelPath === '/if-stories/') {
|
||||||
@@ -806,6 +834,7 @@ class ReZeroWebNovelFrPlugin implements Plugin.PluginBase {
|
|||||||
$(
|
$(
|
||||||
'div.entry-content .sharedaddy, div.entry-content .wpcnt, div.entry-content #jp-post-flair, div.entry-content div[id^="atatags-"]',
|
'div.entry-content .sharedaddy, div.entry-content .wpcnt, div.entry-content #jp-post-flair, div.entry-content div[id^="atatags-"]',
|
||||||
).remove();
|
).remove();
|
||||||
|
$('div.entry-content').find('script, style, ins, iframe, .ads').remove();
|
||||||
|
|
||||||
const title = $('h1.entry-title').html() || '';
|
const title = $('h1.entry-title').html() || '';
|
||||||
const chapter = $('div.entry-content').html() || '';
|
const chapter = $('div.entry-content').html() || '';
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
import { defaultCover } from '@libs/defaultCover';
|
||||||
|
import { fetchApi } from '@libs/fetch';
|
||||||
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
|
import { Plugin } from '@/types/plugin';
|
||||||
|
|
||||||
|
const catalogueTypes = ['Web Novel', 'Light Novel', 'Manhwa'];
|
||||||
|
const chapterPathPattern =
|
||||||
|
/^(?:https?:\/\/trad-index\.com)?\/oeuvre\/([^/?#]+)\/chapitre\/(\d+(?:[.,]\d+)?)/;
|
||||||
|
|
||||||
|
class TradIndexPlugin implements Plugin.PluginBase {
|
||||||
|
id = 'tradindex';
|
||||||
|
name = 'Trad-Index';
|
||||||
|
icon = 'src/fr/tradindex/icon.png';
|
||||||
|
site = 'https://trad-index.com/';
|
||||||
|
version = '1.0.8';
|
||||||
|
|
||||||
|
// The app injects its own device User-Agent; send a desktop one so the
|
||||||
|
// server-rendered catalogue/chapter pages stay identical on mobile.
|
||||||
|
private readonly browserHeaders = {
|
||||||
|
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36',
|
||||||
|
};
|
||||||
|
|
||||||
|
resolveUrl(path: string, isNovel = false): string {
|
||||||
|
const url = new URL(path, this.site);
|
||||||
|
if (url.origin !== new URL(this.site).origin)
|
||||||
|
throw new Error('Cannot resolve a foreign origin');
|
||||||
|
const cleanPath = url.pathname
|
||||||
|
.replace(/^\/+|\/+$/g, '')
|
||||||
|
.replace(/^oeuvre\//, '');
|
||||||
|
if (isNovel) return new URL(`/oeuvre/${cleanPath}`, this.site).href;
|
||||||
|
|
||||||
|
const [slug, chapterNumber] = cleanPath
|
||||||
|
.replace(/\/chapitre\//, '/')
|
||||||
|
.split('/');
|
||||||
|
return new URL(`/oeuvre/${slug}/chapitre/${chapterNumber}`, this.site).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
private retryDelay(response: Response, attempt: number): number {
|
||||||
|
const retryAfter = response.headers.get('retry-after');
|
||||||
|
if (retryAfter) {
|
||||||
|
const seconds = Number(retryAfter);
|
||||||
|
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
||||||
|
const date = Date.parse(retryAfter);
|
||||||
|
if (!Number.isNaN(date)) return Math.max(date - Date.now(), 0);
|
||||||
|
}
|
||||||
|
return 100 * (attempt + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchHtml(path: string, retry = false): Promise<string> {
|
||||||
|
const attempts = retry ? 4 : 1;
|
||||||
|
const url = new URL(path, this.site).href;
|
||||||
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
|
const response = await fetchApi(url, { headers: this.browserHeaders });
|
||||||
|
if (response.ok) return response.text();
|
||||||
|
if (
|
||||||
|
!retry ||
|
||||||
|
(response.status !== 429 && response.status < 500) ||
|
||||||
|
attempt === attempts - 1
|
||||||
|
)
|
||||||
|
throw new Error(`Failed to load ${path}`);
|
||||||
|
await new Promise(resolve =>
|
||||||
|
setTimeout(resolve, this.retryDelay(response, attempt)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to load ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async catalogueSections(paths: string[]): Promise<string[]> {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
paths.map(path => this.fetchHtml(path)),
|
||||||
|
);
|
||||||
|
const pages = results.flatMap(result =>
|
||||||
|
result.status === 'fulfilled' ? [result.value] : [],
|
||||||
|
);
|
||||||
|
if (!pages.length) throw new Error('Failed to load catalogue');
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCards(html: string): Plugin.NovelItem[] {
|
||||||
|
const $ = load(html);
|
||||||
|
const novels = new Map<string, Plugin.NovelItem>();
|
||||||
|
$('a[href]').each((_, element) => {
|
||||||
|
const href = $(element).attr('href') || '';
|
||||||
|
const match = href.match(
|
||||||
|
/^(?:https?:\/\/trad-index\.com)?\/oeuvre\/([^/?#]+)\/?$/,
|
||||||
|
);
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
|
const name = $(element)
|
||||||
|
.find('[class*="line-clamp"]')
|
||||||
|
.first()
|
||||||
|
.text()
|
||||||
|
.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const cover = $(element).find('img').first().attr('src');
|
||||||
|
novels.set(match[1], {
|
||||||
|
name,
|
||||||
|
path: match[1],
|
||||||
|
cover: cover ? new URL(cover, this.site).href : defaultCover,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(novels.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private cataloguePath(type: string, pageNo: number, searchTerm?: string) {
|
||||||
|
const query = searchTerm ? `&q=${encodeURIComponent(searchTerm)}` : '';
|
||||||
|
return `/catalogue?type=${encodeURIComponent(type)}${query}&page=${pageNo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private chapterItems(html: string, slug: string): Plugin.ChapterItem[] {
|
||||||
|
const $ = load(html);
|
||||||
|
const chapters = new Map<string, Plugin.ChapterItem>();
|
||||||
|
$('a[href]').each((_, element) => {
|
||||||
|
const href = $(element).attr('href') || '';
|
||||||
|
const match = href.match(chapterPathPattern);
|
||||||
|
if (!match || match[1] !== slug) return;
|
||||||
|
|
||||||
|
const number = Number(match[2].replace(',', '.'));
|
||||||
|
if (!Number.isFinite(number)) return;
|
||||||
|
const path = `${slug}/${match[2]}`;
|
||||||
|
chapters.set(path, {
|
||||||
|
name: $(element).text().trim() || `Chapitre ${match[2]}`,
|
||||||
|
path,
|
||||||
|
chapterNumber: number,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(chapters.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchChapterPages(
|
||||||
|
html: string,
|
||||||
|
slug: string,
|
||||||
|
): Promise<Plugin.ChapterItem[]> {
|
||||||
|
const $ = load(html);
|
||||||
|
let lastPage = 1;
|
||||||
|
$('a[href*="onglet=chapitres"]').each((_, element) => {
|
||||||
|
const href = $(element).attr('href');
|
||||||
|
if (!href) return;
|
||||||
|
const page = Number(
|
||||||
|
new URL(href, this.resolveUrl(slug, true)).searchParams.get('page'),
|
||||||
|
);
|
||||||
|
if (Number.isInteger(page)) lastPage = Math.max(lastPage, page);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pages = await Promise.all(
|
||||||
|
Array.from({ length: lastPage - 1 }, (_, index) =>
|
||||||
|
this.fetchHtml(
|
||||||
|
`/oeuvre/${slug}?onglet=chapitres&tri=desc&page=${index + 2}`,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const chapters = new Map<string, Plugin.ChapterItem>();
|
||||||
|
for (const pageHtml of [html, ...pages]) {
|
||||||
|
for (const chapter of this.chapterItems(pageHtml, slug)) {
|
||||||
|
chapters.set(chapter.path, chapter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(chapters.values()).sort(
|
||||||
|
(left, right) => (left.chapterNumber || 0) - (right.chapterNumber || 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||||
|
const sitePage = Math.max(1, pageNo);
|
||||||
|
const pages = await this.catalogueSections(
|
||||||
|
catalogueTypes.map(type => this.cataloguePath(type, sitePage)),
|
||||||
|
);
|
||||||
|
const novels = Array.from(
|
||||||
|
new Map(
|
||||||
|
pages
|
||||||
|
.flat()
|
||||||
|
.flatMap(html => this.parseCards(html))
|
||||||
|
.map(novel => [novel.path, novel]),
|
||||||
|
).values(),
|
||||||
|
);
|
||||||
|
if (sitePage === 1 && !novels.length)
|
||||||
|
throw new Error('Trad-Index catalogue returned no work cards');
|
||||||
|
return novels;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchNovels(
|
||||||
|
searchTerm: string,
|
||||||
|
pageNo: number,
|
||||||
|
): Promise<Plugin.NovelItem[]> {
|
||||||
|
const pages = await this.catalogueSections(
|
||||||
|
catalogueTypes.map(type => this.cataloguePath(type, pageNo, searchTerm)),
|
||||||
|
);
|
||||||
|
return Array.from(
|
||||||
|
new Map(
|
||||||
|
pages
|
||||||
|
.flatMap(html => this.parseCards(html))
|
||||||
|
.map(novel => [novel.path, novel]),
|
||||||
|
).values(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
|
||||||
|
const novelUrl = this.resolveUrl(novelPath, true);
|
||||||
|
const slug = new URL(novelUrl).pathname.replace(/^\/oeuvre\//, '');
|
||||||
|
const html = await this.fetchHtml(novelUrl, true);
|
||||||
|
const $ = load(html);
|
||||||
|
const details = $('body').text().replace(/\s+/g, ' ');
|
||||||
|
const formatStatus = details.match(
|
||||||
|
/(Web Novel|Light Novel|Manhwa)\s*·\s*([^\n]+)/i,
|
||||||
|
);
|
||||||
|
const getDetail = (label: string) => {
|
||||||
|
let value: string | undefined;
|
||||||
|
$('*').each((_, element) => {
|
||||||
|
const text = $(element).text().trim();
|
||||||
|
const match = text.match(new RegExp(`^${label}\\s*:\\s*(.+)$`, 'i'));
|
||||||
|
if (match) {
|
||||||
|
value = match[1].trim();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const synopsisHeading = $('h1,h2,h3')
|
||||||
|
.filter(
|
||||||
|
(_, element) => $(element).text().trim().toLowerCase() === 'synopsis',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: slug,
|
||||||
|
name: $('h1').first().text().trim(),
|
||||||
|
cover: $('img[alt^="Couverture de"]').first().attr('src')
|
||||||
|
? new URL(
|
||||||
|
$('img[alt^="Couverture de"]').first().attr('src')!,
|
||||||
|
this.site,
|
||||||
|
).href
|
||||||
|
: defaultCover,
|
||||||
|
summary: synopsisHeading.nextAll('p').first().text().trim() || undefined,
|
||||||
|
author: getDetail('Auteur'),
|
||||||
|
artist: getDetail('Traducteur'),
|
||||||
|
genres: getDetail('Genres'),
|
||||||
|
status: /terminé/i.test(formatStatus?.[2] || '')
|
||||||
|
? NovelStatus.Completed
|
||||||
|
: /en cours/i.test(formatStatus?.[2] || '')
|
||||||
|
? NovelStatus.Ongoing
|
||||||
|
: NovelStatus.Unknown,
|
||||||
|
chapters: await this.fetchChapterPages(html, slug),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseChapter(chapterPath: string): Promise<string> {
|
||||||
|
const html = await this.fetchHtml(
|
||||||
|
this.resolveUrl(chapterPath).replace(this.site.slice(0, -1), ''),
|
||||||
|
);
|
||||||
|
const $ = load(html);
|
||||||
|
const main = $('main').first();
|
||||||
|
const stopPattern =
|
||||||
|
/traduit par|traducteur|navigation|partager|signaler|commentaires?/i;
|
||||||
|
let stopped = false;
|
||||||
|
const prose: string[] = [];
|
||||||
|
main
|
||||||
|
.find(
|
||||||
|
'h1, h2, h3, h4, h5, h6, p, nav, form, [class*="comment"], [class*="share"], [class*="report"], [class*="translator"]',
|
||||||
|
)
|
||||||
|
.each((_, element) => {
|
||||||
|
const part = $(element);
|
||||||
|
if (stopped) return false;
|
||||||
|
if (stopPattern.test(part.text())) {
|
||||||
|
stopped = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
element.tagName === 'p' &&
|
||||||
|
(part.hasClass('narration') || part.hasClass('dialogue'))
|
||||||
|
)
|
||||||
|
prose.push($.html(element));
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = prose.join('');
|
||||||
|
if (load(content).text().trim().length < 200)
|
||||||
|
throw new Error('No readable chapter content found');
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TradIndexPlugin();
|
||||||
@@ -5,12 +5,39 @@ import { defaultCover } from '@libs/defaultCover';
|
|||||||
import { NovelStatus } from '@libs/novelStatus';
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const challengeTitles = new Set([
|
||||||
|
'bot verification',
|
||||||
|
'you are being redirected...',
|
||||||
|
'un instant...',
|
||||||
|
'just a moment...',
|
||||||
|
'redirecting...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function fetchCheckedHtml(
|
||||||
|
url: string,
|
||||||
|
init?: Parameters<typeof fetchApi>[1],
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetchApi(url, init);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status} while loading ${url}`);
|
||||||
|
const html = await response.text();
|
||||||
|
const title = html
|
||||||
|
.match(/<title[^>]*>(.*?)<\/title>/is)?.[1]
|
||||||
|
?.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (title && challengeTitles.has(title))
|
||||||
|
throw new Error(`Bot challenge while loading ${url}`);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
class WarriorLegendTradPlugin implements Plugin.PluginBase {
|
class WarriorLegendTradPlugin implements Plugin.PluginBase {
|
||||||
id = 'warriorlegendtrad';
|
id = 'warriorlegendtrad';
|
||||||
name = 'Warrior Legend Trad';
|
name = 'Warrior Legend Trad';
|
||||||
icon = 'src/fr/warriorlegendtrad/icon.png';
|
icon = 'src/fr/warriorlegendtrad/icon.png';
|
||||||
site = 'https://warriorlegendtrad.wordpress.com';
|
site = 'https://warriorlegendtrad.wordpress.com';
|
||||||
version = '1.0.1';
|
version = '1.0.4';
|
||||||
|
|
||||||
regexAuthors = [/Auteur\u00A0:([^\n]*)/];
|
regexAuthors = [/Auteur\u00A0:([^\n]*)/];
|
||||||
|
|
||||||
@@ -19,10 +46,7 @@ class WarriorLegendTradPlugin implements Plugin.PluginBase {
|
|||||||
regexSummary = [/Synopsis\u00A0:([\s\S]*)index chapitre :/i];
|
regexSummary = [/Synopsis\u00A0:([\s\S]*)index chapitre :/i];
|
||||||
|
|
||||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||||
const r = await fetchApi(url);
|
return load(await fetchCheckedHtml(url));
|
||||||
const body = await r.text();
|
|
||||||
const $ = load(body);
|
|
||||||
return $;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||||
@@ -84,7 +108,7 @@ class WarriorLegendTradPlugin implements Plugin.PluginBase {
|
|||||||
const chapterUrl = $(elem).attr('href');
|
const chapterUrl = $(elem).attr('href');
|
||||||
const releaseDate = dayjs(
|
const releaseDate = dayjs(
|
||||||
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
||||||
).format('DD MMMM YYYY');
|
).format('YYYY-MM-DD');
|
||||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||||
chapters.push({
|
chapters.push({
|
||||||
name: chapterName,
|
name: chapterName,
|
||||||
@@ -137,11 +161,12 @@ class WarriorLegendTradPlugin implements Plugin.PluginBase {
|
|||||||
return NovelStatus.Cancelled;
|
return NovelStatus.Cancelled;
|
||||||
}
|
}
|
||||||
|
|
||||||
return NovelStatus.Ongoing;
|
return NovelStatus.Unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
async parseChapter(chapterPath: string): Promise<string> {
|
async parseChapter(chapterPath: string): Promise<string> {
|
||||||
const $ = await this.getCheerio(this.site + chapterPath);
|
const $ = await this.getCheerio(this.site + chapterPath);
|
||||||
|
$('.entry-content').find('script, style, ins, iframe, .ads').remove();
|
||||||
let contenuHtml = '';
|
let contenuHtml = '';
|
||||||
$('.entry-content')
|
$('.entry-content')
|
||||||
.contents()
|
.contents()
|
||||||
@@ -163,9 +188,11 @@ class WarriorLegendTradPlugin implements Plugin.PluginBase {
|
|||||||
): Promise<Plugin.NovelItem[]> {
|
): Promise<Plugin.NovelItem[]> {
|
||||||
if (pageNo !== 1) return [];
|
if (pageNo !== 1) return [];
|
||||||
|
|
||||||
const popularNovels = this.popularNovels(1);
|
const popularNovels = (
|
||||||
|
await Promise.all([this.popularNovels(1), this.popularNovels(2)])
|
||||||
|
).flat();
|
||||||
|
|
||||||
const novels = (await popularNovels).filter(novel =>
|
const novels = popularNovels.filter(novel =>
|
||||||
novel.name
|
novel.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
|
|||||||
@@ -5,20 +5,70 @@ import { defaultCover } from '@libs/defaultCover';
|
|||||||
import { NovelStatus } from '@libs/novelStatus';
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const challengeTitles = new Set([
|
||||||
|
'bot verification',
|
||||||
|
'you are being redirected...',
|
||||||
|
'un instant...',
|
||||||
|
'just a moment...',
|
||||||
|
'redirecting...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function fetchCheckedHtml(
|
||||||
|
url: string,
|
||||||
|
init?: Parameters<typeof fetchApi>[1],
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetchApi(url, init);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status} while loading ${url}`);
|
||||||
|
const html = await response.text();
|
||||||
|
const title = html
|
||||||
|
.match(/<title[^>]*>(.*?)<\/title>/is)?.[1]
|
||||||
|
?.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (title && challengeTitles.has(title))
|
||||||
|
throw new Error(`Bot challenge while loading ${url}`);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
class WuxialnscantradPlugin implements Plugin.PluginBase {
|
class WuxialnscantradPlugin implements Plugin.PluginBase {
|
||||||
id = 'wuxialnscantrad';
|
id = 'wuxialnscantrad';
|
||||||
name = 'WuxiaLnScantrad';
|
name = 'WuxiaLnScantrad';
|
||||||
icon = 'src/fr/wuxialnscantrad/icon.png';
|
icon = 'src/fr/wuxialnscantrad/icon.png';
|
||||||
site = 'https://wuxialnscantrad.wordpress.com';
|
site = 'https://wuxialnscantrad.wordpress.com';
|
||||||
version = '1.0.0';
|
version = '1.0.4';
|
||||||
|
|
||||||
|
private async findMovedChapter(chapterPath: string): Promise<string | null> {
|
||||||
|
const slug = chapterPath.split('/').filter(Boolean).pop() || '';
|
||||||
|
const match = slug.match(/^(.*?)-chapitre-(\d+)/i);
|
||||||
|
if (!match) return null;
|
||||||
|
const series = match[1].split('-').filter(Boolean).slice(0, 3).join(' ');
|
||||||
|
const query = `${series} chapitre ${match[2]}`;
|
||||||
|
const response = await fetchApi(
|
||||||
|
`https://public-api.wordpress.com/wp/v2/sites/wuxialnscantrad.wordpress.com/search?search=${encodeURIComponent(query)}&type=post&subtype=post&per_page=20`,
|
||||||
|
);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const results = (await response.json()) as {
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
}[];
|
||||||
|
const replacement = results.find(result =>
|
||||||
|
new RegExp(`chapitre\\D*${match[2]}(?:\\D|$)`, 'i').test(
|
||||||
|
result.title || '',
|
||||||
|
),
|
||||||
|
)?.url;
|
||||||
|
if (!replacement) return null;
|
||||||
|
const url = new URL(replacement, this.site);
|
||||||
|
return url.origin === new URL(this.site).origin ? url.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||||
const r = await fetchApi(url, {
|
return load(
|
||||||
headers: { 'Accept-Encoding': 'deflate' },
|
await fetchCheckedHtml(url, {
|
||||||
});
|
headers: { 'Accept-Encoding': 'deflate' },
|
||||||
const body = await r.text();
|
}),
|
||||||
const $ = load(body);
|
);
|
||||||
return $;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
async popularNovels(pageNo: number): Promise<Plugin.NovelItem[]> {
|
||||||
@@ -41,6 +91,15 @@ class WuxialnscantradPlugin implements Plugin.PluginBase {
|
|||||||
novels.push(novel);
|
novels.push(novel);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await Promise.all(
|
||||||
|
novels.map(async item => {
|
||||||
|
const detail = await this.getCheerio(this.site + item.path);
|
||||||
|
item.cover =
|
||||||
|
detail('.entry-content p strong img').first().attr('src') ||
|
||||||
|
detail('.entry-content p img').first().attr('src') ||
|
||||||
|
defaultCover;
|
||||||
|
}),
|
||||||
|
);
|
||||||
return novels;
|
return novels;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,16 +125,17 @@ class WuxialnscantradPlugin implements Plugin.PluginBase {
|
|||||||
|
|
||||||
const pathChapter = $('.entry-content ul').first().children('li');
|
const pathChapter = $('.entry-content ul').first().children('li');
|
||||||
const chapters: Plugin.ChapterItem[] = [];
|
const chapters: Plugin.ChapterItem[] = [];
|
||||||
|
const chapterPaths = new Set<string>();
|
||||||
pathChapter.each((i, elem) => {
|
pathChapter.each((i, elem) => {
|
||||||
const chapterName = $(elem).text().trim();
|
const chapterName = $(elem).text().trim();
|
||||||
const chapterUrl = $(elem).find('a').attr('href');
|
const chapterUrl = $(elem).find('a').attr('href');
|
||||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
||||||
const pathchapter = chapterUrl.replace(this.site, '');
|
const pathchapter = chapterUrl.replace(this.site, '');
|
||||||
// we do not take the paths already present
|
if (!chapterPaths.has(pathchapter)) {
|
||||||
if (!chapters.some(chap => chap.path === pathchapter)) {
|
chapterPaths.add(pathchapter);
|
||||||
const releaseDate = dayjs(
|
const releaseDate = dayjs(
|
||||||
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
chapterUrl?.substring(this.site.length + 1, this.site.length + 11),
|
||||||
).format('DD MMMM YYYY');
|
).format('YYYY-MM-DD');
|
||||||
chapters.push({
|
chapters.push({
|
||||||
name: chapterName,
|
name: chapterName,
|
||||||
path: pathchapter,
|
path: pathchapter,
|
||||||
@@ -149,12 +209,21 @@ class WuxialnscantradPlugin implements Plugin.PluginBase {
|
|||||||
case 'Terminé':
|
case 'Terminé':
|
||||||
return NovelStatus.Completed;
|
return NovelStatus.Completed;
|
||||||
default:
|
default:
|
||||||
return NovelStatus.Ongoing;
|
return NovelStatus.Unknown;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async parseChapter(chapterPath: string): Promise<string> {
|
async parseChapter(chapterPath: string): Promise<string> {
|
||||||
const $ = await this.getCheerio(this.site + chapterPath);
|
const options = { headers: { 'Accept-Encoding': 'deflate' } };
|
||||||
|
let body: string;
|
||||||
|
try {
|
||||||
|
body = await fetchCheckedHtml(this.site + chapterPath, options);
|
||||||
|
} catch (error) {
|
||||||
|
const replacement = await this.findMovedChapter(chapterPath);
|
||||||
|
if (!replacement) throw error;
|
||||||
|
body = await fetchCheckedHtml(replacement, options);
|
||||||
|
}
|
||||||
|
const $ = load(body);
|
||||||
|
|
||||||
let contenuHtml = '';
|
let contenuHtml = '';
|
||||||
$('.entry-content')
|
$('.entry-content')
|
||||||
@@ -172,6 +241,12 @@ class WuxialnscantradPlugin implements Plugin.PluginBase {
|
|||||||
contenuHtml += $.html(this);
|
contenuHtml += $.html(this);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const parsedContent = load(contenuHtml);
|
||||||
|
if (
|
||||||
|
parsedContent.text().replace(/\s+/g, ' ').trim().length < 200 &&
|
||||||
|
!parsedContent('img').length
|
||||||
|
)
|
||||||
|
throw new Error('No readable chapter content found');
|
||||||
return contenuHtml;
|
return contenuHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-11
@@ -5,12 +5,39 @@ import { Plugin } from '@/types/plugin';
|
|||||||
import { defaultCover } from '@libs/defaultCover';
|
import { defaultCover } from '@libs/defaultCover';
|
||||||
import { NovelStatus } from '@libs/novelStatus';
|
import { NovelStatus } from '@libs/novelStatus';
|
||||||
|
|
||||||
|
const challengeTitles = new Set([
|
||||||
|
'bot verification',
|
||||||
|
'you are being redirected...',
|
||||||
|
'un instant...',
|
||||||
|
'just a moment...',
|
||||||
|
'redirecting...',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function fetchCheckedHtml(
|
||||||
|
url: string,
|
||||||
|
init?: Parameters<typeof fetchApi>[1],
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetchApi(url, init);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status} while loading ${url}`);
|
||||||
|
const html = await response.text();
|
||||||
|
const title = html
|
||||||
|
.match(/<title[^>]*>(.*?)<\/title>/is)?.[1]
|
||||||
|
?.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (title && challengeTitles.has(title))
|
||||||
|
throw new Error(`Bot challenge while loading ${url}`);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
class XiaowazPlugin implements Plugin.PluginBase {
|
class XiaowazPlugin implements Plugin.PluginBase {
|
||||||
id = 'xiaowaz';
|
id = 'xiaowaz';
|
||||||
name = 'Xiaowaz';
|
name = 'Xiaowaz';
|
||||||
icon = 'src/fr/xiaowaz/icon.png';
|
icon = 'src/fr/xiaowaz/icon.png';
|
||||||
site = 'https://xiaowaz.fr';
|
site = 'https://xiaowaz.fr';
|
||||||
version = '1.0.2';
|
version = '1.0.5';
|
||||||
static novels: Plugin.NovelItem[] | undefined;
|
static novels: Plugin.NovelItem[] | undefined;
|
||||||
|
|
||||||
async getCheerio(url: string): Promise<CheerioAPI> {
|
async getCheerio(url: string): Promise<CheerioAPI> {
|
||||||
@@ -18,10 +45,7 @@ class XiaowazPlugin implements Plugin.PluginBase {
|
|||||||
let returnError: unknown;
|
let returnError: unknown;
|
||||||
while (retries > 0) {
|
while (retries > 0) {
|
||||||
try {
|
try {
|
||||||
const r = await fetchApi(url);
|
return load(await fetchCheckedHtml(url));
|
||||||
const body = await r.text();
|
|
||||||
const $ = load(body);
|
|
||||||
return $;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
returnError = error;
|
returnError = error;
|
||||||
@@ -171,13 +195,22 @@ class XiaowazPlugin implements Plugin.PluginBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chapters: Plugin.ChapterItem[] = [];
|
const chapters: Plugin.ChapterItem[] = [];
|
||||||
|
const chapterPaths = new Set<string>();
|
||||||
pathChapter.each((i, elem) => {
|
pathChapter.each((i, elem) => {
|
||||||
const chapterName = $(elem).text().trim();
|
const chapterName = $(elem).text().trim();
|
||||||
const chapterUrl = $(elem).attr('href');
|
const chapterUrl = $(elem).attr('href');
|
||||||
if (chapterUrl && chapterUrl.includes(this.site) && chapterName) {
|
if (
|
||||||
|
chapterUrl &&
|
||||||
|
chapterUrl.includes(this.site) &&
|
||||||
|
!/\.pdf(?:$|[?#])/i.test(chapterUrl) &&
|
||||||
|
chapterName
|
||||||
|
) {
|
||||||
|
const path = chapterUrl.replace(this.site, '');
|
||||||
|
if (chapterPaths.has(path)) return;
|
||||||
|
chapterPaths.add(path);
|
||||||
chapters.push({
|
chapters.push({
|
||||||
name: chapterName,
|
name: chapterName,
|
||||||
path: chapterUrl.replace(this.site, ''),
|
path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -229,15 +262,20 @@ class XiaowazPlugin implements Plugin.PluginBase {
|
|||||||
async parseChapter(chapterPath: string): Promise<string> {
|
async parseChapter(chapterPath: string): Promise<string> {
|
||||||
const $ = await this.getCheerio(this.site + chapterPath);
|
const $ = await this.getCheerio(this.site + chapterPath);
|
||||||
|
|
||||||
const startTag = $('.wp-post-navigation');
|
const startTag = $('.entry-content .wp-post-navigation').first();
|
||||||
const endTag = $('.abh_box.abh_box_down.abh_box_business');
|
|
||||||
|
|
||||||
const elementsBetweenTags: string[] = [];
|
const elementsBetweenTags: string[] = [];
|
||||||
let footnotesElement: string | null = null;
|
let footnotesElement: string | null = null;
|
||||||
|
|
||||||
if (startTag.length > 0 && endTag.length > 0) {
|
if (startTag.length > 0) {
|
||||||
let currentElement = startTag.next();
|
let currentElement = startTag.next();
|
||||||
while (currentElement.length > 0 && !currentElement.is(endTag)) {
|
while (currentElement.length > 0) {
|
||||||
|
if (
|
||||||
|
currentElement.hasClass('wp-post-navigation') ||
|
||||||
|
currentElement.is('.abh_box.abh_box_down.abh_box_business')
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
currentElement.find('p > a[href="https://ko-fi.com/wazouille"]')
|
currentElement.find('p > a[href="https://ko-fi.com/wazouille"]')
|
||||||
.length > 0
|
.length > 0
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Reference in New Issue
Block a user