Files
lnreader-plugins/plugins/french/chireads.ts
T

399 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
type WordPressCategory = {
name: string;
link: string;
};
class ChireadsPlugin implements Plugin.PluginBase {
id = 'chireads';
name = 'Chireads';
icon = 'src/fr/chireads/icon.png';
site = 'https://chireads.com';
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> {
const r = await fetchApi(url, { headers: this.browserHeaders });
if (!r.ok) throw new Error(`HTTP ${r.status} while loading ${url}`);
const body = await r.text();
return load(body);
}
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(
pageNo: number,
{ filters, showLatestNovels }: Plugin.PopularNovelsOptions,
): Promise<Plugin.NovelItem[]> {
if (showLatestNovels) {
if (pageNo !== 1) return [];
const $ = await this.getCheerio(this.site);
const novels: Plugin.NovelItem[] = [];
const seen = new Set<string>();
$('.dernieres-tabel tbody tr').each((i, el) => {
const novelUrl = $(el).find('td').first().find('a').attr('href');
if (!novelUrl || seen.has(novelUrl)) return;
seen.add(novelUrl);
novels.push({
name: $(el)
.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');
}
const novels: Plugin.NovelItem[] = [];
const seen = new Set<string>();
for (const catalogue of catalogues) {
if (catalogue.status !== 'fulfilled') continue;
const $ = catalogue.value;
for (const novel of this.parseCards($)) {
if (seen.has(novel.path)) continue;
seen.add(novel.path);
novels.push(novel);
}
}
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const novel: Plugin.SourceNovel = {
path: this.toPath(novelPath),
name: '',
};
const $ = await this.getCheerio(this.site + novel.path);
novel.name = $('h1.refresh-detail-title').first().text().trim();
novel.cover = this.absoluteUrl(
$('.refresh-detail-cover img').attr('src') ||
$('.refresh-detail-cover img').attr('data-src'),
);
novel.summary = $('.refresh-detail-summary-content').text().trim();
$('.refresh-detail-meta > div').each((i, el) => {
const label = $(el).find('dt').text().trim();
const value = $(el).find('dd').text().trim();
if (label.includes('Auteur')) novel.author = value;
else if (label.includes('Statut')) {
const status = value.toLowerCase();
if (status.includes('en pause') || status.includes('hiatus'))
novel.status = NovelStatus.OnHiatus;
else if (status.includes('complet') || status.includes('termin'))
novel.status = NovelStatus.Completed;
else novel.status = NovelStatus.Ongoing;
}
});
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;
}
async parseChapter(chapterUrl: string): Promise<string> {
const $ = await this.getCheerio(
this.site + this.compactChapterPath(chapterUrl),
);
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 content.html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const parents = await Promise.allSettled(
[2, 811].map(async parent => {
const response = await fetchApi(
`${this.site}/wp-json/wp/v2/categories?parent=${parent}&search=${encodeURIComponent(searchTerm)}&per_page=100&page=${pageNo}`,
{ headers: this.restHeaders },
);
if (!response.ok) throw new Error(`Search parent ${parent} failed`);
const categories: unknown = await response.json();
if (
!Array.isArray(categories) ||
!categories.every(
category =>
category !== null &&
typeof category === 'object' &&
typeof (category as WordPressCategory).name === 'string' &&
typeof (category as WordPressCategory).link === 'string',
)
)
throw new Error(`Search parent ${parent} returned invalid data`);
return categories as WordPressCategory[];
}),
);
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');
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 = {
tag: {
type: FilterTypes.Picker,
label: 'Tag',
value: 'all',
options: [
{ label: 'Tous', value: 'all' },
{ label: 'Arts martiaux', value: 'arts-martiaux' },
{ label: 'De faible à fort', value: 'de-faible-a-fort' },
{ label: 'Adapté en manhua', value: 'adapte-en-manhua' },
{ label: 'Cultivation', value: 'cultivation' },
{ label: 'Action', value: 'action' },
{ label: 'Aventure', value: 'aventure' },
{ label: 'Monstres', value: 'monstres' },
{ label: 'Xuanhuan', value: 'xuanhuan' },
{ label: 'Fantastique', value: 'fantastique' },
{ label: 'Adapté en Animé', value: 'adapte-en-anime' },
{ label: 'Alchimie', value: 'alchimie' },
{ label: 'Éléments de jeux', value: 'elements-de-jeux' },
{ label: 'Calme Protagoniste', value: 'calme-protagoniste' },
{
label: 'Protagoniste intelligent',
value: 'protagoniste-intelligent',
},
{ label: 'Polygamie', value: 'polygamie' },
{ label: 'Belle femelle Lea', value: 'belle-femelle-lea' },
{ label: 'Personnages arrogants', value: 'personnages-arrogants' },
{ label: 'Système de niveau', value: 'systeme-de-niveau' },
{ label: 'Cheat', value: 'cheat' },
{ label: 'Protagoniste génie', value: 'protagoniste-genie' },
{ label: 'Comédie', value: 'comedie' },
{ label: 'Gamer', value: 'gamer' },
{ label: 'Mariage', value: 'mariage' },
{ label: 'seeking Protag', value: 'seeking-protag' },
{ label: 'Romance précoce', value: 'romance-precoce' },
{ label: 'Croissance accélérée', value: 'croissance-acceleree' },
{ label: 'Artefacts', value: 'artefacts' },
{
label: 'Intelligence artificielle',
value: 'intelligence-artificielle',
},
{ label: 'Mariage arrangé', value: 'mariage-arrange' },
{ label: 'Mature', value: 'mature' },
{ label: 'Adulte', value: 'adulte' },
{
label: 'Administrateur de système',
value: 'administrateur-de-systeme',
},
{ label: 'Beau protagoniste', value: 'beau-protagoniste' },
{
label: 'Protagoniste charismatique',
value: 'protagoniste-charismatique',
},
{ label: 'Protagoniste masculin', value: 'protagoniste-masculin' },
{ label: 'Démons', value: 'demons' },
{ label: 'Reincarnation', value: 'reincarnation' },
{ label: 'Académie', value: 'academie' },
{
label: 'Cacher les vraies capacités',
value: 'cacher-les-vraies-capacites',
},
{
label: 'Protagoniste surpuissant',
value: 'protagoniste-surpuissant',
},
{ label: 'Joueur', value: 'joueur' },
{
label: 'Protagoniste fort dès le départ',
value: 'protagoniste-fort-des-le-depart',
},
{ label: 'Immortels', value: 'immortels' },
{ label: 'Cultivation rapide', value: 'cultivation-rapide' },
{ label: 'Harem', value: 'harem' },
{ label: 'Assasins', value: 'assasins' },
{ label: 'De pauvre à riche', value: 'de-pauvre-a-riche' },
{
label: 'Système de classement de jeux',
value: 'systeme-de-classement-de-jeux',
},
{ label: 'Capacités spéciales', value: 'capacites-speciales' },
{ label: 'Vengeance', value: 'vengeance' },
],
},
} satisfies Filters;
}
export default new ChireadsPlugin();