import { CheerioAPI, load } from 'cheerio'; import { Plugin } from '@/types/plugin'; import { fetchApi } from '@libs/fetch'; import { NovelStatus } from '@libs/novelStatus'; import dayjs, { ManipulateType } from 'dayjs'; type NovelJSON = { success: boolean; data: { posts: { title: string; thumbnail: string; permalink: string; }[]; }; }; type ChapterJSON = { success: boolean; data: { title: string; url: string; slug: string; date: string; }[]; }; class StorySeedlingPlugin implements Plugin.PluginBase { id = 'storyseedling'; name = 'StorySeedling'; icon = 'src/en/storyseedling/icon.png'; site = 'https://storyseedling.com/'; version = '1.0.6'; nonce: string | undefined; async getCheerio(url: string, search: boolean): Promise { const r = await fetchApi(url); if (!r.ok && search != true) throw new Error( 'Could not reach site (' + r.status + ') try to open in webview.', ); const $ = load(await r.text()); return $; } private parseAgoDate(date: string | undefined) { //parseMadaraDate const parsed = dayjs(date); if (date && parsed.isValid()) { return parsed.toISOString(); } const [amt, time, ago] = date?.toLowerCase().trim().split(/\s+/) || []; const decade = time?.includes('decade'); // dayjs no support, but just in case const amount = (amt === 'a' || amt === 'an' ? 1 : +amt) * (decade ? 10 : 1); const unit = (decade ? 'year' : time) as ManipulateType; const validUnits = [ 'millisecond', // waow 'second', 'minute', 'hour', 'day', 'week', 'month', 'year', ]; if (ago !== 'ago' || isNaN(amount) || !validUnits.includes(unit)) { return null; } return dayjs().subtract(amount, unit).toISOString(); } private async getNovels( pageNo: number, searchTerm = '', ): Promise { const body = await fetchApi(this.site + 'browse').then(r => r.text()); const loadedCheerio = load(body); const postValue = loadedCheerio('div[ax-load][x-data]') .attr('x-data') ?.replace("browse('", '') .replace("')", '') as string; const data = new FormData(); data.append('search', searchTerm); data.append('orderBy', 'recent'); data.append('curpage', pageNo.toString()); data.append('post', postValue); data.append('action', 'fetch_browse'); const response = (await fetchApi(this.site + 'ajax', { body: data, method: 'POST', }).then(res => res.json())) as NovelJSON; const novels: Plugin.NovelItem[] = response.data.posts.map(element => ({ name: element.title, cover: element.thumbnail, path: element.permalink.replace(this.site, ''), })); return novels; } async popularNovels(pageNo: number): Promise { return this.getNovels(pageNo); } async parseNovel(novelPath: string): Promise { const $ = await this.getCheerio(this.site + novelPath, false); const novel: Partial = { path: novelPath, }; novel.name = $('h1').text().trim(); const coverUrl = $('img[x-ref="art"].w-full.rounded.shadow-md').attr('src'); if (coverUrl) { novel.cover = new URL(coverUrl, this.site).href; } const genres: string[] = []; $( 'section[x-data="{ tab: location.hash.substr(1) || \'chapters\' }"].relative > div > div > div.flex.flex-wrap > a', ).each(function () { genres.push($(this).text().trim()); }); novel.genres = genres.join(', '); novel.author = $('div.mb-1 a').text().trim(); const rawStatus = $('div.gap-2 span.text-sm').text().trim(); const map: Record = { ongoing: NovelStatus.Ongoing, hiatus: NovelStatus.OnHiatus, dropped: NovelStatus.Cancelled, cancelled: NovelStatus.Cancelled, completed: NovelStatus.Completed, }; novel.status = map[rawStatus.toLowerCase()] ?? NovelStatus.Unknown; const summaryDiv = $('div.mb-4.order-2:not(.lg\\:grid-in-buttons)'); const pTagSummary = summaryDiv.find('p'); novel.summary = pTagSummary.length > 0 ? pTagSummary .map((_, el) => $(el).text().trim()) .get() .join('\n\n') : summaryDiv.text().trim(); // --- Else (no

tags) --- const chapters: Plugin.ChapterItem[] = []; const xdata = $('.bg-accent div[ax-load][x-data]').attr('x-data'); // expected data format: toc('000000', 'xxxxxxxxxx') (6 numbers, 10 hex) if (xdata) { const listXdata = xdata?.split("'"); const dataNovelId = listXdata[1]; const dataNovelN = listXdata[3]; const idMatch = novelPath.match(/\d+/); // Error checking in case of extraneous slashes const novelId = dataNovelId || (idMatch ? idMatch[0] : null); if (novelId) { const chapterListing = 'ajax'; // Currently URL is only ajax const formData = new FormData(); formData.append('post', dataNovelN); formData.append('id', dataNovelId); formData.append('action', 'series_toc'); const chaptersUrl = `${this.site}${chapterListing}`; const refererUrl = `${this.site}${novel.path}`; const results: ChapterJSON = await fetchApi(chaptersUrl, { method: 'POST', referrer: refererUrl, referrerPolicy: 'origin', body: formData, }) .then(r => r.json()) .catch( e => (novel.summary = 'Chapter Parse Error: ' + e + '\n\n' + novel.summary), ); if (results.data) { results.data.forEach(chap => { if (chap.url == null) { return; } const name = chap.title; const url = chap.url as string; const chapterNumber = chap.slug; chapters.push({ name: name, path: url.replace(this.site, ''), releaseTime: this.parseAgoDate(chap.date), chapterNumber: parseInt(chapterNumber), }); }); } } } novel.chapters = chapters; return novel as Plugin.SourceNovel; } async updateNonce(chapterPath: string) { const $ = await this.getCheerio(this.site + chapterPath, false); this.nonce = $('div.mb-4:has(h1.text-xl) > div') .attr('x-data') ?.match(/loadChapter\('.+?', '(.+?)'\)/)![1]; } async parseChapter(chapterPath: string): Promise { const updatedNonce = !this.nonce; if (!this.nonce) await this.updateNonce(chapterPath); const text = await fetchApi(this.site + chapterPath + '/content', { method: 'POST', headers: { 'referrer': this.site + chapterPath + '/', 'x-nonce': this.nonce, }, body: JSON.stringify({ 'captcha_response': '' }), }).then(r => r.text()); let textJson; try { textJson = JSON.parse(text); } catch (_) { //not json :fire: we have chapter } if (textJson && !textJson.success) { if (textJson.message === 'Invalid security.') { if (updatedNonce) { throw new Error(`Failed to find code!`); } this.nonce = ''; return await this.parseChapter(chapterPath); } if (textJson.captcha) { throw new Error( `Failed to bypass turnstile captcha (read in webview until it stops ig)`, ); } } const html = text .replace(/cls[a-f0-9]+/g, '') .split('') .map(char => { const code = char.charCodeAt(0); const offset = code > 12123 ? 12027 : 12033; const decoded = code - offset; return decoded >= 32 && decoded <= 126 ? String.fromCharCode(decoded) : char; }) .join(''); const $ = load(html); $('span').text((_, txt) => txt.toLowerCase().includes('storyseedling') || txt.toLowerCase().includes('story seedling') ? '' : txt, ); return $.html(); } async searchNovels( searchTerm: string, pageNo: number, ): Promise { return this.getNovels(pageNo, searchTerm); } } export default new StorySeedlingPlugin();