diff --git a/plugins/english/genesis.ts b/plugins/english/genesis.ts index 28dfc60..a44d03b 100644 --- a/plugins/english/genesis.ts +++ b/plugins/english/genesis.ts @@ -1,16 +1,9 @@ +import { CheerioAPI, load } from 'cheerio'; import { fetchApi } from '@libs/fetch'; import { Filters, FilterTypes } from '@libs/filterInputs'; import { Plugin } from '@/types/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' - */ +import { NovelStatus } from '@libs/novelStatus'; +import { storage } from '@libs/storage'; class Genesis implements Plugin.PluginBase { id = 'genesistudio'; @@ -18,7 +11,17 @@ class Genesis implements Plugin.PluginBase { icon = 'src/en/genesis/icon.png'; customCSS = 'src/en/genesis/customCSS.css'; site = 'https://genesistudio.com'; - version = '1.1.2'; + api = 'https://api.genesistudio.com'; + version = '2.0.0'; + + hideLocked = storage.get('hideLocked'); + pluginSettings = { + hideLocked: { + value: '', + label: 'Hide locked chapters', + type: 'Switch', + }, + }; imageRequestInit?: Plugin.ImageRequestInit | undefined = { headers: { @@ -26,434 +29,167 @@ class Genesis implements Plugin.PluginBase { }, }; - async parseNovels(json: any[]): Promise { + async parseNovelJSON(json: any[]): Promise { return json.map((novel: any) => ({ name: novel.novel_title, - path: `/novels/${novel.abbreviation}`, - cover: novel.cover, + path: `/novels/${novel.abbreviation}`.trim(), + cover: `${this.api}/storage/v1/object/public/directus/${novel.cover}.png`, })); } - async popularNovels( - pageNo: number, - { - showLatestNovels, - filters, - }: Plugin.PopularNovelsOptions, - ): Promise { + async popularNovels(pageNo: number): Promise { + // There is only one page of results, and no known page function, so do not try 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}`; - } - } + // Only 14 results, no use in sorting or status + // Also all novels are Ongoing with no Completed, can't test status filter + const link = `${this.site}/api/directus/novels?status=published&fields=["cover","novel_title","cover","abbreviation"]&limit=-1`; 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]; + return this.parseNovelJSON(json); } async parseNovel(novelPath: string): Promise { - const url = `${this.site}${novelPath}/__data.json?x-sveltekit-invalidated=001`; + const abbreviation = novelPath.replace('/novels/', ''); + const url = `${this.site}/api/directus/novels/by-abbreviation/${abbreviation}`; // 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 raw = await fetchApi(url); + const json = await raw.json(); // Initialize the novel object with default values - const novel: Plugin.SourceNovel = { - path: novelPath, - name: '', - cover: '', - summary: '', - author: '', - status: 'Unknown', - chapters: [], + const parse = await this.parseNovelJSON([json]); + const novel: Plugin.SourceNovel = parse[0]; + novel.summary = json.synopsis; + novel.author = json.author; + const map: Record = { + ongoing: NovelStatus.Ongoing, + hiatus: NovelStatus.OnHiatus, + dropped: NovelStatus.Cancelled, + cancelled: NovelStatus.Cancelled, + completed: NovelStatus.Completed, + unknown: NovelStatus.Unknown, }; - - // 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); - - // Look for chapter container with required fields - 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]; - - // Check container for keys that match the required fields - if ( - mapping && - typeof mapping === 'object' && - contentKey in mapping && - notesKey in mapping && - footnotesKey in mapping - ) { - // Retrieve the chapter's content, notes, and footnotes using the mapping. - const content = data[mapping[contentKey]]; - const notes = data[mapping[notesKey]]; - const footnotes = data[mapping[footnotesKey]]; - - // Return the combined parts with appropriate formatting - return ( - content + - (notes ? `

Notes


${notes}` : '') + - (footnotes ?? '') + novel.status = map[json.serialization.toLowerCase()] ?? NovelStatus.Unknown; + if (json.cover) { + const url = `${this.site}/api/directus-file/${json.cover}`; + const imgJson = await (await fetchApi(url)).json(); + console.log(imgJson.type); + novel.cover = `${this.api}/storage/v1/object/public/directus/${json.cover}.png`; + if (imgJson.type == 'image/gif') { + novel.cover = novel.cover?.replace('.png', '.gif'); + } else if (imgJson.type !== 'image/png') { + novel.cover = novel.cover?.replace( + '.png', + '.' + imgJson.type.toString().split('/')[1], ); } } - // If no mapping object was found, return an empty string or handle appropriately. - return ''; + // Parse the chapters if available and assign them to the novel object + novel.chapters = await this.extractChapters(json.id); + + return novel; + } + + // Helper function to extract and format chapters + async extractChapters(id: string): Plugin.ChapterItem[] { + const url = `${this.site}/api/novels-chapter/${id}`; + + // Fetch the chapter data in JSON format + const raw = await fetchApi(url); + const json = await raw.json(); + + // Format each chapter and add only valid ones + const chapters = json.data.chapters + .map(index => { + const title = index.chapter_title; + const chapterPath = `/viewer/${index.id}`; + const isLocked = !index.isUnlocked; + if (this.hideLocked && isLocked) return null; + const chapterName = isLocked ? '🔒 ' + title : title; + const chapterNum = index.chapter_number; + + if (!chapterPath) return null; + + return { + name: chapterName, + path: chapterPath, + chapterNumber: Number(chapterNum), + }; + }) + .filter(chapter => chapter !== null) as Plugin.ChapterItem[]; + + return chapters; + } + + async parseChapter(chapterPath: string): Promise { + const url = `${this.site}${chapterPath}`; + const id = chapterPath.replace('/viewer/', ''); + + // Fetch the novel's data in JSON format + const raw = await fetchApi(url); + const $ = load(await raw.text()); + let external_api; + let apikey; + + let URLs = []; + let code; + + // Find URL with API Key + const srcs = $('head') + .find('script') + .map(function () { + const src = $(this).attr('src'); + if (src in URLs) { + return null; + } + URLs.push(src); + }) + .toArray(); + for (let src of URLs) { + const script = await fetchApi(`${this.site}${src}`); + const raw = await script.text(); + if (raw.includes('sb_publishable')) { + code = raw; + break; + } + } + if (!code) { + throw new Error('Failed to find API Key'); + } + // Find right segment of code + let arr = code.split(';'); + for (const seg of arr) { + if (seg.includes('sb_publishable')) { + code = seg; + break; + } + } + arr = code.split('"'); + for (const seg of arr) { + if (seg.includes('https')) { + external_api = seg; + continue; + } + if (seg.includes('sb_publishable')) { + apikey = seg; + continue; + } + } + + const path = `${external_api}/rest/v1/chapters?select=id,chapter_title,chapter_number,chapter_content,status,novel&id=eq.${id}&status=eq.released`; + + const chQuery = await fetchApi(path, { + method: 'GET', + headers: { + // Cookie: 'csrftoken=' + csrftoken, + Referer: this.site, + 'apikey': apikey, + 'x-client-info': 'supabase-ssr/0.7.0 createBrowserClient', + }, + }); + const json = await chQuery.json(); + const ch = json[0].chapter_content.replaceAll('\n', '
'); + return ch; } async searchNovels( @@ -461,18 +197,18 @@ class Genesis implements Plugin.PluginBase { pageNo: number, ): Promise { if (pageNo !== 1) return []; - const url = `${this.site}/api/novels/search?serialization=All&sort=Popular&title=${encodeURIComponent(searchTerm)}`; + // TODO: Figure out how to search + const url = `${this.site}/api/novels/search?title=${encodeURIComponent(searchTerm)}`; const json = await fetchApi(url).then(r => r.json()); - return this.parseNovels(json); + return this.parseNovelJSON(json); } filters = { sort: { label: 'Sort Results By', - value: 'Popular', + value: 'Date', options: [ - { label: 'Popular', value: 'Popular' }, - { label: 'Recent', value: 'Recent' }, + { label: 'Date', value: 'Date' }, { label: 'Views', value: 'Views' }, ], type: FilterTypes.Picker,