import { fetchApi } from '@libs/fetch'; import { Filters, FilterTypes } from '@libs/filterInputs'; import { Plugin } from '@typings/plugin'; /** * Example for novel API: * https://genesistudio.com/novels/dlh/__data.json?x-sveltekit-invalidated=001 * -> to view novel remove '__data.json?x-sveltekit-invalidated=001' * * Example for chapter API: * https://genesistudio.com/viewer/2443/__data.json?x-sveltekit-invalidated=001 * -> to view chapter remove '__data.json?x-sveltekit-invalidated=001' */ class Genesis implements Plugin.PluginBase { id = 'genesistudio'; name = 'Genesis'; icon = 'src/en/genesis/icon.png'; customCSS = 'src/en/genesis/customCSS.css'; site = 'https://genesistudio.com'; version = '1.1.0'; imageRequestInit?: Plugin.ImageRequestInit | undefined = { headers: { 'referrer': this.site, }, }; async parseNovels(json: any[]): Promise { return json.map((novel: any) => ({ name: novel.novel_title, path: `/novels/${novel.abbreviation}`, cover: novel.cover, })); } async popularNovels( pageNo: number, { showLatestNovels, filters, }: Plugin.PopularNovelsOptions, ): Promise { if (pageNo !== 1) return []; let link = `${this.site}/api/novels/search?`; if (showLatestNovels) { link += 'serialization=All&sort=Recent'; } else { if (filters?.genres.value.length) { link += 'genres=' + filters.genres.value.join('&genres=') + '&'; } if (filters?.storyStatus.value) { link += 'serialization=' + `${filters.storyStatus.value}` + '&'; } if (filters?.sort.value) { link += 'sort=' + `${filters.sort.value}`; } } const json = await fetchApi(link).then(r => r.json()); return this.parseNovels(json); } // Helper function to extract novel data from nodes extractData(nodes: any[]): any { return nodes .filter((node: { type: string }) => node.type === 'data') .map((node: { data: any }) => node.data)[0]; } async parseNovel(novelPath: string): Promise { const url = `${this.site}${novelPath}/__data.json?x-sveltekit-invalidated=001`; // Fetch the novel's data in JSON format const json = await fetchApi(url).then(r => r.json()); const nodes = json.nodes; // Extract the main novel data from the nodes const data = this.extractData(nodes); // Initialize the novel object with default values const novel: Plugin.SourceNovel = { path: novelPath, name: '', cover: '', summary: '', author: '', status: 'Unknown', chapters: [], }; // Parse and assign novel metadata (title, cover, summary, author, etc.) this.populateNovelMetadata(novel, data); // Parse the chapters if available and assign them to the novel object novel.chapters = this.extractChapters(data); return novel; } // Helper function to populate novel metadata populateNovelMetadata(novel: Plugin.SourceNovel, data: any): void { for (const key in data) { const value = data[key]; if ( typeof value === 'object' && value !== null && 'novel_title' in value ) { novel.name = data[value.novel_title] || 'Unknown Title'; novel.cover = data[value.cover] || ''; novel.summary = data[value.synopsis] || ''; novel.author = data[value.author] || 'Unknown Author'; novel.genres = (data[value.genres] as number[]) .map((genreId: number) => data[genreId]) .join(', ') || 'Unknown Genre'; novel.status = value.release_days ? 'Ongoing' : 'Completed'; break; // Break the loop once metadata is found } } } // Helper function to extract and format chapters extractChapters(data: any): Plugin.ChapterItem[] { const chapterKey = 'chapters'; const chapters: Plugin.ChapterItem[] = []; // Iterate over each property in data to find chapter containers for (const key in data) { const value = data[key]; // Look for an object with a 'chapters' key if (value && typeof value === 'object' && chapterKey in value) { const chaptersIndexKey = value[chapterKey]; const chapterData = data[chaptersIndexKey]; if (!chapterData || typeof chapterData !== 'object') continue; // Dynamically get chapter indices from every category (free, premium, etc.) let allChapterIndices: number[] = []; for (const category of Object.keys(chapterData)) { const chapterIndices = data[chapterData[category]]; if (Array.isArray(chapterIndices)) { allChapterIndices = allChapterIndices.concat(chapterIndices); } } // Format each chapter and add only valid ones const formattedChapters = allChapterIndices .map((index: number) => data[index]) .map((chapter: any) => this.formatChapter(chapter, data)) .filter((chapter): chapter is Plugin.ChapterItem => chapter !== null); chapters.push(...formattedChapters); } } return chapters; } // Helper function to format an individual chapter formatChapter(chapter: any, data: any): Plugin.ChapterItem | null { const { id, chapter_title, chapter_number, required_tier, date_created } = chapter; // Destructure from data using computed property names based on chapter keys const { [id]: chapterId, [chapter_title]: chapterTitle, [chapter_number]: chapterNumberVal, [required_tier]: requiredTierVal, [date_created]: dateCreated, } = data; // Validate required fields if ( chapterId && chapterTitle && chapterNumberVal >= 0 && requiredTierVal !== null && dateCreated ) { const chapterNumber = parseInt(String(chapterNumberVal), 10) || 0; const requiredTier = parseInt(String(requiredTierVal), 10) || 0; // Only process chapters that do not require a premium tier if (requiredTier === 0) { return { name: `Chapter ${chapterNumber} - ${chapterTitle}`, path: `/viewer/${chapterId}`, releaseTime: dateCreated, chapterNumber: chapterNumber, }; } } return null; } // // Helper function to extract and format chapters // extractChapters(data: any): Plugin.ChapterItem[] { // for (const key in data) { // const value = data[key]; // // // Change string here if the chapters are stored under a different key // const chapterKey = 'chapters'; // if (typeof value === 'object' && value !== null && chapterKey in value) { // const chapterData = this.decodeData(data[value[chapterKey]]); // // // Object.values will give us an array of arrays (any[][]) // const chapterArrays: any[][] = Object.values(chapterData); // // // Flatten and format the chapters // return chapterArrays.flatMap((chapters: any[]) => { // return chapters // .map((chapter: any) => this.formatChapter(chapter)) // .filter( // (chapter): chapter is Plugin.ChapterItem => chapter !== null, // ); // }); // } // } // // return []; // } // // // Helper function to format an individual chapter // formatChapter(chapter: any): Plugin.ChapterItem | null { // const { id, chapter_title, chapter_number, required_tier, date_created } = // chapter; // // // Ensure required fields are present and valid // if ( // id && // chapter_title && // chapter_number >= 0 && // required_tier !== null && // date_created // ) { // const number = parseInt(chapter_number, 10) || 0; // const requiredTier = parseInt(required_tier, 10) || 0; // // // Only process chapters with a 'requiredTier' of 0 // if (requiredTier === 0) { // return { // name: `Chapter ${number}: ${chapter_title}`, // path: `/viewer/${id}`, // releaseTime: date_created, // chapterNumber: number, // }; // } // } // // return null; // } // // decodeData(code: any) { // const offset = this.getOffsetIndex(code); // const params = this.getDecodeParams(code); // const constant = this.getConstant(code); // const data = this.getStringsArrayRaw(code); // // const getDataAt = (x: number) => data[x - offset]; // // //reshuffle data array // // eslint-disable-next-line no-constant-condition // while (true) { // try { // const some_number = this.applyDecodeParams(params, getDataAt); // if (some_number === constant) break; // else data.push(data.shift()); // } catch (err) { // data.push(data.shift()); // } // } // // return this.getChapterData(code, getDataAt); // } // // getOffsetIndex(code: string) { // // @ts-ignore // const string = /{(\w+)=\1-0x(?[0-9a-f]+);/.exec(code).groups.offset; // return parseInt(string, 16); // } // // /** // * @returns {string[]} // */ // getStringsArrayRaw(code: string) { // // @ts-ignore // let json = /function \w+\(\){var \w+=(?\['.+']);/.exec(code).groups // .array; // // //replace string single quotes with double quotes and add escaped chars // json = json.replace(/'(.+?)'([,\]])/g, (match, p1, p2) => { // return `"${p1.replace(/\\x([0-9a-z]{2})/g, (match: any, p1: string) => { // //hexadecimal unicode escape chars // return String.fromCharCode(parseInt(p1, 16)); // })}"${p2}`; // }); // // return JSON.parse(json); // } // // /** // * @returns {{offset: number, divider: number, negated: boolean}[][]} // */ // getDecodeParams(code: string) { // // @ts-ignore // const jsDecodeInt = /while\(!!\[]\){try{var \w+=(?.+?);/.exec(code) // .groups.code; // const decodeSections = jsDecodeInt.split('+'); // const params = []; // for (const section of decodeSections) { // params.push(this.decodeParamSection(section)); // } // return params; // } // // /** // * @param {string} section // * @returns {{offset: number, divider: number, negated: boolean}[]} // */ // decodeParamSection(section: string) { // const sections = section.split('*'); // const params = []; // for (const section of sections) { // // @ts-ignore // const offsetStr = /parseInt\(\w+\(0x(?[0-9a-f]+)\)\)/.exec( // section, // ).groups.offset; // const offset = parseInt(offsetStr, 16); // // @ts-ignore // const dividerStr = /\/0x(?[0-9a-f]+)/.exec(section).groups // .divider; // const divider = parseInt(dividerStr, 16); // const negated = section.includes('-'); // params.push({ offset, divider, negated }); // } // return params; // } // // getConstant(code: string) { // // @ts-ignore // const constantStr = /}}}\(\w+,0x(?[0-9a-f]+)\),/.exec(code).groups // .constant; // return parseInt(constantStr, 16); // } // // getChapterData( // code: string, // getDataAt: { (x: number): any; (arg0: number): any }, // ) { // let chapterDataStr = // // @ts-ignore // /\),\(function\(\){var \w+=\w+;return(?{.+?});/.exec(code).groups // .data; // // //replace hex with decimal // chapterDataStr = chapterDataStr.replace(/:0x([0-9a-f]+)/g, (match, p1) => { // const hex = parseInt(p1, 16); // return `: ${hex}`; // }); // // //replace ![] with false and !![] with true // chapterDataStr = chapterDataStr // .replace(/:!!\[]/g, ':true') // .replace(/:!\[]/g, ':false'); // // //replace string single quotes with double quotes and add escaped chars // chapterDataStr = chapterDataStr.replace( // /'(.+?)'([,\]}:])/g, // (match, p1, p2) => { // return `"${p1.replace(/\\x([0-9a-z]{2})/g, (match: any, p1: string) => { // //hexadecimal unicode escape chars // return String.fromCharCode(parseInt(p1, 16)); // })}"${p2}`; // }, // ); // // //parse the data getting methods // chapterDataStr = chapterDataStr.replace( // // @ts-ignore // /:\w+\(0x(?[0-9a-f]+)\)/g, // (match, p1) => { // const offset = parseInt(p1, 16); // return `:${JSON.stringify(getDataAt(offset))}`; // }, // ); // // return JSON.parse(chapterDataStr); // } // // /** // * @param {{offset: number, divider: number, negated: boolean}[][]} params // * @param {function(number): string} getDataAt // */ // applyDecodeParams( // params: { offset: number; divider: number; negated: boolean }[][], // getDataAt: { (x: number): any; (arg0: any): string }, // ) { // let res = 0; // for (const paramAdd of params) { // let resInner = 1; // for (const paramMul of paramAdd) { // resInner *= parseInt(getDataAt(paramMul.offset)) / paramMul.divider; // if (paramMul.negated) resInner *= -1; // } // res += resInner; // } // return res; // } async parseChapter(chapterPath: string): Promise { const url = `${this.site}${chapterPath}/__data.json?x-sveltekit-invalidated=001`; // Fetch the novel's data in JSON format const json = await fetchApi(url).then(r => r.json()); const nodes = json.nodes; // Extract the main novel data from the nodes const data = this.extractData(nodes); const contentKey = 'content'; const notesKey = 'notes'; const footnotesKey = 'footnotes'; // Iterate over each property in data to find chapter containers for (const key in data) { const mapping = data[key]; // Look for an object with a 'chapters' key if ( mapping && typeof mapping === 'object' && contentKey in mapping && notesKey in mapping && footnotesKey in mapping ) { // Use the found mapping object to determine the actual keys. const contentMappingKey = mapping[contentKey]; const notesMappingKey = mapping[notesKey]; const footnotesMappingKey = mapping[footnotesKey]; // Retrieve the chapter's content, notes, and footnotes using the mapping. // Provide a fallback for content if needed. const content = data[contentMappingKey] ?? data[19]; const notes = data[notesMappingKey]; const footnotes = data[footnotesMappingKey]; return ( content + (notes ? `Notes
${notes}` : '') + (footnotes ? `Footnotes
${footnotes}` : '') ); } } // If no mapping object was found, return an empty string or handle appropriately. return ''; } async searchNovels( searchTerm: string, pageNo: number, ): Promise { if (pageNo !== 1) return []; const url = `${this.site}/api/novels/search?serialization=All&sort=Popular&title=${searchTerm}`; const json = await fetchApi(url).then(r => r.json()); return this.parseNovels(json); } filters = { sort: { label: 'Sort Results By', value: 'Popular', options: [ { label: 'Popular', value: 'Popular' }, { label: 'Recent', value: 'Recent' }, { label: 'Views', value: 'Views' }, ], type: FilterTypes.Picker, }, storyStatus: { label: 'Status', value: 'All', options: [ { label: 'All', value: 'All' }, { label: 'Ongoing', value: 'Ongoing' }, { label: 'Completed', value: 'Completed' }, ], type: FilterTypes.Picker, }, genres: { label: 'Genres', value: [], options: [ { label: 'Action', value: 'Action' }, { label: 'Comedy', value: 'Comedy' }, { label: 'Drama', value: 'Drama' }, { label: 'Fantasy', value: 'Fantasy' }, { label: 'Harem', value: 'Harem' }, { label: 'Martial Arts', value: 'Martial Arts' }, { label: 'Modern', value: 'Modern' }, { label: 'Mystery', value: 'Mystery' }, { label: 'Psychological', value: 'Psychological' }, { label: 'Romance', value: 'Romance' }, { label: 'Slice of life', value: 'Slice of Life' }, { label: 'Tragedy', value: 'Tragedy' }, ], type: FilterTypes.CheckboxGroup, }, } satisfies Filters; } export default new Genesis();