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
+349
View File
@@ -0,0 +1,349 @@
import { load as loadCheerio } from 'cheerio';
import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { defaultCover } from '@libs/defaultCover';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { NovelStatus } from '@libs/novelStatus';
// const novelStatus = require('@/types/constants');
// const isUrlAbsolute = require('@/lib/utils');
// const parseDate = require('@libs/parseDate');
class Syosetu implements Plugin.PluginBase {
id = 'yomou.syosetu';
name = 'Syosetu';
icon = 'src/jp/syosetu/icon.png';
site = 'https://yomou.syosetu.com/';
novelPrefix = 'https://ncode.syosetu.com';
version = '1.1.2';
headers = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
};
searchUrl = (pagenum?: number, order?: string) => {
return `${this.site}search.php?order=${order || 'hyoka'}${
pagenum !== undefined
? `&p=${pagenum <= 1 || pagenum > 100 ? '1' : pagenum}` // check if pagenum is between 1 and 100
: '' // if isn't don't set ?p
}`;
};
async popularNovels(
pageNo: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const getNovelsFromPage = async (pagenumber: number) => {
// load page
let url = this.site;
if (!filters.genre.value) {
url += `rank/list/type/${filters.ranking.value}_${filters.modifier.value}/?p=${pagenumber}`;
} else {
url += `rank/${
filters.genre.value.length === 1 ? 'isekailist' : 'genrelist'
}/type/${filters.ranking.value}_${filters.genre.value}${
filters.modifier.value === 'total' ? '' : `_${filters.modifier.value}`
}/?p=${pagenumber}`;
}
const html = await (await fetchApi(url)).text();
const loadedCheerio = loadCheerio(html, {
decodeEntities: false,
});
if (parseInt(loadedCheerio('.is-current').html() || '1') !== pagenumber)
return [];
const novels: Plugin.NovelItem[] = [];
loadedCheerio('.c-card').each((_, e) => {
const anchor = loadedCheerio(e).find('.p-ranklist-item__title a');
const url = anchor.attr('href');
if (!url) return;
const name = anchor.text();
const novel: Plugin.NovelItem = {
path: url.replace(this.novelPrefix, ''),
name,
cover: defaultCover,
};
novels.push(novel);
});
return novels;
};
const novels = await getNovelsFromPage(pageNo);
return novels;
}
private async parseChaptersFromPage(
loadedCheerio: cheerio.CheerioAPI,
): Promise<Plugin.ChapterItem[]> {
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio('.p-eplist__sublist').each((_, element) => {
const chapterLink = loadedCheerio(element).find('a');
const chapterUrl = chapterLink.attr('href');
const chapterName = chapterLink.text().trim();
const releaseDate = loadedCheerio(element)
.find('.p-eplist__update')
.text()
.trim()
.split(' ')[0]
.replace(/\//g, '-');
if (chapterUrl) {
chapters.push({
name: chapterName,
releaseTime: releaseDate,
path: chapterUrl.replace(this.novelPrefix, ''),
});
}
});
return chapters;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
// First fetch main page
const result = await fetchApi(this.novelPrefix + novelPath, {
headers: this.headers,
});
const body = await result.text();
const loadedCheerio = loadCheerio(body, { decodeEntities: false });
// Parse status
let status = 'Unknown';
if (
loadedCheerio('.c-announce').text().includes('連載中') ||
loadedCheerio('.c-announce').text().includes('未完結')
) {
status = NovelStatus.Ongoing;
} else if (
loadedCheerio('.c-announce').text().includes('更新されていません')
) {
status = NovelStatus.OnHiatus;
} else if (loadedCheerio('.c-announce').text().includes('完結')) {
status = NovelStatus.Completed;
}
// Create novel object with metadata
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('.p-novel__title').text(),
author: loadedCheerio('.p-novel__author')
.text()
.replace('作者:', '')
.trim(),
status: status,
artist: '',
cover: defaultCover,
chapters: [],
genres: loadedCheerio('meta[property="og:description"]')
.attr('content')
?.split(' ')
.join(','), // Get genres from meta tag
};
// Get summary if available
novel.summary = loadedCheerio('#novel_ex').html() || '';
const chapters: Plugin.ChapterItem[] = [];
// Get last page URL first
const lastPageLink = loadedCheerio('.c-pager__item--last').attr('href');
if (!lastPageLink) {
// If no pagination, just parse chapters from the current page
loadedCheerio('.p-eplist__sublist').each((_, element) => {
const chapterLink = loadedCheerio(element).find('a');
const chapterUrl = chapterLink.attr('href');
const chapterName = chapterLink.text().trim();
const releaseDate = loadedCheerio(element)
.find('.p-eplist__update')
.text()
.trim()
.split(' ')[0]
.replace(/\//g, '-');
if (chapterUrl) {
chapters.push({
name: chapterName,
releaseTime: releaseDate,
path: chapterUrl.replace(this.novelPrefix, ''),
});
}
});
} else {
const lastPageMatch = lastPageLink.match(/\?p=(\d+)/);
const totalPages = lastPageMatch ? parseInt(lastPageMatch[1]) : 1;
// Fetch all pages in parallel for better performance
const pagePromises = Array.from({ length: totalPages }, (_, i) =>
fetchApi(`${this.novelPrefix}${novelPath}?p=${i + 1}`).then(r =>
r.text(),
),
);
const pageResults = await Promise.all(pagePromises);
// Process each page's chapters
pageResults.forEach(pageBody => {
const pageCheerio = loadCheerio(pageBody, { decodeEntities: false });
pageCheerio('.p-eplist__sublist').each((_, element) => {
const chapterLink = pageCheerio(element).find('a');
const chapterUrl = chapterLink.attr('href');
const chapterName = chapterLink.text().trim();
const releaseDate = pageCheerio(element)
.find('.p-eplist__update')
.text()
.trim()
.split(' ')[0]
.replace(/\//g, '-');
if (chapterUrl) {
chapters.push({
name: chapterName,
releaseTime: releaseDate,
path: chapterUrl.replace(this.novelPrefix, ''),
});
}
});
});
}
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const result = await fetchApi(this.novelPrefix + chapterPath, {
headers: this.headers,
});
const body = await result.text();
const cheerioQuery = loadCheerio(body, {
decodeEntities: false,
});
// Get the chapter title
const chapterTitle = cheerioQuery('.p-novel__title').html() || '';
// Get the chapter content
const chapterContent =
cheerioQuery(
'.p-novel__body .p-novel__text:not([class*="p-novel__text--"])',
).html() || '';
// Combine title and content with proper HTML structure
return `<h1>${chapterTitle}</h1>${chapterContent}`;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
let novels = [];
// returns list of novels from given page
const getNovelsFromPage = async (pagenumber: number) => {
// load page
const url = this.searchUrl(pagenumber) + `&word=${searchTerm}`;
const result = await fetchApi(url, { headers: this.headers });
const body = await result.text();
// Cheerio it!
const cheerioQuery = loadCheerio(body, { decodeEntities: false });
const pageNovels: Plugin.NovelItem[] = [];
// find class=searchkekka_box
cheerioQuery('.searchkekka_box').each((i, e) => {
// get div with link and name
const novelDIV = cheerioQuery(e).find('.novel_h');
// get link element
const novelA = novelDIV.children()[0];
// add new novel to array
const novelPath = novelA.attribs.href.replace(this.novelPrefix, '');
if (novelPath) {
pageNovels.push({
name: novelDIV.text(), // get the name
path: novelPath, // get last part of the link
cover: defaultCover,
});
}
});
// return all novels from this page
return pageNovels;
};
// counter of loaded pages
// let pagesLoaded = 0;
// do {
// // always load first one
// novels.push(...(await getNovelsFromPage(pagesLoaded + 1)));
// pagesLoaded++;
// } while (pagesLoaded < maxPageLoad && isNext); // check if we should load more
novels = await getNovelsFromPage(pageNo);
/** Use
* novels.push(...(await getNovelsFromPage(pageNumber)))
* if you want to load more
*/
// respond with novels!
return novels;
}
resolveUrl(path: string): string {
return this.novelPrefix + path;
}
filters = {
ranking: {
type: FilterTypes.Picker,
label: 'Ranked by',
options: [
{ label: '日間', value: 'daily' },
{ label: '週間', value: 'weekly' },
{ label: '月間', value: 'monthly' },
{ label: '四半期', value: 'quarter' },
{ label: '年間', value: 'yearly' },
{ label: '累計', value: 'total' },
],
value: 'total',
},
genre: {
type: FilterTypes.Picker,
label: 'Ranking Genre',
options: [
{ label: '総ジャンル', value: '' },
{ label: '異世界転生/転移〔恋愛〕〕', value: '1' },
{ label: '異世界転生/転移〔ファンタジー〕', value: '2' },
{ label: '異世界転生/転移〔文芸・SF・その他〕', value: 'o' },
{ label: '異世界〔恋愛〕', value: '101' },
{ label: '現実世界〔恋愛〕', value: '102' },
{ label: 'ハイファンタジー〔ファンタジー〕', value: '201' },
{ label: 'ローファンタジー〔ファンタジー〕', value: '202' },
{ label: '純文学〔文芸〕', value: '301' },
{ label: 'ヒューマンドラマ〔文芸〕', value: '302' },
{ label: '歴史〔文芸〕', value: '303' },
{ label: '推理〔文芸〕', value: '304' },
{ label: 'ホラー〔文芸〕', value: '305' },
{ label: 'アクション〔文芸〕', value: '306' },
{ label: 'コメディー〔文芸〕', value: '307' },
{ label: 'VRゲーム〔SF〕', value: '401' },
{ label: '宇宙〔SF〕', value: '402' },
{ label: '空想科学〔SF〕', value: '403' },
{ label: 'パニック〔SF〕', value: '404' },
{ label: '童話〔その他〕', value: '9901' },
{ label: '詩〔その他〕', value: '9902' },
{ label: 'エッセイ〔その他〕', value: '9903' },
{ label: 'その他〔その他〕', value: '9999' },
],
value: '',
},
modifier: {
type: FilterTypes.Picker,
label: 'Modifier',
options: [
{ label: 'すべて', value: 'total' },
{ label: '連載中', value: 'r' },
{ label: '完結済', value: 'er' },
{ label: '短編', value: 't' },
],
value: 'total',
},
} satisfies Filters;
}
export default new Syosetu();
+277
View File
@@ -0,0 +1,277 @@
import { fetchText } from '@/lib/fetch';
import { Plugin } from '@/types/plugin';
import { Filters, FilterTypes } from '@libs/filterInputs';
import { load as loadCheerio } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
import { NovelStatus } from '@libs/novelStatus';
class KakuyomuPlugin implements Plugin.PluginBase {
id = 'kakuyomu';
name = 'kakuyomu';
icon = 'src/jp/kakuyomu/icon.png';
site = 'https://kakuyomu.jp';
version = '1.0.0';
filters = {
genre: {
type: FilterTypes.Picker,
label: 'Genre',
options: [
{ label: '総合', value: 'all' },
{ label: '異世界ファンタジー', value: 'fantasy' },
{ label: '現代ファンタジー', value: 'action' },
{ label: 'SF', value: 'sf' },
{ label: '恋愛', value: 'love_story' },
{ label: 'ラブコメ', value: 'romance' },
{ label: '現代ドラマ', value: 'drama' },
{ label: 'ホラー', value: 'horror' },
{ label: 'ミステリー', value: 'mystery' },
{ label: 'エッセイ・ノンフィクション', value: 'nonfiction' },
{ label: '歴史・時代・伝奇', value: 'history' },
{ label: '創作論・評論', value: 'criticism' },
{ label: '詩・童話・その他', value: 'others' },
],
value: 'all',
},
period: {
type: FilterTypes.Picker,
label: 'Period',
options: [
{ label: '累計', value: 'entire' },
{ label: '日間', value: 'daily' },
{ label: '週間', value: 'weekly' },
{ label: '月間', value: 'monthly' },
{ label: '年間', value: 'yearly' },
],
value: 'entire',
},
} satisfies Filters;
imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined;
//flag indicates whether access to LocalStorage, SesesionStorage is required.
webStorageUtilized?: boolean;
async popularNovels(
pageNo: number,
{ filters }: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
const url = new URL(
`/rankings/${filters.genre.value}/${filters.period.value}`,
this.site,
);
if (pageNo > 1) {
url.searchParams.set('page', pageNo.toString());
}
const html = await fetchText(url.toString());
const $ = loadCheerio(html);
const novels: Plugin.NovelItem[] = [];
$('.widget-media-genresWorkList-right > .widget-work').each((_, elem) => {
const anchor = $(elem).find('a.widget-workCard-titleLabel');
const path = anchor.attr('href');
if (!path) return;
const name = anchor.text();
novels.push({
name,
path,
cover: defaultCover,
});
});
return novels;
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = new URL(novelPath, this.site);
const html = await fetchText(url.toString());
const $ = loadCheerio(html);
const json = JSON.parse(
$('script#__NEXT_DATA__[type="application/json"]').html() || '{}',
);
const apolloState = json?.props?.pageProps?.__APOLLO_STATE__ ?? {};
const work = Object.values(apolloState).find(
v =>
typeof v === 'object' &&
v !== null &&
'__typename' in v &&
v.__typename === 'Work' &&
'id' in v &&
v.id === novelPath.replace('/works/', ''),
) as Work;
const author = Object.values(apolloState).find(
v =>
typeof v === 'object' &&
v !== null &&
'__typename' in v &&
v.__typename === 'UserAccount' &&
'id' in v &&
v.id === work.author.__ref.replace('UserAccount:', ''),
) as UserAccount;
const chapters = Object.values(apolloState).filter(v => {
if (
typeof v === 'object' &&
v !== null &&
'__typename' in v &&
v.__typename === 'Chapter'
) {
return true;
}
}) as Chapter[];
const tableOfContentsChapter = Object.values(apolloState).filter(v => {
if (
typeof v === 'object' &&
v !== null &&
'__typename' in v &&
v.__typename === 'TableOfContentsChapter'
) {
return true;
}
}) as TableOfContentsChapter[];
const episodes = Object.values(apolloState).filter(v => {
if (
typeof v === 'object' &&
v !== null &&
'__typename' in v &&
v.__typename === 'Episode'
) {
return true;
}
}) as Episode[];
const joinChapters: {
chapter: Chapter | undefined;
episode: Episode;
}[] = episodes.map(v => {
const chapter = tableOfContentsChapter.find(c =>
c.episodeUnions.some(e => e.__ref === `Episode:${v.id}`),
)?.chapter;
return {
chapter: chapters.find(
c => c.id === chapter?.__ref.replace('Chapter:', ''),
),
episode: v,
};
});
const novel: Plugin.SourceNovel = {
path: novelPath,
name: work.title,
cover: work.adminCoverImageUrl ?? defaultCover,
genres: (work?.tagLabels ?? []).join(','),
author: author?.activityName,
status:
work.serialStatus === 'COMPLETED'
? NovelStatus.Completed
: NovelStatus.Ongoing,
summary: work.introduction,
chapters: joinChapters.map(v => {
return {
name: v.chapter?.title
? `${v.chapter?.title} - ${v.episode?.title}`
: v.episode?.title ?? '',
path: `${novelPath}/episodes/${v.episode?.id}`,
releaseTime: new Date(v.episode?.publishedAt ?? 0).toISOString(),
};
}),
};
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const url = new URL(chapterPath, this.site);
const html = await fetchText(url.toString());
const $ = loadCheerio(html);
const chapterTitle = $('.chapterTitle').text() ?? '';
const episodeTitle = $('.widget-episodeTitle').html() ?? '';
const episodeBody = $('.widget-episodeBody').html() ?? '';
const chapterText = `
<div>
${chapterTitle ? `<h1>${chapterTitle}</h1>` : ''}
<h2>${episodeTitle}</h2>
</div>
<p><br><br></p>
${episodeBody}`;
return chapterText;
}
async searchNovels(
searchTerm: string,
pageNo: number,
): Promise<Plugin.NovelItem[]> {
const url = new URL('/search', this.site);
url.searchParams.set('q', searchTerm);
if (pageNo > 1) {
url.searchParams.set('page', pageNo.toString());
}
const html = await fetchText(url.toString());
const $ = loadCheerio(html);
const json = JSON.parse(
$('script#__NEXT_DATA__[type="application/json"]').html() || '{}',
);
const works = Object.values(
json?.props?.pageProps?.__APOLLO_STATE__ ?? {},
).filter(v => {
if (
typeof v === 'object' &&
v !== null &&
'__typename' in v &&
v.__typename === 'Work'
) {
return true;
}
}) as Work[];
const novels: Plugin.NovelItem[] = works.map(v => ({
name: v.title ?? '',
path: `/works/${v.id}`,
cover: v.adminCoverImageUrl ?? defaultCover,
}));
return novels;
}
}
export default new KakuyomuPlugin();
type Work = {
id: string;
title: string;
serialStatus: string;
tagLabels: string[];
introduction: string;
adminCoverImageUrl?: string;
author: {
__ref: string;
};
};
type UserAccount = {
activityName: string;
};
type Chapter = {
id: string;
title: string;
};
type Episode = {
title: string;
id: string;
publishedAt: string;
};
type TableOfContentsChapter = {
chapter: {
__ref: string;
};
episodeUnions: {
__ref: string;
}[];
};