From 716ee504163b0cc9e2026d2e09db5296e3794543 Mon Sep 17 00:00:00 2001 From: Danyon <83524127+SupremeMango@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:08:47 +0630 Subject: [PATCH] add NovelDex plugin (#2535) --- plugins/english/noveldex.ts | 983 +++++++++++++++++++++++++ public/static/src/en/noveldex/icon.png | Bin 0 -> 8034 bytes 2 files changed, 983 insertions(+) create mode 100644 plugins/english/noveldex.ts create mode 100644 public/static/src/en/noveldex/icon.png diff --git a/plugins/english/noveldex.ts b/plugins/english/noveldex.ts new file mode 100644 index 0000000..00e8d69 --- /dev/null +++ b/plugins/english/noveldex.ts @@ -0,0 +1,983 @@ +import { fetchApi } from '@libs/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'; + +// NovelDex is a Next.js application using app router, rsc ( react server components ), and flight protocol + +type SeriesUrlFilters = { + sort?: { value?: string }; + genre?: { value?: { include?: string[]; exclude?: string[] } }; + tag?: { value?: { include?: string[]; exclude?: string[] } }; + type?: { value?: string[] }; + status?: { value?: string[] }; + origin?: { value?: string[] }; + sale?: { value?: boolean }; + images?: { value?: boolean }; + ch_min?: { value?: string }; + ch_max?: { value?: string }; +}; + +// Dealing with content encryption from noveldex +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +function base64ToBytes(b64: string): Uint8Array { + const clean = b64.replace(/[^A-Za-z0-9+/]/g, ''); + const out = new Uint8Array(Math.floor((clean.length * 3) / 4)); + let o = 0; + for (let i = 0; i < clean.length; i += 4) { + const a = B64.indexOf(clean[i]); + const b = B64.indexOf(clean[i + 1]); + const c = i + 2 < clean.length ? B64.indexOf(clean[i + 2]) : -1; + const d = i + 3 < clean.length ? B64.indexOf(clean[i + 3]) : -1; + out[o++] = (a << 2) | (b >> 4); + if (c >= 0) out[o++] = ((b & 15) << 4) | (c >> 2); + if (d >= 0) out[o++] = ((c & 3) << 6) | d; + } + return out.subarray(0, o); +} + +function deriveNoveldexKey( + hint: string, + timestamp: number, + nonce: string, +): Uint8Array { + const material = `${hint}|${timestamp}|${nonce}`; + const key = new Uint8Array(32); + let i = 0x811c9dc5; + for (let j = 0; j < material.length; j++) { + i ^= material.charCodeAt(j); + i = Math.imul(i, 0x1000193) >>> 0; + } + for (let k = 0; k < 32; k++) { + i ^= Math.imul(k, 0x9e3779b9) >>> 0; + i = Math.imul(i, 0x1000193) >>> 0; + key[k] = i & 0xff; + } + return key; +} + +function xorDecrypt(cipher: Uint8Array, key: Uint8Array): Uint8Array { + const out = new Uint8Array(cipher.length); + for (let i = 0; i < cipher.length; i++) { + out[i] = cipher[i] ^ key[i % key.length]; + } + return out; +} + +type NoveldexChapter = { + title: string; + number: number; + publishedAt: string; + hasAccess?: boolean; +}; + +type NoveldexGenre = { + name: string; +}; + +type NoveldexSeriesData = { + title: string; + description?: string; + coverImage?: string; + status: string; + chapterCount?: number; // HTML fallback only, for totalPages + genres: NoveldexGenre[]; +}; + +type NoveldexSeriesPage = { + series: NoveldexSeriesData; + chapters: NoveldexChapter[]; + totalPages: number; +}; + +type BrowseSeriesItem = { + title: string; + urlSlug: string; + coverImage?: string; +}; + +type SeriesBrowsePage = { + initialSeries: BrowseSeriesItem[]; +}; + +type NovelStatusValue = (typeof NovelStatus)[keyof typeof NovelStatus]; + +class Noveldex implements Plugin.PluginBase { + id = 'noveldex'; + name = 'NovelDex'; + icon = 'src/en/noveldex/icon.png'; + site = 'https://noveldex.io'; + version = '1.0.0'; + // Filter do take a lot of space + filters = { + sort: { + label: 'Sort By', + type: FilterTypes.Picker, + value: 'views', + options: [ + { label: 'Recently Updated', value: 'updated' }, + { label: 'Most Bookmarked', value: 'popular' }, + { label: 'Most Viewed', value: 'views' }, + { label: 'Longest', value: 'longest' }, + { label: 'Trending', value: 'trending' }, + { label: 'Rating', value: 'rating' }, + { label: 'Newest', value: 'newest' }, + ], + }, + type: { + label: 'Type', + type: FilterTypes.CheckboxGroup, + value: [], + options: [ + { label: 'Novel', value: 'Novel' }, + { label: 'Light Novel', value: 'Light Novel' }, + { label: 'Web Novel', value: 'Web Novel' }, + { label: 'Published Novel', value: 'Published Novel' }, + { label: 'Fanfiction', value: 'Fanfiction' }, + { label: 'Original Fiction', value: 'Original Fiction' }, + { label: 'One Shot', value: 'One Shot' }, + { label: 'Manhwa', value: 'Manhwa' }, + { label: 'Manhua', value: 'Manhua' }, + { label: 'Manga', value: 'Manga' }, + { label: 'Webtoon', value: 'Webtoon' }, + ], + }, + status: { + label: 'Status', + type: FilterTypes.CheckboxGroup, + value: [], + options: [ + { label: 'Ongoing', value: 'Ongoing' }, + { label: 'Completed', value: 'Completed' }, + { label: 'Hiatus', value: 'Hiatus' }, + { label: 'Dropped', value: 'Dropped' }, + { label: 'Discontinued', value: 'Discontinued' }, + { label: 'Upcoming', value: 'Upcoming' }, + ], + }, + origin: { + label: 'Origin', + type: FilterTypes.CheckboxGroup, + value: [], + options: [ + { label: 'Korean', value: 'KOREAN' }, + { label: 'Japanese', value: 'JAPANESE' }, + { label: 'Chinese', value: 'CHINESE' }, + { label: 'Other', value: 'OTHER' }, + ], + }, + sale: { + label: 'On Sale', + type: FilterTypes.Switch, + value: false, + }, + images: { + label: 'Illustrated', + type: FilterTypes.Switch, + value: false, + }, + ch_min: { + label: 'Min Chapters', + type: FilterTypes.TextInput, + value: '', + }, + ch_max: { + label: 'Max Chapters', + type: FilterTypes.TextInput, + value: '', + }, + genre: { + label: 'Genres', + type: FilterTypes.ExcludableCheckboxGroup, + value: { include: [], exclude: [] }, + options: [ + { label: 'Action', value: 'action' }, + { label: 'Adult', value: 'adult' }, + { label: 'Adventure', value: 'adventure' }, + { label: 'Comedy', value: 'comedy' }, + { label: 'Drama', value: 'drama' }, + { label: 'Ecchi', value: 'ecchi' }, + { label: 'Fantasy', value: 'fantasy' }, + { label: 'GameLit', value: 'gamelit' }, + { label: 'Gender Bender', value: 'gender-bender' }, + { label: 'Harem', value: 'harem' }, + { label: 'Historical', value: 'historical' }, + { label: 'Horror', value: 'horror' }, + { label: 'Isekai', value: 'isekai' }, + { label: 'Josei', value: 'josei' }, + { label: 'LitRPG', value: 'litrpg' }, + { label: 'Martial Arts', value: 'martial-arts' }, + { label: 'Mature', value: 'mature' }, + { label: 'Mecha', value: 'mecha' }, + { label: 'Military', value: 'military' }, + { label: 'Mystery', value: 'mystery' }, + { label: 'Psychological', value: 'psychological' }, + { label: 'Romance', value: 'romance' }, + { label: 'School Life', value: 'school-life' }, + { label: 'Sci-Fi', value: 'sci-fi' }, + { label: 'Seinen', value: 'seinen' }, + { label: 'Shoujo', value: 'shoujo' }, + { label: 'Shoujo Ai', value: 'shoujo-ai' }, + { label: 'Shounen', value: 'shounen' }, + { label: 'Shounen Ai', value: 'shounen-ai' }, + { label: 'Slice of Life', value: 'slice-of-life' }, + { label: 'Smut', value: 'smut' }, + { label: 'Sports', value: 'sports' }, + { label: 'Supernatural', value: 'supernatural' }, + { label: 'Thriller', value: 'thriller' }, + { label: 'Tragedy', value: 'tragedy' }, + { label: 'Virtual Reality', value: 'virtual-reality' }, + { label: 'Wuxia', value: 'wuxia' }, + { label: 'Xianxia', value: 'xianxia' }, + { label: 'Xuanhuan', value: 'xuanhuan' }, + { label: 'Yaoi', value: 'yaoi' }, + { label: 'Yuri', value: 'yuri' }, + ], + }, + tag: { + label: 'Tags', + type: FilterTypes.ExcludableCheckboxGroup, + value: { include: [], exclude: [] }, + options: [ + { label: 'Male Protagonist', value: 'male-protagonist' }, + { label: 'Overpowered Protagonist', value: 'overpowered-protagonist' }, + { label: 'Modern Day', value: 'modern-day' }, + { label: 'Harem', value: 'harem' }, + { label: 'Misunderstandings', value: 'misunderstandings' }, + { label: 'Fantasy', value: 'fantasy' }, + { label: 'Academy', value: 'academy' }, + { label: 'Transmigration', value: 'transmigration' }, + { label: 'Fantasy World', value: 'fantasy-world' }, + { label: 'Magic', value: 'magic' }, + { label: 'Character Growth', value: 'character-growth' }, + { label: 'Obsessive Love', value: 'obsessive-love' }, + { label: 'Beautiful Female Lead', value: 'beautiful-female-lead' }, + { label: 'Modern Fantasy', value: 'modern-fantasy' }, + { label: 'Reincarnation', value: 'reincarnation' }, + { label: 'Game Elements', value: 'game-elements' }, + { label: 'Female Protagonist', value: 'female-protagonist' }, + { label: 'Action', value: 'action' }, + { label: 'Romance', value: 'romance' }, + { label: 'Survival', value: 'survival' }, + { label: 'Monsters', value: 'monsters' }, + { label: 'Possession', value: 'possession' }, + { label: 'Weak to Strong', value: 'weak-to-strong' }, + { label: 'R-18', value: 'r-18' }, + { label: 'Yandere', value: 'yandere' }, + { label: 'Adventure', value: 'adventure' }, + { label: '19+', value: '19-360615' }, + { label: 'Hunters', value: 'hunters' }, + { label: 'Special Abilities', value: 'special-abilities' }, + { label: 'Adult', value: 'adult' }, + { label: 'Dark Fantasy', value: 'dark-fantasy' }, + { label: 'Genius Protagonist', value: 'genius-protagonist' }, + { label: 'Calm Protagonist', value: 'calm-protagonist' }, + { label: 'Nobles', value: 'nobles' }, + { label: 'Gender Bender', value: 'gender-bender' }, + { label: 'System', value: 'system' }, + { label: 'Comedy', value: 'comedy' }, + { label: 'Light Novel', value: 'light-novel' }, + { label: 'Obsession', value: 'obsession' }, + { label: 'Dungeons', value: 'dungeons' }, + { label: 'Slice of Life', value: 'slice-of-life' }, + { label: 'Clever Protagonist', value: 'clever-protagonist' }, + { label: 'Drama', value: 'drama' }, + { label: 'Sword And Magic', value: 'sword-and-magic' }, + { label: 'Handsome Male Lead', value: 'handsome-male-lead' }, + { label: 'Aristocracy', value: 'aristocracy' }, + { label: 'Revenge', value: 'revenge' }, + { label: 'Male to Female', value: 'male-to-female' }, + { label: 'Adventurers', value: 'adventurers' }, + { label: 'Corruption', value: 'corruption' }, + { label: 'Second Chance', value: 'second-chance' }, + { label: 'Demons', value: 'demons' }, + { label: 'Mature', value: 'mature' }, + { label: 'Smut', value: 'smut' }, + { label: 'Pure Love', value: 'pure-love-978330' }, + { label: 'Regression', value: 'regression' }, + { label: 'Medieval', value: 'medieval' }, + { label: 'Politics', value: 'politics' }, + { label: 'Alternate World', value: 'alternate-world' }, + { label: 'Apocalypse', value: 'apocalypse' }, + { label: 'Accelerated Growth', value: 'accelerated-growth' }, + { + label: 'Protagonist Strong from the Start', + value: 'protagonist-strong-from-the-start', + }, + { label: 'Martial Arts', value: 'martial-arts' }, + { label: 'Antihero Protagonist', value: 'antihero-protagonist' }, + { label: 'Heroes', value: 'heroes' }, + { label: 'Adapted to Manhwa', value: 'adapted-to-manhwa' }, + { + label: 'Hard-Working Protagonist', + value: 'hard-working-protagonist', + }, + { label: 'Training', value: 'training' }, + { label: 'Ecchi', value: 'ecchi' }, + { label: 'Isekai', value: 'isekai' }, + { label: 'Royalty', value: 'royalty' }, + { label: 'Sword Wielder', value: 'sword-wielder' }, + { label: 'Urban Fantasy', value: 'urban-fantasy' }, + { label: 'Strong to Stronger', value: 'strong-to-stronger' }, + { label: 'Acting', value: 'acting' }, + { label: 'Regret', value: 'regret-728982' }, + { label: 'Supernatural', value: 'supernatural' }, + { label: 'Dark', value: 'dark' }, + { label: 'Betrayal', value: 'betrayal' }, + { label: 'Fantasy Creatures', value: 'fantasy-creatures' }, + { label: 'European Ambience', value: 'european-ambience' }, + { + label: 'Schemes And Conspiracies', + value: 'schemes-and-conspiracies', + }, + { label: 'Level System', value: 'level-system' }, + { label: 'High Level', value: 'highlevel-008161' }, + { label: 'Romance Fantasy', value: 'romance-fantasy-578698' }, + { label: 'Live Streaming', value: 'livestreaming' }, + { label: 'Cunning Protagonist', value: 'cunning-protagonist' }, + { label: 'Gods', value: 'gods' }, + { label: 'Tragic Past', value: 'tragic-past' }, + { label: 'Incest', value: 'incest' }, + { + label: 'Reincarnated in Another World', + value: 'reincarnated-in-another-world', + }, + { label: 'Hidden Identity', value: 'hidden-identity' }, + { label: 'Multiple POV', value: 'multiple-pov' }, + { label: 'First-time Intercourse', value: 'first-time-intercourse' }, + { label: 'Possessive Characters', value: 'possessive-characters' }, + { + label: 'Transported into a Game World', + value: 'transported-into-a-game-world', + }, + { label: 'Hyundai', value: 'hyundai-025236' }, + { label: 'Kingdom Building', value: 'kingdom-building' }, + { label: 'Secret Identity', value: 'secret-identity' }, + { label: 'Tower Climbing', value: 'tower-climbing' }, + { label: 'Kingdoms', value: 'kingdoms' }, + { label: 'Military', value: 'military' }, + { label: 'Guilds', value: 'guilds' }, + { label: 'Netori', value: 'netori' }, + { label: 'Past Plays a Big Role', value: 'past-plays-a-big-role' }, + { label: 'Childhood Friends', value: 'childhood-friends' }, + { label: 'Ruthless Protagonist', value: 'ruthless-protagonist' }, + { label: 'Modern Knowledge', value: 'modern-knowledge' }, + { label: 'School Life', value: 'school-life' }, + { label: 'Hunter', value: 'hunter-343706' }, + { label: 'Slow Romance', value: 'slow-romance' }, + { label: 'Depictions of Cruelty', value: 'depictions-of-cruelty' }, + { label: 'Hidden Abilities', value: 'hidden-abilities' }, + { label: 'Strategist', value: 'strategist' }, + { label: 'Elves', value: 'elves' }, + { label: 'Modern', value: 'modern-943487' }, + { label: 'Hidden Power', value: 'hidden-power' }, + { label: 'Knights', value: 'knights' }, + { label: 'BDSM', value: 'bdsm' }, + { label: 'Determined Protagonist', value: 'determined-protagonist' }, + ], + }, + } satisfies Filters; + + imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; + webStorageUtilized?: boolean; + + private 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`, + }; + + // little fetchers helpers + /* + RSC response looks like this (simplified) + + 1:I[99630,[],"LoadingBoundaryProvider"] + 5c:I[46538,[...chunks...],"SeriesBrowseGrid"] + 59:["$","$L5c",null,{"seed":"...","initialSeries":[...],...}] + */ + + private async fetchPage(url: string) { + const repsonse = await fetchApi(url, { + headers: this.headers, + }); + return loadCheerio(await repsonse.text()); + } + + private async fetchRsc(url: string): Promise { + const res = await fetchApi(url, { + headers: { + 'User-Agent': this.headers['User-Agent'], + Referer: this.site, + RSC: '1', + }, + }); + return res.text(); + } + + // ----------------------------------------------------------------------- + // RSC parsing + // ----------------------------------------------------------------------- + + /** + Find the flight-stream module id + The id changes with each build, so we resolve it dynamically. + */ + + // For searching novels | Helpers for main function + private findClientId(rscText: string, componentName: string): string | null { + const escaped = componentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = rscText.match( + new RegExp(`^([0-9a-fA-F]+):I\\[\\d+,\\[[^\\]]*\\],"${escaped}"\\]`, 'm'), + ); + return match ? match[1] : null; + } + + private findSeriesClientId(rscText: string): string | null { + return this.findClientId(rscText, 'SeriesDetailClient'); + } + + /** + * Return the substring of `text` starting at `start` (which must be `{` or + * `[`) up to and including the matching closing bracket. + * Handles nested braces, string literals, and escapes. + */ + private extractBalancedJson(text: string, start: number): string | null { + const first = text[start]; + if (first !== '{' && first !== '[') return null; + + const open = first; + const close = open === '{' ? '}' : ']'; + let depth = 0; + let inString = false; + let escaped = false; + + for (let i = start; i < text.length; i++) { + const c = text[i]; + if (escaped) { + escaped = false; + continue; + } + if (c === '\\') { + escaped = true; + continue; + } + if (c === '"') { + inString = !inString; + continue; + } + if (inString) continue; + + if (c === open) depth++; + else if (c === close) { + depth--; + if (depth === 0) return text.slice(start, i + 1); + } + } + return null; + } + + // Pull `{ series, chapters, totalPages, ... }` out of the flight payload. + private extractSeriesPage(rscText: string): NoveldexSeriesPage | null { + const clientId = this.findSeriesClientId(rscText); + if (!clientId) return null; + + const marker = `"$L${clientId}",null,`; + + let idx = rscText.indexOf(marker); + while (idx !== -1) { + const jsonText = this.extractBalancedJson(rscText, idx + marker.length); + if (jsonText) { + try { + const parsed = JSON.parse(jsonText); + if (parsed && parsed.series && Array.isArray(parsed.chapters)) { + return parsed as NoveldexSeriesPage; + } + } catch { + // fall through and try the next occurrence + } + } + idx = rscText.indexOf(marker, idx + marker.length); + } + + return null; + } + + //Fetch one page of a series via RSC + private async fetchSeriesPage( + novelPath: string, + page: number, + ): Promise { + const base = novelPath.split('?')[0]; // Modify the path lil bit + const url = `${this.site}${base}?page=${page}`; + const rscText = await this.fetchRsc(url); + return this.extractSeriesPage(rscText); + } + + // Function to continue fetching the remaining chapters through RSC raw flight text + private mapChapters( + chapters: NoveldexChapter[], + base: string, + ): Plugin.ChapterItem[] { + return chapters.map(chapter => { + const accessible = chapter.hasAccess !== false; + const title = chapter.title || 'Untitled'; + return { + name: accessible ? title : `🔒 ${title}`, + path: `${base}/chapter/${chapter.number}`, + releaseTime: chapter.publishedAt, + chapterNumber: chapter.number, + }; + }); + } + + // Convert NovelDex's raw status strings into the framework's NovelStatus enum. + private mapStatus(raw: string | undefined): NovelStatusValue { + switch (raw?.toUpperCase()) { + case 'ONGOING': + return NovelStatus.Ongoing; + case 'COMPLETED': + return NovelStatus.Completed; + case 'HIATUS': + return NovelStatus.OnHiatus; + case 'DROPPED': + case 'DISCONTINUED': + case 'CANCELLED': + case 'CANCELED': + return NovelStatus.Cancelled; + case 'UPCOMING': + case 'INACTIVE': + return NovelStatus.Inactive; + case 'LICENSED': + return NovelStatus.Licensed; + default: + return NovelStatus.Unknown; + } + } + + // Popular list or Latest Novel + async popularNovels( + page: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + const url = this.buildSeriesUrl(page, { + // "Latest" tab wins over the sort picker — the site defaults to recently-updated when sort is omitted. + sort: showLatestNovels ? undefined : filters?.sort?.value || 'views', + filters, + }); + + const rscText = await this.fetchRsc(url); + const pageData = this.extractBrowsePage(rscText); + if (!pageData) return []; + + return pageData.initialSeries.map(s => ({ + name: s.title, + path: `/series/novel/${s.urlSlug}`, + cover: s.coverImage + ? new URL(s.coverImage, this.site).href + : defaultCover, + })); + } + + private buildSeriesUrl( + page: number, + opts: { + q?: string; + sort?: string; + filters?: SeriesUrlFilters; + } = {}, + ): string { + const params = new URLSearchParams(); + + if (opts.sort) params.set('sort', opts.sort); + if (opts.q) params.set('q', opts.q); + + const f = opts.filters; + + // Excludable groups — genre and tag have include/exclude halves. + const addExcludable = ( + value: { include?: string[]; exclude?: string[] } | undefined, + includeKey: string, + excludeKey: string, + ) => { + if (value?.include?.length) { + params.set(includeKey, value.include.join(',')); + } + if (value?.exclude?.length) { + params.set(excludeKey, value.exclude.join(',')); + } + }; + + addExcludable(f?.genre?.value, 'genre', 'exgenre'); + addExcludable(f?.tag?.value, 'tag', 'extag'); + + // Simple multi-select groups — comma-joined, include only. + const addList = (value: string[] | undefined, key: string) => { + if (value?.length) params.set(key, value.join(',')); + }; + + addList(f?.type?.value, 'type'); + addList(f?.status?.value, 'status'); + addList(f?.origin?.value, 'origin'); + + // Boolean toggles — only added when true. + if (f?.sale?.value) params.set('sale', 'true'); + if (f?.images?.value) params.set('images', 'true'); + + // Chapter count range — added only when the user typed something. + const min = f?.ch_min?.value?.trim(); + const max = f?.ch_max?.value?.trim(); + if (min) params.set('ch_min', min); + if (max) params.set('ch_max', max); + + if (page > 1) params.set('page', String(page)); + + return `${this.site}/series?${params.toString()}`; + } + + // ----------------------------------------------------------------------- + // HTML fallbacks - used only if RSC parsing fails, RIP 🙏 + // ----------------------------------------------------------------------- + private async parseChapterPage( + novelPath: string, + page: string, + ): Promise { + const $ = await this.fetchPage( + `${this.site}${novelPath.split('?')[0]}?page=${page}`, + ); + + let chapters: NoveldexChapter[] = []; + + $('script').each((_, el) => { + const text = $(el).text(); + + if (text.includes('hasAccess')) { + const chapterStart = text.indexOf('\\"chapters\\":['); + const chapterEnd = text.indexOf(',\\"characters\\"', chapterStart); + + if (chapterStart === -1 || chapterEnd === -1) return; + + const chaptersText = text.slice( + chapterStart + '\\"chapters\\":'.length, + chapterEnd, + ); + + const chaptersJson = chaptersText + .replace(/\\\\/g, '\\') + .replace(/\\"/g, '"'); + + chapters = JSON.parse(chaptersJson) as NoveldexChapter[]; + return false; + } + }); + + const clrNovelPath = novelPath.split('?')[0]; + + return chapters.map(chapter => ({ + name: chapter.title || 'Untitled', + path: `${clrNovelPath}/chapter/${chapter.number}`, + releaseTime: chapter.publishedAt, + chapterNumber: chapter.number, + })); + } + + private async parseNovelFromHtml( + novelPath: string, + ): Promise { + const $ = await this.fetchPage(`${this.site}${novelPath}`); + + const title = $('h1').text().trim() || ''; + const src = $(`img[alt="${title}"]`).attr('src'); + const cover = src ? new URL(src, this.site).href : undefined; + + let seriesData: NoveldexSeriesData | undefined; + + $('script').each((_, el) => { + const text = $(el).text(); + + if (text.includes('hasAccess')) { + const jsonStartIdx = text.indexOf('{\\"id\\"'); + const jsonEndIdx = text.indexOf(',\\"chapters\\"', jsonStartIdx); + + if (jsonStartIdx !== -1 && jsonEndIdx !== -1) { + const seriesText = text.slice(jsonStartIdx, jsonEndIdx); + const jsonText = seriesText.replace(/\\"/g, '"'); + seriesData = JSON.parse(jsonText) as NoveldexSeriesData; + return false; + } + } + }); + + const chapterCount = seriesData?.chapterCount || 0; + const totalPages = Math.ceil(chapterCount / 100); + + const allChapters: Plugin.ChapterItem[] = []; + for (let i = 1; i <= totalPages; i++) { + const pageChapters = await this.parseChapterPage(novelPath, i.toString()); + allChapters.push(...pageChapters); + + await new Promise(resolve => setTimeout(resolve, 200)); + } + + const description = seriesData?.description + ?.replace(/\\r\\n/g, '\n') + ?.replace(/\\n/g, '\n') + ?.trim(); + + const genres = seriesData?.genres?.map(g => g.name) || []; + + return { + path: novelPath, + name: title || seriesData?.title || 'Untitled', + cover: cover || defaultCover, + status: this.mapStatus(seriesData?.status), + summary: description, + genres: genres.join(', '), + chapters: allChapters, + }; + } + + // ----------------------------------------------------------------------- + // Main parser — RSC first, HTML fallback + // ----------------------------------------------------------------------- + + async parseNovel(novelPath: string): Promise { + const base = novelPath.split('?')[0]; + + try { + const first = await this.fetchSeriesPage(base, 1); + + if (first?.series) { + const allChapters = this.mapChapters(first.chapters, base); + + // Walk remaining pages — RSC-only + for (let page = 2; page <= first.totalPages; page++) { + try { + const next = await this.fetchSeriesPage(base, page); + if (next?.chapters?.length) { + allChapters.push(...this.mapChapters(next.chapters, base)); + } + await new Promise(r => setTimeout(r, 100)); + } catch (error) { + console.warn(`Failed to fetch page ${page}:`, error); + } + } + + allChapters.sort( + (a, b) => (a.chapterNumber ?? 0) - (b.chapterNumber ?? 0), + ); + + const s = first.series; + + const cover = s.coverImage + ? new URL(s.coverImage, this.site).href + : defaultCover; + + const description = (s.description || '') + .replace(/\\r\\n/g, '\n') + .replace(/\\n/g, '\n') + .trim(); + + const genres = + s.genres + ?.map(g => g.name) + .filter(Boolean) + .join(', ') || ''; + + return { + path: base, + name: s.title || 'Untitled', + cover, + status: this.mapStatus(s.status), + summary: description, + genres, + chapters: allChapters, + }; + } + } catch { + // fall through to the HTML scraper + } + + /* + Next.js's flight format is not a stable public API + If noveldex upgrades next.js, Client Component returns null so to make sure the code still work + We fall back on extracting with html + cheerio, we lose speed but it works. + */ + + return this.parseNovelFromHtml(base); + } + + // Chapter content helper + private extractDeferredText( + rscText: string, + refId: string, + ): string | undefined { + const esc = refId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const m = new RegExp(`(?:^|\\n)${esc}:T([0-9a-fA-F]+),`).exec(rscText); + if (!m) return undefined; + const start = m.index + m[0].length; + const bytes = new TextEncoder().encode(rscText.slice(start)); + return new TextDecoder().decode(bytes.slice(0, parseInt(m[1], 16))); + } + + // Main chapter content + async parseChapter(chapterPath: string): Promise { + const url = `${this.site}${chapterPath}`; + const rscText = await this.fetchRsc(url); + + // Checking whether the chapter is free + if (/"isUnlocked"\s*:\s*false/.test(rscText)) { + throw new Error( + 'This chapter requires premium access and cannot be read here.', + ); + } + + // Decrypting starts + + // 1. Locate the xorEncryption block + const xorMatch = rscText.match(/"xorEncryption"\s*:\s*(\{[^}]+\})/); + if (!xorMatch) + throw new Error( + 'Could not load chapter. It may require premium access or a purchase.', + ); + + const xor = JSON.parse(xorMatch[1]) as { + encryptedBase64: string; + partialKeyHint: string; + timestamp: number; + clientNonce: string; + }; + + // 2. Resolve "$64" → line "64:T," + let b64: string; + const refMatch = /^\$([0-9a-zA-Z]+)$/.exec(xor.encryptedBase64); + + if (refMatch) { + const esc = refMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const m = new RegExp(`(?:^|\\n)${esc}:T([0-9a-fA-F]+),`).exec(rscText); + if (!m) throw new Error('noveldex: cannot resolve encrypted ref'); + + const start = m.index + m[0].length; + const byteLen = parseInt(m[1], 16); + const bytes = new TextEncoder().encode(rscText.slice(start)); + b64 = new TextDecoder().decode(bytes.slice(0, byteLen)); + } else { + b64 = xor.encryptedBase64; + } + + // 3. Decrypt + const cipher = base64ToBytes(b64); + const key = deriveNoveldexKey( + xor.partialKeyHint, + xor.timestamp, + xor.clientNonce, + ); + const content = new TextDecoder('utf-8').decode(xorDecrypt(cipher, key)); + + if (!content?.trim()) throw new Error('noveldex: empty chapter'); + + // 4. Normalize + return ( + content + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + // Make NovelDex hosted images absolute. + // src="/uploads/..." → src="https://noveldex.io/uploads/..." + .replace(/(]*?\bsrc=")\/(?!\/)([^"]*")/gi, `$1${this.site}/$2`) + /* + Unwrap stray

wrapping around block-level

s. + NovelDex emits

...

— invalid HTML that + breaks the system-UI card layout in the reader WebView. + */ + .replace(/

\s*(]*>)/gi, '$1') + .replace(/(<\/div>)\s*<\/p>/gi, '$1') + + /* + Collapse
runs adjacent to block-level tags. + Every source line ends in
, which doubles spacing around the styled div blocks. + */ + .replace(/(<\/(?:p|div|figure)>)\s*(?:\s*)+/gi, '$1') + .replace(/(?:\s*)+(<(?:p|div|figure)\b)/gi, '$1') + + .replace(/style="([^"]*?)"/g, (match, styles) => + /border|background|box-shadow/.test(styles) + ? `style="font-family:'JetBrains Mono','SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;${styles}"` + : match, + ) + /* + Split into chunks and wrap plain-text ones. + Chunks that already begin with a block-level tag pass through as-is + so we don't nest

inside

. + */ + + .split(/\n{2,}/) + .map(p => p.trim()) + .filter(Boolean) + .map(p => + /^<(?:p|div|figure|img|h[1-6]|blockquote|hr|ul|ol)\b/i.test(p) + ? p + : `

${p.replace(/\n/g, '
')}

`, + ) + .join('') + ); + } + + // Search starts + // Bridge between raw RSC and structured data + private extractBrowsePage(rscText: string): SeriesBrowsePage | null { + const clientId = this.findClientId(rscText, 'SeriesBrowseGrid'); + if (!clientId) return null; + + const marker = `"$L${clientId}",null,`; + + let idx = rscText.indexOf(marker); + while (idx !== -1) { + const jsonText = this.extractBalancedJson(rscText, idx + marker.length); + if (jsonText) { + try { + const parsed = JSON.parse(jsonText); + if (parsed && Array.isArray(parsed.initialSeries)) { + return parsed as SeriesBrowsePage; + } + } catch { + // try the next occurrence + } + } + idx = rscText.indexOf(marker, idx + marker.length); + } + + return null; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (!searchTerm.trim()) return []; + + const params = new URLSearchParams({ q: searchTerm, sort: 'views' }); + if (pageNo > 1) params.set('page', String(pageNo)); + + const rscText = await this.fetchRsc( + `${this.site}/series?${params.toString()}`, + ); + + const page = this.extractBrowsePage(rscText); + if (!page) return []; + + return page.initialSeries.map(s => ({ + name: s.title, + path: `/series/novel/${s.urlSlug}`, + cover: s.coverImage + ? new URL(s.coverImage, this.site).href + : defaultCover, + })); + } + + resolveUrl = (path: string) => this.site + path; +} + +export default new Noveldex(); diff --git a/public/static/src/en/noveldex/icon.png b/public/static/src/en/noveldex/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a6d947b217b520212dde1871f95e5cb89d219119 GIT binary patch literal 8034 zcmZ{J2Qb{>)BjgL{m9|;8aH970F1<;yHVEQB*mvC*TJnrWc~@=ENT2CX zvo4!a4Kd+7JpW;fRvqf?%=9VVA=IPk_~!aDb>%+zlmmdcWu>2~0ZS;gfO;%Q=Nc;4*tD zR>yz)*I9t2v-|o{_Q3#Jem|6vB-{XGhygLHKY&+vA5&`i>fI^S4FO*5R0t#-*wPlR zB6QJuG(-{Bl$t4g?LFc!8$pdHfD`I?U672QBHR6)Hj;pZN1qa(E0GM|fX6}{3=2SI zorS#7z(H&-1CR{w3u&Bie}DKwtD;91(kBh9l*rDCucu|F(cn{T8UV(|_;hKnpF^~i zPZL#Oik~VrNGCl=_mqSrWG}LD5;fBc2av0H$7O95oh(}CxB!w%3IQNKt>Q3-Ll)B= z3)d-GZna`hvhc4c-z@sq9b|ySP(?+_HH2qDN;gJH1Bo$v+{C1v zgDpwQ#%Gva58cjyHw|zj^TnZMViA*)*jxUjT?D}}JahE2>ifLcF@zWs$r>YH11|-6 z%~3>#Y+S~io2wN=N9NM#2-;|5nf$qtL*zFIsB6ZqxYmOFFe zBy`ddW_-#>0^k&z^(GmX9iaN11Pqe%yNW8Fq(s)9VrU~gyh7)QhmS&#_MFobVU3@& z`&oNgzflp0`>ldCvopfYkC`L!HI(ul1^QTORM;iq5M5E(k z(+101$&(YWKL=~9a~(l=ltR=jx{u%&)JnIs$TciXOY!p+4nPl$7vB9{=(&=M!P}?Q z0<0DBS)#|ZD{dr$ABjHi`w{4I*nlGlP{K?RxM2W3agzF`351LlgPA(jgu&8Sxm=o) zqzhCaj$~|kpENc-AA0;#iYgpSVXoDa!)IqnQ*)V%;P+_M&TTx?7P&weV$Gngzbcpr z79WhHE)FE_L8ZR9(NR{mJ*woXTPsmZ|I;uS5|$@(h#k1{3>>BX7Q+h01DhK1HPj9~ zI??Tp)Z@>)sSb?TVI`8`qyP}GXwEcMzpWCiZ?8Ap_hyb89Hvc%e`=VntDX{vMX-tl zh#7OoVnd8Vg0RL;UxEb!I|+3?>+ps~pIAIy3OqHKb>~a(4qv(Ed+@4~tu3uf+pdzh zCcTi*y=@&E3rG&p5AeK4fZ}!P77-B$Ll{x0VvoGAO&Dl%e_vjZTDd^G$BI2Jon%MB z!d3ROxf_?q<-3nVye^oIdUARzPq!$UUK6R#cjjVn>gVgwMwC0TPu$y#WD5E7ciuPz zANHI3?HuNHxtndGqGo1noR(2!dU}KznVHeLfk%Zzz{N@rc+4BCl*>{|6u>Ew&u(=W z`6-DeYAVq)ZuOAh<-z>%7UAGPsfbyNUOV`pFeV)PgTI};#6v(*Ow2dx;O!{JmGB9s zKKSkm2eEDbz7S`)@?(CGrQhpQiFETb(O<2{T++8EB%C-Ir5Ghh`GU7W*Glex5AY*G zF+<^Oa4>)mDv(PigGS*@PU+O-(21t#LQN*Zo%o_<_&XxjK9snbGh$)2iwj6#4Z1VO zE$lGWD^F!I&9kLGXN zQ4PAklkq$IXbT6&C_OO?zBGiQLd`me7JUljafL!v!c^qnld#|`#1$0PFsfpp3c710 zm@qRauJBO#X!7aG_=19`bDzWI3*IRd4d!Qb2Yw>7M@7xg+k=?vofoZz1q4KmIX5^& z9D?L@sE1E2@&K=SLT1*3c6lS|@taFA=XSetGw$OAda15JkHo!gr|uCk3qt0{B=piG z6y(KI%qrnX=}W$oWc~@VC}aw5wF+Hs6#&kls4fppNPw7_Bypx!Aq>k6oFQ@6yylX~ zpS3=lnSJmOF-fUOzq5IB=q7E~_f+&rkupj1;%TIEF^q?;+Ngx;D>nwnL0&jb5gIj1 zL|S(Z4?w9cHUvraz~z!@GVrJ0#vQ)ms1KOs+%W;TyueM#0Ucw^ktAI2EZU9n6^F%j zHSDYDU#QihdwnW7A!-@f8H2HpJfX5rBlq@vF6597{R5fPJoWyk9>YIJikh2o4*iIf z6X^zTdU!=$pGK$XZVe6ni7p)PV_5MG3Bnvx6mqf^otY6mh+U&_oTZZpj1l_C3!=fr zYpZv%=MU7fwCn(VVYsVYmA3;I%O#O$WW4W*fZz&~g+kb^427%=-vuV>@KTlUEg~^{ zw3$H)^;Rvl(dz8cX=!YR)lfwvTf9&6bSz|8)zC+eChMK?I`*wzUl2Ll*d!73_sT*b z=a&ZJkAwQL>Z|28Q5c3E>s_ngaufvxM13Hb9D~Q9-I{@G{I0#vsGQ}Mm3fv>i4Oe= zP>_nG#FC079F#0f9m-xbW}Q;}PMal@r5EOA<^LA#k_zF$#A1SjLu0gS7ToAaryYo~ zZkJU=#Vlv{ZpVa)Q3Y!k7mygxr96pw&TX;&oV1wieYvHvr+kxKMGsmE7*WZuX~xt2 z{>8_a_@$VvEJsx;4fb;ovrf&7`^JE!S&K8Fs0-Zy4pnw61&{yQHNm_(u_aUf^GB#A zm2{n^Qc3&yF~PI#Fm;)?G4=%}HQL`W9+5r%jZe-7j#}fdqh$jIj@BP6iM7prLuPJn zpAkjEj6E<|#$1Bj576c=*5`Jnh-QU&ZHCmf-n0_wq-ZIeQxaes|XA+U_m{$ES}ps&5#thGZDS+p(J7 zOqvXj=&KOWlL>`o?!p?wz?eEbxJsZEQYICon@2~9Nuzt973I7Wndk8fFQ3oyU`s12 zTwN6ayKFDNq{RFpS4v2v5j`kxr&{s-!As_j&%n5`xqBZ-r_rY+mw_0%n=MC`$L@nU z`vVKjYOB{63lsKzf1Z@jQr8ZG*#w%339~*|!=ad-dZJypfm#S36w#nNS$rR26Bjd4 zjtyMC`uBn{f+LO}G)#os>lviN{9g5OK@T`pmH7DhMT?8u(K&!E7o*b0!e#v=Pxda~ zo*kWT=7E#IuzY_-oZswCO(gRxJ4S3pw`!g~7agA3)zBb8MB=rU={P!=Ho|JB+6PRm zqA*2F^G%ZLox|)+jK2iqCslB=52_uQDM1CX@%yUTGn67ECu&}Q6wr6ZDU!439~o!x7rrFbWDtbD*X{Ylq9P)uqokVO9gi`%0_@C7qp+YT=hKNz9FMI6<{ zgeWi((yrkK75v%p6zzE{yw)A^E6$%d2FlJWq(n9uVN}<9j*Q7gGIgC+jj0o}R$aT_ zeJ1<5M%)X9s{Hvj&J}p{$fEbgiDIbKM!3uhYBmu`!Grr=^9u`Qe1VC(fP}=O(;HQ* zGnELvioZN2c}BuW(xnr;i++BR9=+V3Z(p-8D6FXP`=bKhtFx0O4z9N1Zv1m*OBT2Y z2CW=x`+f(@EAx&MO{(OZOzKo|Rj&wRQh=myx^zIIWQmW#@k-+9#H%QROyO<_!cT*i zF9@xg-P*udQ$tCVQ}4SkaC2%Q3t}c(@<4kV2MiFLoO$;)Q+{d!kLg-tr)v zvW?$u_pr$FU)bj9;{LBmC^(|y>bCo+vnH4@W1yHA>d6Jvw}5)E*C6bUpmR;HkYjI zV)>sJkSR2jK4&AR)UCm9D$RQD5L+Zcp&)J9bu2wR@){;785+mm7*bA5W!^*< zWFEA+ZQuL7`4*<$bGPh0tijEe%zoAZOI$m}WVP)B$N`>+P{IvxqtLUR0C$qcNeW?+ z$M0QS<#{cUFY?X2-jAl~@EU0IrfBn~uD#*wVuD9{*WE0Jy}R}jh|7C=oMTk1$$u@t zL}nVUeVLJMu869M*xHgPiESZ$9G~2H#Zi{JwZc9hp;T(^!RwIEWsPvNCyX*WTdnp? zLj?21+4}4a%HI7^FmCn-Q{{fa`2c#g`%%W&(+0Z%8!UjGv_*NshtCv&d=>DG>U&1! z_q7W@5~f&vNAlQGY)Tu)x$7GuYf1;RnyIxpzOb%m`P&dqs)D1g9U(BXm>|gDnUO7l z)_wq4={O-XTlUr8A0~C_&3J5uKLB!23OH&i-OH=VqTOj|Rbxb>f=odS%H;Plh6#bq zP-N(-Q{{IMM4bDhd{*HBZGBVb#pk8hv>ZVNKVU)Iyr)RBTBMkT7iDeW;ym=_a&%-X zXxm%KQH_{QnwM`1=I=-H=TV%tDm*fxB9=UXo{uT7rq>sPOO7mV4n~dI>wQc14#X4@ z_8#e?Vi$${Bd*W6eomJs?XwbFu@w!u;j)4?$}b098qT%v$RNQcVTXJ0NHILQ`tR;l zhQe&1UM3ryM41Rsf%0S2Bqc{yWIehxJmGh^cy~rjVf)C) z^P?HGH6;Ed44Z{}Y6kh5wjqY=8Gio~!G|~CvKGRoAGblEXj8-_|S3=lQm42Xpsld$bKQ zrZI)syEB-{8x^z1gmBE zEeQcI3X|0=up?vGiTqq*RIRTlYDVo%si~%;yHtUm=&N&C?Oi4TM!c~RMcRx*Tth!r zp)?1{lB+f=ub)wd8A%th%g_8edaPYlSt*6KVr{A1UKjRh%5aAHy^Ha|FZ>5`5Nph= zye3Wa-lZAiH+^EvJV2C(^f*|tvt9~evm%kNkOWC~0fh2Np9;xK7c%-lShie|Vj_d< zt+OxRySS(gJ^(V0pul5}#>=5M=k{EFcoWZTR?I-i)7#ktb3rs~&V3@r-UBN=yJ6cyq=eJEXk~op7K`sreH!1=$>lYtX z6_4fmn8nPa@vnXVjqK=1 zI&4lb_Q%iNXTxkTsC;&IrcKfTb3Gc4;amZ7xx66N^xehC{_TyN67IsBPnb02r3`@Y z{8ZOsaB@^@I}biZ*XKw=#7!y;qYa>&n#=T_bR72t9M7$eR#q535jbDrTm=F6!q7&B!!kobOR zEz6E=>;Uh0UcK~oU|VXhbFFRN+jxa#PnO0>wmwvt_vbw{3*q#rew&Z|87_7j_6-*2*X1Y&-K`Ax$9z%Gl3!NTa8Kc zKwST$2ERg;{jYcJFC$75N|RXDKQy4f*{%6ZebQvHAt}kKAuY?QmW@|_MH|xLxK=cz z;A_)y@sZa3Y(p5E4m%f5gGOBNMU*|LN%$k^MXy>>+@|>gew}fci^@Ct(cgM`0Z`XY zFKp5~zmpT3gSo`u2=p&^teY;qxAV1zRROG)UR|(1zPGDwE7_gf3$(sQ+#;M&EJ%EU z{mw(Ii@o#CWf!Gt5tNIt5qIjPx=--^$yCb9tWZm_Nlj+6L&XA%4b{_!Z@rS zHay<=`x;ykM*d5rl=bf(FxE42{A=#dd-uDNu)|A?Z0&>V$^?CU1do0`%d|<*O`nsq zAa#+;;MwnB&ffXf3498HD$CXdhiA>T>b2Pz^{Xv=Te}#*>h)8zSq11PZB-ewZ~qLW zKinwt{yMUaR<=M@gdprp2&f6<%s3&NgNd^KTdS*|q#S%swU;XxHOi!1oYZ_#^t;Qx zzCLGWjEQ3Hv!5L;?%NZ8dDyNfyQG^cf4&U{WB(%F4_GNOHv-8CMpB_Pt=NC0_BnYezh2`l=(!dE zMzSaEQulXblOY}r)_$D^+?2sH68CGc>h&?cDOI0 zEcj^h>}Ti`xpGNXGRNvN+k=;*gxpd-(r?gF#94XbqECaRDIGf|g^REn8--RTz0kY0 zXpWyxuXjS8e`#hVkL?H&XuQx@R^E4J)_VwqsbxaKxY4W+b1_rz&YxH8s|z<*vlrBT ze@)qG9eaa)GpsZ+YC(p*){yi^Hvhx!)*7#pAu+4PnxB#KiOJb6=<=7bvTjglum9TS z+|oVox*A>)jYU3TFuEajXIXAJ%GGFPyH#`N;DZF|_fF1auAqWC){b*M#(O9e+gU>H z!m)MP5|)`5azDC$R}$m3lO(!lVr@JqB*S6rp*IUyIr#phXkAw$S-0Gn*ITD~gT#ot zWPj!KUr5qzABSw59h(O{`A~-J$ATf(>|V0qu^v}^m5SoexeBPqWQSjSw2m^ zItYReo{hx7PJ*Ps=`ooz|FDY2V^C}yba~xbMuR;&Rki2z71JEZ}fig z)Sj35b-nIdQ_82uAT^MJ7&cP1nP9LY0yV8W?!8Eq`hMPNFBsl7Q6shgZlLGSy2q~j zGVHgvm}B`^mf!C~y!(xU=-^cV5E3Gz4!N4=k2C&Cu<|5am3r=qr81HwX^2LwMCQVq zDq$oa6>rx4GCA)w2~{Aog*PUMf~%bDoPLq@HwvkHtfiiJGYL$sgltIv+}H_Wr|6hJ z?G5f%>3_xCRG-iqn{J!1nSxa=#Sj6SZ**>PF=u7T_U&HxJBn3ixb7?KxEN#`Zi@t) z;;;D%#}A-gpyUH|wIRBvKc^ zQH4s{jz)Ds3ip-Mu5>j9amKAq#x8SirMaW)1|_=Td=z2%*hX~^z`4r@bJI{bJHxE2 z~)D^YPV+dOL`rK(0S5Ex3)M*X3{*T@+PcL)EZALRxq{@bd zkb5lpjblbQ-Z%btIA=R=A`64Kc=!yervI?@C#j0G5Lo(^={kKV4LXw2vJD}av%&cz+HcG|z zef@WBZV63*H9imX$%tyrw_liGIDLkuKuptR3e`?|Nt*uf`); z>zL7?ozjF|p+G1{sr8{2(v@H;)snB2aT%WsYhpc`|Ed+>kS^U@2mU>4)2>Q& zUrCpZTe;~{)-X5DB@?-*yE~d}*=5bs#O|1&q%7MtsQTRE={ZOit9ZEUx)eR=yE8cW z)W7kpNV${I>}rGXu!D3dZZOz`(WPTesO6k-zn(~@aQDp`^>r8_KOq&Bt)kx7=Tu4B zeAe?^d&EYk@~*L}B&jSbq?|-uzJc~%uD>wKX8o83&(pBFkaH&_vOl^g3OHYlV_c@I zYAx^!oK@fLovqeyON6kbvA3+^VnCvgI#FUxll zDQY;1HJo&VN)0K|>>^9|y8!^ec$1S@r2UO$dWwL8TalNRqx1)q#+YD*P`eUxxI?H_ z(AbWrb-QEv^>$Y*{G%n5CI%9N-Emq|xB8YR1k0c;>@gPWm4zacCI$pja0J21u%IX}=TekJhQf#Y7bPY@vOym%RSr?=SPl#kzm zX(K42ZcSfpkr`G&#_a;)RO