diff --git a/plugins/english/puffinFolio.ts b/plugins/english/puffinFolio.ts new file mode 100644 index 000000000..21b0c0eda --- /dev/null +++ b/plugins/english/puffinFolio.ts @@ -0,0 +1,143 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters } from '@libs/filterInputs'; +import { load as loadCheerio } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +class PuffinFolioPlugin implements Plugin.PluginBase { + id = 'puffinfolio'; + name = 'Puffin Folio'; + icon = 'src/en/puffinfolio/icon.png'; + site = 'https://www.puffinfolio.com/'; + version = '1.0.0'; + filters: Filters | undefined = undefined; + imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; + + private async loadPage(path: string) { + const response = await fetchApi(this.site + path.replace(/^\//, '')); + return loadCheerio(await response.text()); + } + + // Covers are served through next/image, so the real file sits in a url= param. + private resolveCover(raw?: string): string { + if (!raw) return defaultCover; + const candidate = raw.trim().split(/\s+/)[0]; + const embedded = candidate.match(/url=([^&]+)/); + const file = embedded ? decodeURIComponent(embedded[1]) : candidate; + if (/^https?:\/\//.test(file)) return file; + return ( + this.site.replace(/\/$/, '') + (file.startsWith('/') ? file : '/' + file) + ); + } + + private async novelList(): Promise { + const $ = await this.loadPage('browse'); + const novels: Plugin.NovelItem[] = []; + + $('a[href^="/novel/"]').each((_, element) => { + const link = $(element); + const href = link.attr('href'); + if (!href || href.includes('/chapter/')) return; + + const image = link.find('img').first(); + const name = + link.find('h3').first().text().trim() || image.attr('alt')?.trim(); + if (!name) return; + + const path = href.replace(/^\//, ''); + if (novels.some(novel => novel.path === path)) return; + + novels.push({ + name, + path, + cover: this.resolveCover(image.attr('src') || image.attr('srcset')), + }); + }); + + return novels; + } + + async popularNovels(pageNo: number): Promise { + // The catalogue is small enough to sit on a single browse page. + if (pageNo > 1) return []; + return this.novelList(); + } + + async parseNovel(novelPath: string): Promise { + const $ = await this.loadPage(novelPath); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: $('h1').first().text().trim() || 'Untitled', + }; + + const cover = $('img[src*="covers"], img[srcset*="covers"]').first(); + novel.cover = this.resolveCover(cover.attr('src') || cover.attr('srcset')); + + novel.summary = $('meta[name="description"]').attr('content')?.trim(); + + novel.genres = $('a[href^="/browse?tag="]') + .map((_, element) => $(element).text().trim()) + .get() + .filter(Boolean) + .join(','); + + const statusText = $('span') + .map((_, element) => $(element).text().trim()) + .get() + .find(text => text === 'Ongoing' || text === 'Completed'); + novel.status = + statusText === 'Completed' + ? NovelStatus.Completed + : statusText === 'Ongoing' + ? NovelStatus.Ongoing + : NovelStatus.Unknown; + + const chapters: Plugin.ChapterItem[] = []; + $('li a[href*="/chapter/"]').each((_, element) => { + const link = $(element); + const href = link.attr('href'); + if (!href) return; + + const path = href.replace(/^\//, ''); + if (chapters.some(chapter => chapter.path === path)) return; + + const time = link.find('time'); + // Strip the leading chapter-number badge so the name reads "Chapter 7". + const label = link.find('span').first().clone(); + label.find('span').remove(); + + chapters.push({ + name: label.text().trim() || href.split('/').pop() || '', + path, + releaseTime: time.attr('datetime') || time.attr('title') || null, + chapterNumber: Number(href.match(/\/chapter\/(\d+)/)?.[1]) || undefined, + }); + }); + + novel.chapters = chapters.sort( + (a, b) => (a.chapterNumber ?? 0) - (b.chapterNumber ?? 0), + ); + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const $ = await this.loadPage(chapterPath); + return $('.reader-prose').html() || ''; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (pageNo > 1) return []; + const term = searchTerm.toLowerCase(); + const novels = await this.novelList(); + return novels.filter(novel => novel.name.toLowerCase().includes(term)); + } + + resolveUrl = (path: string) => this.site + path.replace(/^\//, ''); +} + +export default new PuffinFolioPlugin(); diff --git a/public/static/src/en/puffinfolio/icon.png b/public/static/src/en/puffinfolio/icon.png new file mode 100644 index 000000000..a71199a31 Binary files /dev/null and b/public/static/src/en/puffinfolio/icon.png differ