refactor: Overhaul Plugin Architecture & Playground UI (#1846)

* refactor: Move Plugins Out and Consolidate with Multisrc

* feat: Overhaul Playground UI

* feat: Add Filters to New UI

* feat: Persist Selected Plugin

* feat: Add Direct Parse Novel/Chapter Options and Feedback Toasts

[skip ci]

* feat: UX Improvements

* chore: Update Plugin Path in Publish Workflow

* fix: Add Backward Compatibility

* chore: Update Plugin Publishing Script
This commit is contained in:
Rajarshee Chatterjee
2025-10-20 22:54:08 +05:30
committed by GitHub
parent 252beff0bf
commit 20c2b75b01
363 changed files with 6848 additions and 3007 deletions
+33
View File
@@ -0,0 +1,33 @@
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const folder = dirname(fileURLToPath(import.meta.url));
import list from './sources.json' with { type: 'json' };
export const generateAll = function () {
return list.map(source => {
console.log(
`[fictioneer] Generating: ${source.id}${' '.repeat(20 - source.id.length)}`,
);
return generator(source);
});
};
const generator = function generator(source) {
const readNovelFullTemplate = readFileSync(join(folder, 'template.ts'), {
encoding: 'utf-8',
});
const pluginScript = `
${readNovelFullTemplate}
const plugin = new FictioneerPlugin(${JSON.stringify(source)});
export default plugin;
`.trim();
return {
lang: source.options?.lang || 'English',
filename: source.sourceName,
pluginScript,
};
};
+51
View File
@@ -0,0 +1,51 @@
[
{
"id": "daoistquest",
"sourceSite": "https://daoist.quest",
"sourceName": "Daoist Quest",
"options": {
"browsePage": "collection/novels",
"versionIncrements": 1
}
},
{
"id": "novelib",
"sourceSite": "https://novelib.com",
"sourceName": "NovelLib",
"options": {
"browsePage": "browse"
}
},
{
"id": "lilyonthevalley",
"sourceSite": "https://lilyonthevalley.com",
"sourceName": "Lily on the Valley",
"options": {
"browsePage": "stories"
}
},
{
"id": "penguinsquad",
"sourceSite": "https://penguin-squad.com",
"sourceName": "Penguin Squad",
"options": {
"browsePage": "novels"
}
},
{
"id": "prizmatranslation",
"sourceSite": "https://prizmatranslation.com",
"sourceName": "Prizma",
"options": {
"browsePage": "home/all-novels"
}
},
{
"id": "dearestrosalie",
"sourceSite": "https://dearestrosalie.com",
"sourceName": "Dearest Rosalie",
"options": {
"browsePage": "stories"
}
}
]
+171
View File
@@ -0,0 +1,171 @@
import { CheerioAPI, load as loadCheerio, load } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { NovelStatus } from '@libs/novelStatus';
import { Filters } from '@libs/filterInputs';
type FictioneerOptions = {
browsePage: string;
lang?: string;
versionIncrements?: number;
};
export type FictioneerMetadata = {
id: string;
sourceSite: string;
sourceName: string;
options: FictioneerOptions;
};
class FictioneerPlugin implements Plugin.PluginBase {
id: string;
name: string;
icon: string;
site: string;
version: string;
options: FictioneerOptions;
filters: Filters | undefined = undefined;
constructor(metadata: FictioneerMetadata) {
this.id = metadata.id;
this.name = metadata.sourceName;
this.icon = `multisrc/fictioneer/${metadata.id.toLowerCase()}/icon.png`;
this.site = metadata.sourceSite;
const versionIncrements = metadata.options?.versionIncrements || 0;
this.version = `1.0.${0 + versionIncrements}`;
this.options = metadata.options;
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const req = await fetchApi(
this.site +
'/' +
this.options.browsePage +
'/' +
(pageNo === 1 ? '' : 'page/' + pageNo + '/'),
);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
return loadedCheerio(
'#featured-list > li > div > div, #list-of-stories > li > div > div',
)
.map((i, el) => {
const novelName = loadedCheerio(el).find('h3 > a').text();
const novelCover = loadedCheerio(el)
.find('a.cell-img:has(img)')
.attr('href');
const novelUrl = loadedCheerio(el).find('h3 > a').attr('href');
return {
name: novelName,
cover: novelCover,
path: novelUrl!.replace(this.site + '/', '').replace(/\/$/, ''),
};
})
.toArray();
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const req = await fetchApi(this.site + '/' + novelPath + '/');
const body = await req.text();
const loadedCheerio = loadCheerio(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('h1.story__identity-title').text(),
};
// novel.artist = '';
novel.author = loadedCheerio('div.story__identity-meta')
.text()
.split('|')[0]
.replace('Author: ', '')
.replace('by ', '')
.trim();
novel.cover = loadedCheerio('figure.story__thumbnail > a').attr('href');
novel.genres = loadedCheerio('div.tag-group > a, section.tag-group > a')
.map((i, el) => loadedCheerio(el).text())
.toArray()
.join(',');
novel.summary = loadedCheerio('section.story__summary').text();
novel.chapters = loadedCheerio('li.chapter-group__list-item._publish')
.filter((i, el) => !el.attribs['class'].includes('_password'))
.filter(
(i, el) =>
!loadedCheerio(el)
.find('i')
.first()!
.attr('class')!
.includes('fa-lock'),
)
.map((i, el) => {
const chapterName = loadedCheerio(el).find('a').text();
const chapterUrl = loadedCheerio(el)
.find('a')
.attr('href')
?.replace(this.site + '/', '')
.replace(/\/$/, '');
return {
name: chapterName,
path: chapterUrl!,
};
})
.toArray();
const status = loadedCheerio('span.story__status').text().trim();
if (status === 'Ongoing') novel.status = NovelStatus.Ongoing;
if (status === 'Completed') novel.status = NovelStatus.Completed;
if (status === 'Cancelled') novel.status = NovelStatus.Cancelled;
if (status === 'Hiatus') novel.status = NovelStatus.OnHiatus;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const req = await fetchApi(this.site + '/' + chapterPath + '/');
const body = await req.text();
const loadedCheerio = loadCheerio(body);
return loadedCheerio('section#chapter-content > div').html() || '';
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const req = await fetchApi(
this.site +
`/${pageNo === 1 ? '' : 'page/' + pageNo + '/'}?s=${encodeURIComponent(searchTerm)}&post_type=fcn_story`,
);
const body = await req.text();
const loadedCheerio = loadCheerio(body);
return loadedCheerio('#search-result-list > li > div > div')
.map((i, el) => {
const novelName = loadedCheerio(el).find('h3 > a').text();
const novelCover = loadedCheerio(el)
.find('a.cell-img:has(img)')
.attr('href');
const novelUrl = loadedCheerio(el).find('h3 > a').attr('href');
return {
name: novelName,
cover: novelCover,
path: novelUrl!.replace(this.site + '/', '').replace(/\/$/, ''),
};
})
.toArray();
}
resolveUrl = (path: string, isNovel?: boolean) =>
this.site + '/' + path + '/';
}