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:
@@ -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();
|
||||
Reference in New Issue
Block a user