From 3706a37ca1d105a4e89b392bd1d46dde47e75965 Mon Sep 17 00:00:00 2001 From: ShakeyHands91 <143759757+ShakeyHands91@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:10:06 +0100 Subject: [PATCH 1/5] Add files via upload --- wtrlab.ts | 2022 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2022 insertions(+) create mode 100644 wtrlab.ts diff --git a/wtrlab.ts b/wtrlab.ts new file mode 100644 index 000000000..07ebb583f --- /dev/null +++ b/wtrlab.ts @@ -0,0 +1,2022 @@ +import { Plugin } from '@/types/plugin'; +import { fetchApi } from '@libs/fetch'; +import { FilterTypes, Filters } from '@libs/filterInputs'; +import { CheerioAPI, load as parseHTML } from 'cheerio'; +import { gcm } from '@libs/aes'; +import { storage } from '@libs/storage'; + +class WTRLAB implements Plugin.PluginBase { + id = 'WTRLAB'; + name = 'WTR-LAB'; + site = 'https://wtr-lab.com/'; + version = '1.2.2'; + icon = 'src/en/wtrlab/icon.png'; + sourceLang = 'en/'; + baggage = ''; + trace = ''; + + pluginSettings = { + signInUrl: { + value: '', + label: + 'Sign-in link — request "Continue with Email" on wtr-lab, then paste the full link from that email here (the plugin reads the token out of it and redeems it). Clear this field once AI chapters load; links are single-use and short-lived.', + type: 'Text', + }, + sessionCookie: { + value: '', + label: + 'Session cookie (fallback) — usually leave EMPTY. Android replaces this header with its own stored cookies whenever it has any, so the sign-in link above is the reliable route.', + type: 'Text', + }, + preferredMode: { + value: 'ai', + label: 'Preferred translation', + type: 'Select', + options: [ + { label: 'AI', value: 'ai' }, + { label: 'Web+', value: 'webplus' }, + { label: 'Web', value: 'web' }, + { label: 'Custom — set the id below', value: 'custom' }, + ], + }, + customMode: { + value: '', + label: + 'Custom translation id — only used when "Custom" is selected above. This is the value wtr-lab sends as "translate" in its /api/reader/get request.', + type: 'Text', + }, + fallbackToWeb: { + value: true, + label: 'Fall back to Web when the preferred translation is unavailable', + type: 'Switch', + }, + showModeNotice: { + value: true, + label: 'Show which translation was used at the top of each chapter', + type: 'Switch', + }, + }; + + /** Full Cookie header value supplied by the user in plugin settings. */ + get sessionCookie(): string { + return (storage.get('sessionCookie') || '').trim(); + } + + /** Only attempt the sign-in link once per app run: the links are single-use. */ + signInAttempted = false; + + /** + * Visits the user's emailed sign-in link once. Any Set-Cookie it returns is + * stored by Android's shared cookie jar, which every later request uses + * automatically — unlike a Cookie header, which the jar overrides. + * + * @returns a short status line to show the user, or null if nothing was tried. + */ + async ensureSignedIn(): Promise { + const raw = (storage.get('signInUrl') || '').trim(); + if (!raw || this.signInAttempted) return null; + this.signInAttempted = true; + + // A full URL must be wtr-lab's own; a bare token is accepted too. + if ( + /^[a-z]+:\/\//i.test(raw) && + !/^https:\/\/([a-z0-9-]+\.)*wtr-lab\.com\//i.test(raw) + ) { + return 'Sign-in link ignored — it is not an https wtr-lab.com address.'; + } + + // The emailed link points at a PAGE whose JavaScript redeems the token. + // Nothing runs that script here, so pull the token out and call the + // redeem endpoint directly. + let token = ''; + const fromQuery = raw.match(/[?&]token=([^&\s]+)/); + if (fromQuery) { + token = decodeURIComponent(fromQuery[1]); + } else if (/^[A-Za-z0-9_.-]{16,}$/.test(raw)) { + token = raw; + } + + if (!token) { + return 'Sign-in link ignored — no token found. Paste the whole link from the email (it contains "token=").'; + } + + const verifyUrl = `${this.site}api/auth/magic-link/verify?token=${encodeURIComponent( + token, + )}&callbackURL=/`; + + try { + const res = await fetchApi(verifyUrl, { + headers: { + 'Accept': 'application/json,text/html,*/*', + 'Referer': this.site, + }, + }); + const landedOn = (res.url || '').replace(this.site, '/') || 'unknown'; + return `Sign-in: redeem token HTTP ${res.status}, ended at ${landedOn}`; + } catch (e) { + return `Sign-in failed: ${String(e)}`; + } + } + + /** + * Asks wtr-lab who it thinks we are. This is the ground truth for whether a + * session actually survived into the plugin's requests. + */ + async checkSession(): Promise { + try { + const cookie = this.sessionCookie; + const res = await fetchApi(`${this.site}api/auth/get-session`, { + headers: { + 'Accept': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + }, + }); + const text = await res.text(); + let body = null; + try { + body = JSON.parse(text); + } catch (e) { + body = null; + } + // wtr-lab nests this differently in places, so check the likely shapes. + const user = + body?.user || + body?.session?.user || + body?.data?.user || + body?.session?.session?.user; + if (typeof user === 'string' && user) { + return `signed in (${user})`; + } + if (user && typeof user === 'object') { + const label = + user.user_name || + user.name || + user.username || + user.email || + user.id || + ''; + return label ? `signed in as ${label}` : 'signed in'; + } + return `NOT signed in (get-session HTTP ${res.status}: ${ + text + .slice(0, 60) + .replace(/<[^>]*>/g, ' ') + .trim() || 'empty response' + })`; + } catch (e) { + return `session check failed: ${String(e)}`; + } + } + + /** Translation modes to try, in order, based on plugin settings. */ + get translationModes(): string[] { + const preferred = (storage.get('preferredMode') || 'ai').trim(); + const custom = (storage.get('customMode') || '').trim(); + const fallback = storage.get('fallbackToWeb'); + + const modes: string[] = []; + if (preferred === 'custom') { + if (custom) modes.push(custom); + } else if (preferred) { + modes.push(preferred); + } + + // `false` means the user turned it off; `undefined` means never set, so default to on. + if (fallback !== false && !modes.includes('web')) modes.push('web'); + if (modes.length === 0) modes.push('web'); + + return modes; + } + + get headers(): Record { + const headers: Record = { + baggage: this.baggage, + 'sentry-trace': this.trace, + }; + if (this.sessionCookie) headers.Cookie = this.sessionCookie; + return headers; + } + + async popularNovels( + page: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + let link = this.site + this.sourceLang + 'novel-list?'; + + const params = new URLSearchParams(); + params.append('orderBy', filters.orderBy.value); + params.append('order', filters.order.value); + params.append('status', filters.status.value); + params.append('release_status', filters.release_status.value); + params.append('addition_age', filters.addition_age.value); + params.append('page', page.toString()); + + if (filters.search.value) { + params.append('text', filters.search.value); + } + + if ( + filters.genres.value?.include && + filters.genres.value.include.length > 0 + ) { + params.append('gi', filters.genres.value.include.join(',')); + params.append('gc', filters.genre_operator.value); + } + if ( + filters.genres.value?.exclude && + filters.genres.value.exclude.length > 0 + ) { + params.append('ge', filters.genres.value.exclude.join(',')); + } + + if (filters.tags.value?.include && filters.tags.value.include.length > 0) { + params.append('ti', filters.tags.value.include.join(',')); + params.append('tc', filters.tag_operator.value); + } + if (filters.tags.value?.exclude && filters.tags.value.exclude.length > 0) { + params.append('te', filters.tags.value.exclude.join(',')); + } + + if (filters.folders.value) { + params.append('folders', filters.folders.value); + } + if (filters.library_exclude.value) { + params.append('le', filters.library_exclude.value); + } + + if (filters.min_chapters.value) { + params.append('count_value', filters.min_chapters.value); + } + if (filters.min_rating.value) { + params.append('minr', filters.min_rating.value); + } + if (filters.min_review_count.value) { + params.append('minrc', filters.min_review_count.value); + } + + if (showLatestNovels) { + const response = await fetchApi(this.site + 'api/home/recent', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ page: page }), + }); + + const recentNovel: JsonNovel = await response.json(); + + // Parse novels from JSON + const novels: Plugin.NovelItem[] = recentNovel.data.map( + (datum: Datum) => ({ + name: datum.serie.data.title || datum.serie.slug || '', + cover: datum.serie.data.image, + path: + this.sourceLang + + 'serie-' + + datum.serie.raw_id + + '/' + + datum.serie.slug || '', + }), + ); + + return novels; + } else { + const finderPage = await fetchApi(this.site + 'en/novel-finder').then( + res => res.text(), + ); + const finderCheerio = parseHTML(finderPage); + const nextData = finderCheerio('#__NEXT_DATA__').html(); + if (!nextData) { + throw new Error('Could not find __NEXT_DATA__ on novel finder page'); + } + const buildId = JSON.parse(nextData).buildId; + + link = `${this.site}_next/data/${buildId}/en/novel-finder.json?${params.toString()}`; + + const response = await fetchApi(link); + const json = await response.json(); + const seenIds = new Set(); + + const novels: Plugin.NovelItem[] = json.pageProps.series + .filter((novel: Datum) => { + if (seenIds.has(novel.raw_id)) { + return false; + } + seenIds.add(novel.raw_id); + return true; + }) + .map((novel: Datum) => ({ + name: novel.data.title, + cover: novel.data.image, + path: `${this.sourceLang}serie-${novel.raw_id}/${novel.slug}`, + })); + + return novels; + } + } + + async fetchTokens() { + const body = await fetchApi(this.site + this.sourceLang).then(res => + res.text(), + ); + const $ = parseHTML(body); + + this.baggage = $('meta[name="baggage"]').attr('content') ?? ''; + this.trace = $('meta[name="sentry-trace"]').attr('content') ?? ''; + } + + async parseNovel(novelPath: string): Promise { + const body = await fetchApi(this.site + novelPath).then(res => res.text()); + const loadedCheerio = parseHTML(body); + + const baggage = loadedCheerio('meta[name="baggage"]').attr('content'); + const trace = loadedCheerio('meta[name="sentry-trace"]').attr('content'); + + if (baggage && trace) { + this.baggage = baggage; + this.trace = trace; + } else if (!this.baggage || !this.trace) { + await this.fetchTokens(); + } + + const nextDataElement = loadedCheerio('#__NEXT_DATA__'); + const nextDataText = nextDataElement.html(); + + let rawId: number | null = null; + let slug: string | null = null; + let chapterCount = 0; + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: loadedCheerio('h1.text-uppercase').text(), + summary: loadedCheerio('.lead').text().trim(), + }; + + if (nextDataText) { + try { + const jsonData = JSON.parse(nextDataText); + const serieData = jsonData?.props?.pageProps?.serie?.serie_data; + + // console.log('Parsed novel JSON data:', serieData); + + if (serieData) { + novel.name = serieData.data?.title || ''; + novel.cover = serieData.data?.image || ''; + novel.summary = serieData.data?.description || ''; + novel.author = serieData.data?.author || ''; + rawId = serieData.raw_id || null; + slug = serieData.slug || null; + + switch (serieData.status) { + case 0: + novel.status = 'Ongoing'; + break; + case 1: + novel.status = 'Completed'; + break; + default: + novel.status = 'Unknown'; + } + } + } catch (error) { + console.error('Failed to parse __NEXT_DATA__:', error); + } + } + + if (!novel.name) { + novel.name = + loadedCheerio('h1.text-uppercase').text() || + loadedCheerio('h1.long-title').text() || + loadedCheerio('.title-wrap h1').text().trim(); + } + + if (!novel.cover) { + novel.cover = + loadedCheerio('.image-wrap img').attr('src') || + loadedCheerio('.img-wrap > img').attr('src'); + } + + if (!novel.summary) { + novel.summary = + loadedCheerio('.description').text().trim() || + loadedCheerio('.desc-wrap .description').text().trim() || + loadedCheerio('.lead').text().trim(); + } + + // Genres and tags live in __NEXT_DATA__ as numeric ids, not in the page + // markup. Tag names are served already resolved in pageProps.tags; genre + // names appear only as the text of their chip links. + const labels: string[] = []; + + if (nextDataText) { + try { + const tagData = JSON.parse(nextDataText); + const pageProps = tagData?.props?.pageProps; + const ids: number[] = pageProps?.serie?.serie_data?.genres || []; + + const genreNames = new Map(); + loadedCheerio('a[href*="novel-list?genre="]').each((i, el) => { + const href = loadedCheerio(el).attr('href') || ''; + const matched = href.match(/genre=(\d+)/); + const name = loadedCheerio(el).text().trim(); + if (matched && name) genreNames.set(parseInt(matched[1], 10), name); + }); + + for (const id of ids) { + const name = genreNames.get(id); + if (name) { + labels.push(name.charAt(0).toUpperCase() + name.slice(1)); + } + } + + if (Array.isArray(pageProps?.tags)) { + for (const tag of pageProps.tags) { + const title = tag?.title && String(tag.title).trim(); + if (title) labels.push(title); + } + } + } catch (error) { + console.error('Failed to read genres/tags from __NEXT_DATA__:', error); + } + } + + if (labels.length > 0) { + novel.genres = labels + .filter((label, index) => labels.indexOf(label) === index) + .join(', '); + } + + if (!novel.author) { + novel.author = + loadedCheerio('td:contains("Author")') + .next() + .text() + .replace(/[\t\n]/g, '') + .trim() || + loadedCheerio('td:contains("Author") + td') + .text() + .replace(/[\t\n]/g, '') + .trim(); + } + + if (!novel.status) { + novel.status = + loadedCheerio('td:contains("Status")') + .next() + .text() + .replace(/[\t\n]/g, '') + .trim() || + loadedCheerio('td:contains("Status") + td') + .text() + .replace(/[\t\n]/g, '') + .trim() || + loadedCheerio('.detail-line:contains("•")') + .text() + .match(/•\s*(\w+)/)?.[1] || + ''; + } + + const urlMatch = novelPath.match(/(?:serie|novel)-?(\d+)\/([^/]+)/); + if (urlMatch) { + rawId = parseInt(urlMatch[1]); + slug = urlMatch[2]; + } + + const chapterCountText = + loadedCheerio('.detail-line:contains("Chapters")').text() || + loadedCheerio('div:contains("Chapters")').text(); + const chapterCountMatch = chapterCountText.match(/(\d+)\s+Chapters?/i); + if (chapterCountMatch) { + chapterCount = parseInt(chapterCountMatch[1]); + } + if (chapterCount === 0 && nextDataText) { + try { + const jsonData = JSON.parse(nextDataText); + + chapterCount = + jsonData?.props?.pageProps?.serie?.serie_data?.chapter_count ?? 0; + } catch (error) { + console.error( + 'Failed to parse chapter_count from __NEXT_DATA__:', + error, + ); + } + } + let chapters: Plugin.ChapterItem[] = []; + + if (rawId && slug) { + try { + chapters = await this.fetchAllChapters(rawId, slug); + } catch (error) { + console.error('Failed to fetch chapters via API:', error); + chapters = []; + } + } else { + console.warn('Could not extract rawId or slug from page', { + rawId, + slug, + }); + } + + novel.chapters = chapters; + + return novel; + } + + async decrypt(encrypted: string, encKey: string) { + try { + // t is set to false here; true if arr: + // If true we parse as json + let t = !1, + u = encrypted; + // t true if arr:, str: straight, else error + encrypted.startsWith('arr:') + ? ((t = !0), (u = encrypted.substring(4))) + : encrypted.startsWith('str:') && (u = encrypted.substring(4)); + const r = u.split(':'); + if (3 !== r.length) throw Error('Invalid encrypted data format'); + + // Remove base64, setup vars + const [iv, tag, ciphertext] = r.map(part => + Uint8Array.from(atob(part), e => e.charCodeAt(0)), + ), + combined = new Uint8Array(ciphertext.length + tag.length); + + // Make the ciphertext + tag format expected for decryption + combined.set(ciphertext), combined.set(tag, ciphertext.length); + + // Decrypt with encKey + // Convert the key to bytes (first 32 characters of encKey) + const keyBytes = new TextEncoder().encode(encKey.slice(0, 32)); + + // Create AES-GCM cipher instance + const aes = gcm(keyBytes, iv); + + // Decrypt the combined ciphertext + const decrypted = aes.decrypt(combined); + + // Convert decrypted bytes to string + const m = new TextDecoder().decode(decrypted); + + // const D = new TextEncoder().encode(encKey.slice(0, 32)); + // const d = await crypto.subtle.importKey( + // 'raw', + // D, + // { name: 'AES-GCM' }, + // !1, + // ['decrypt'], + // ); + // const h = await crypto.subtle.decrypt( + // { name: 'AES-GCM', iv: iv }, + // d, + // combined, + // ); + // const m = new TextDecoder().decode(h); + + // If it was arr:, parse as json + if (t) return JSON.parse(m); + // Otherwise (str:) return straight + return m; + } catch (error) { + console.error('Client-side decryption error:', error); + const msg = { 'error': `

Client-side decryption error:

${error}` }; + return msg; + } + } + + async getKey($: CheerioAPI): Promise { + // Fetch the novel's data in JSON format + const searchKey = 'TextEncoder().encode("'; + + const URLs: string[] = []; + let code: string | undefined; + let index = -1; + + // Find URL with API Key + const scripts = $('head').find('script').toArray(); + for (const el of scripts) { + const src = $(el).attr('src'); + if (!src) continue; + if (URLs.includes(src)) continue; + URLs.push(src); + } + + for (const src of URLs) { + const script = await fetchApi(`${this.site}${src}`); + const raw = await script.text(); + index = raw.indexOf(searchKey); + if (index >= 0) { + code = raw; + break; + } + } + if (!code) { + throw new Error('Failed to find Encryption Key'); + } + // Get right segment of code + const encKey = code.substring(index + 22, index + 54); + return encKey; + } + + async translate(data: string[]): Promise { + const contained = data.map((line, i) => `${line}`); + + const response = await fetchApi( + 'https://translate-pa.googleapis.com/v1/translateHtml', + { + 'credentials': 'omit', + 'headers': { + 'content-type': 'application/json+protobuf', + // Generic public API key source also uses + // Seen all over google + 'X-Goog-API-Key': 'AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520', + }, + 'referrer': 'https://wtr-lab.com/', + 'body': `[[${JSON.stringify(contained)},"zh-CN","en"],"te_lib"]`, + 'method': 'POST', + }, + ); + const translated = await response.json(); + const out = translated && translated[0] ? translated[0] : []; + return out as string[]; + } + + async parseChapter(chapterPath: string): Promise { + const url = this.site + chapterPath; + let rawId: number | null = null; + let chapterNo: number | null = null; + let loadedCheerio = null; + + const urlMatch = chapterPath.match( + /(?:serie|novel)-?(\d+)\/[^/]+\/chapter-(\d+)/, + ); + if (urlMatch) { + rawId = parseInt(urlMatch[1], 10); + chapterNo = parseInt(urlMatch[2], 10); + // console.log('Extracted from URL - rawId:', rawId, 'chapterNo:', chapterNo); + } + + if (!rawId || !chapterNo) { + const body = await fetchApi(url).then(res => res.text()); + + loadedCheerio = parseHTML(body); + const chapterJson = loadedCheerio('#__NEXT_DATA__').html() + ''; + const jsonData: NovelJson = JSON.parse(chapterJson); + + // const chapterID = jsonData.props.pageProps.serie.chapter.id; + rawId = jsonData.props.pageProps.serie.chapter.raw_id; + chapterNo = jsonData.props.pageProps.serie.chapter.order; + } + + if (!rawId || !chapterNo) { + const errorMsg = `Missing required parameters for API call from URL '${chapterPath}' - rawId: ${rawId}, chapterNo: ${chapterNo}. Please check the URL format.`; + console.error(errorMsg); + throw new Error(errorMsg); + } + + const translationTypes = this.translationModes; + const cookie = this.sessionCookie; + + const attemptLog: string[] = []; + let parsedJson; + let usedType: string | null = null; + + // Establish a session from the emailed link before asking for a chapter. + const signInNote = await this.ensureSignedIn(); + if (signInNote) attemptLog.push(signInNote); + + for (const type of translationTypes) { + const apiResponse = await fetchApi(`${this.site}api/reader/get`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + }, + referrer: url, + body: JSON.stringify({ + translate: type, + language: this.sourceLang.replace('/', ''), + raw_id: rawId, + chapter_no: chapterNo, + retry: false, + force_retry: false, + }), + }); + + // Read as text first: an auth redirect or a Cloudflare challenge returns + // HTML, and .json() would throw before we could report what came back. + const rawBody = await apiResponse.text(); + let candidate = null; + try { + candidate = JSON.parse(rawBody); + } catch (e) { + candidate = null; + } + + if (!candidate) { + attemptLog.push( + `"${type}": HTTP ${apiResponse.status} — response was not JSON: ${rawBody + .slice(0, 150) + .replace(/<[^>]*>/g, ' ') + .trim()}`, + ); + continue; + } + + parsedJson = candidate; + + if (!apiResponse.ok) { + attemptLog.push( + `"${type}": HTTP ${apiResponse.status}${ + candidate.error ? ' — ' + candidate.error : '' + }${candidate.message ? ' — ' + candidate.message : ''}`, + ); + continue; + } + if (candidate.error) { + attemptLog.push(`"${type}": ${candidate.error}`); + continue; + } + if (candidate.success === false) { + attemptLog.push( + `"${type}": ${candidate.message || 'request was not successful'}`, + ); + continue; + } + + usedType = type; + break; + } + + const showNotice = storage.get('showModeNotice') !== false; + const cookieState = showNotice + ? await this.checkSession() + : cookie + ? `session cookie sent (${cookie.length} chars)` + : 'no session cookie set'; + + if (!usedType || !parsedJson?.data?.data) { + const errorMsg = + `None of the requested translations could be loaded [${cookieState}]. ` + + (attemptLog.length + ? attemptLog.join(' | ') + : 'The server returned no usable response.'); + console.error(errorMsg); + throw new Error(errorMsg); + } + let chapterContent: any = parsedJson.data.data.body; + const chapterTitle: string | undefined = parsedJson?.chapter?.title; + const chapterGlossary: ChapterContent['glossary_data'] | undefined = + parsedJson?.data?.data?.glossary_data; + + let htmlString = ''; + if (chapterTitle) { + htmlString += `

${chapterTitle}

`; + } + + if ( + chapterContent.toString().startsWith('arr:') || + chapterContent.toString().startsWith('str:') + ) { + if (!loadedCheerio) { + const body = await fetchApi(url).then(res => res.text()); + + loadedCheerio = parseHTML(body); + } + const encKey = await this.getKey(loadedCheerio); + chapterContent = await this.decrypt(chapterContent, encKey); + if (Object.prototype.hasOwnProperty.call(chapterContent, 'error')) { + htmlString += `

${chapterContent.error.toString()}

`; + return htmlString; + } + chapterContent = await this.translate(chapterContent); + usedType = `${usedType} + Google Translate (on-device)`; + } + + if (storage.get('showModeNotice') !== false && usedType) { + htmlString += `

Translation: ${usedType} — ${cookieState}

`; + } + + if (attemptLog.length) { + htmlString += `

Skipped: ${attemptLog.join( + ' | ', + )}

`; + } + + const dictionary = chapterGlossary?.terms?.map(t => t[0]) || []; + + for (let text of chapterContent) { + if (dictionary.length > 0) { + text = text.replaceAll( + /(?:wtr-lab\s+)?※([0-9]+)[⛬〓]/g, + (m: string, index: string) => dictionary[parseInt(index)] || m, + ); + } + htmlString += `

${text}

`; + } + + return htmlString; + } + + async fetchAllChapters( + rawId: number, + slug: string, + ): Promise { + const allChapters: Plugin.ChapterItem[] = []; + const batchSize = 500; + let start = 1; + let hasMore = true; + + while (hasMore) { + const end = start + batchSize - 1; + + try { + const response = await fetchApi( + `${this.site}api/chapters/${rawId}?start=${start}&end=${end}`, + { + headers: { + ...this.headers, + }, + }, + ); + + const data = await response.json(); + const chapters = data.chapters ?? data.data?.chapters ?? []; + + if (!Array.isArray(chapters) || chapters.length === 0) { + hasMore = false; + break; + } + + const batchChapters: Plugin.ChapterItem[] = chapters.map( + (apiChapter: ApiChapter) => ({ + name: + apiChapter.title || + apiChapter.name || + `Chapter ${apiChapter.order}`, + path: `${this.sourceLang}serie-${rawId}/${slug}/chapter-${apiChapter.order}`, + releaseTime: apiChapter.updated_at?.substring(0, 10), + chapterNumber: apiChapter.order, + }), + ); + + allChapters.push(...batchChapters); + + if (chapters.length < batchSize) { + hasMore = false; + break; + } + + start += batchSize; + } catch (error) { + console.error(`Failed to fetch chapters ${start}-${end}:`, error); + hasMore = false; + break; + } + } + + return allChapters.sort( + (a, b) => (a.chapterNumber || 0) - (b.chapterNumber || 0), + ); + } + + async searchNovels( + searchTerm: string, + page: number, + ): Promise { + const filters = this.filters; + filters.search.value = searchTerm; + return this.popularNovels(page, { showLatestNovels: false, filters }); + } + + filters = { + search: { + value: '', + label: 'Search', + type: FilterTypes.TextInput, + }, + orderBy: { + value: 'update', + label: 'Order by', + options: [ + { label: 'Update Date', value: 'update' }, + { label: 'Addition Date', value: 'date' }, + { label: 'Random', value: 'random' }, + { label: 'Weekly View', value: 'weekly_rank' }, + { label: 'Monthly View', value: 'monthly_rank' }, + { label: 'All-Time View', value: 'view' }, + { label: 'Name', value: 'name' }, + { label: 'Reader', value: 'reader' }, + { label: 'Chapter', value: 'chapter' }, + { label: 'Rating', value: 'rating' }, + { label: 'Review Count', value: 'total_rate' }, + { label: 'Vote Count', value: 'vote' }, + ], + type: FilterTypes.Picker, + }, + order: { + value: 'desc', + label: 'Order', + options: [ + { label: 'Descending', value: 'desc' }, + { label: 'Ascending', value: 'asc' }, + ], + type: FilterTypes.Picker, + }, + status: { + value: 'all', + label: 'Status', + options: [ + { label: 'All', value: 'all' }, + { label: 'Ongoing', value: 'ongoing' }, + { label: 'Completed', value: 'completed' }, + { label: 'Hiatus', value: 'hiatus' }, + { label: 'Dropped', value: 'dropped' }, + ], + type: FilterTypes.Picker, + }, + release_status: { + value: 'all', + label: 'Release Status', + options: [ + { label: 'All', value: 'all' }, + { label: 'Released', value: 'released' }, + { label: 'On Voting', value: 'voting' }, + ], + type: FilterTypes.Picker, + }, + addition_age: { + value: 'all', + label: 'Addition Age', + options: [ + { label: 'All', value: 'all' }, + { label: '< 2 Days', value: 'day' }, + { label: '< 1 Week', value: 'week' }, + { label: '< 1 Month', value: 'month' }, + ], + type: FilterTypes.Picker, + }, + min_chapters: { + value: '', + label: 'Minimum Chapters', + type: FilterTypes.TextInput, + }, + min_rating: { + value: '', + label: 'Minimum Rating (0.0-5.0)', + type: FilterTypes.TextInput, + }, + min_review_count: { + value: '', + label: 'Minimum Review Count', + type: FilterTypes.TextInput, + }, + genre_operator: { + value: 'and', + label: 'Genre (And/Or)', + options: [ + { label: 'And', value: 'and' }, + { label: 'Or', value: 'or' }, + ], + type: FilterTypes.Picker, + }, + genres: { + label: 'Genres', + type: FilterTypes.ExcludableCheckboxGroup, + value: { include: [], exclude: [] }, + options: [ + { label: 'Action', value: '1' }, + { label: 'Adult', value: '2' }, + { label: 'Adventure', value: '3' }, + { label: 'Comedy', value: '4' }, + { label: 'Drama', value: '5' }, + { label: 'Ecchi', value: '6' }, + { label: 'Erciyuan', value: '7' }, + { label: 'Fan-Fiction', value: '8' }, + { label: 'Fantasy', value: '9' }, + { label: 'Game', value: '10' }, + { label: 'Gender-Bender', value: '11' }, + { label: 'Harem', value: '12' }, + { label: 'Historical', value: '13' }, + { label: 'Horror', value: '14' }, + { label: 'Josei', value: '15' }, + { label: 'Martial-Arts', value: '16' }, + { label: 'Mature', value: '17' }, + { label: 'Mecha', value: '18' }, + { label: 'Military', value: '19' }, + { label: 'Mystery', value: '20' }, + { label: 'Psychological', value: '21' }, + { label: 'Romance', value: '22' }, + { label: 'School-Life', value: '23' }, + { label: 'Sci-Fi', value: '24' }, + { label: 'Seinen', value: '25' }, + { label: 'Shoujo', value: '26' }, + { label: 'Shoujo-Ai', value: '27' }, + { label: 'Shounen', value: '28' }, + { label: 'Shounen-Ai', value: '29' }, + { label: 'Slice-Of-Life', value: '30' }, + { label: 'Smut', value: '31' }, + { label: 'Sports', value: '32' }, + { label: 'Supernatural', value: '33' }, + { label: 'Tragedy', value: '34' }, + { label: 'Urban-Life', value: '35' }, + { label: 'Wuxia', value: '36' }, + { label: 'Xianxia', value: '37' }, + { label: 'Xuanhuan', value: '38' }, + { label: 'Yaoi', value: '39' }, + { label: 'Yuri', value: '40' }, + ], + }, + tag_operator: { + value: 'and', + label: 'Tag (And/Or)', + options: [ + { label: 'And', value: 'and' }, + { label: 'Or', value: 'or' }, + ], + type: FilterTypes.Picker, + }, + + tags: { + label: 'Tags', + type: FilterTypes.ExcludableCheckboxGroup, + value: { + include: [], + exclude: [], + }, + options: [ + { label: 'Abandoned Children', value: '1' }, + { label: 'Ability Steal', value: '2' }, + { label: 'Absent Parents', value: '3' }, + { label: 'Abusive Characters', value: '4' }, + { label: 'Academy', value: '5' }, + { label: 'Accelerated Growth', value: '6' }, + { label: 'Acting', value: '7' }, + { label: 'Adapted from Manga', value: '8' }, + { label: 'Adapted from Manhua', value: '9' }, + { label: 'Adapted to Anime', value: '10' }, + { label: 'Adapted to Drama', value: '11' }, + { label: 'Adapted to Drama CD', value: '12' }, + { label: 'Adapted to Game', value: '13' }, + { label: 'Adapted to Manga', value: '14' }, + { label: 'Adapted to Manhua', value: '15' }, + { label: 'Adapted to Manhwa', value: '16' }, + { label: 'Adapted to Movie', value: '17' }, + { label: 'Adapted to Visual Novel', value: '18' }, + { label: 'Adopted Children', value: '19' }, + { label: 'Adopted Protagonist', value: '20' }, + { label: 'Adultery', value: '21' }, + { label: 'Adventurers', value: '22' }, + { label: 'Affair', value: '23' }, + { label: 'Age Progression', value: '24' }, + { label: 'Age Regression', value: '25' }, + { label: 'Aggressive Characters', value: '26' }, + { label: 'Alchemy', value: '27' }, + { label: 'Aliens', value: '28' }, + { label: 'All-Girls School', value: '29' }, + { label: 'Alternate World', value: '30' }, + { label: 'Amnesia', value: '31' }, + { label: 'Amusement Park', value: '32' }, + { label: 'Anal', value: '33' }, + { label: 'Ancient China', value: '34' }, + { label: 'Ancient Times', value: '35' }, + { label: 'Androgynous Characters', value: '36' }, + { label: 'Androids', value: '37' }, + { label: 'Angels', value: '38' }, + { label: 'Animal Characteristics', value: '39' }, + { label: 'Animal Rearing', value: '40' }, + { label: 'Anti-Magic', value: '41' }, + { label: 'Anti-social Protagonist', value: '42' }, + { label: 'Antihero Protagonist', value: '43' }, + { label: 'Antique Shop', value: '44' }, + { label: 'Apartment Life', value: '45' }, + { label: 'Apathetic Protagonist', value: '46' }, + { label: 'Apocalypse', value: '47' }, + { label: 'Appearance Changes', value: '48' }, + { label: 'Appearance Different from Actual Age', value: '49' }, + { label: 'Archery', value: '50' }, + { label: 'Aristocracy', value: '51' }, + { label: 'Arms Dealers', value: '52' }, + { label: 'Army', value: '53' }, + { label: 'Army Building', value: '54' }, + { label: 'Arranged Marriage', value: '55' }, + { label: 'Array', value: '822' }, + { label: 'Arrogant Characters', value: '56' }, + { label: 'Artifact Crafting', value: '57' }, + { label: 'Artifacts', value: '58' }, + { label: 'Artificial Intelligence', value: '59' }, + { label: 'Artists', value: '60' }, + { label: 'Assassins', value: '61' }, + { label: 'Astrologers', value: '62' }, + { label: 'Autism', value: '63' }, + { label: 'Automatons', value: '64' }, + { label: 'Average-looking Protagonist', value: '65' }, + { label: 'Award-winning Work', value: '66' }, + { label: 'Awkward Protagonist', value: '67' }, + { label: 'Bands', value: '68' }, + { label: 'Based on a Movie', value: '69' }, + { label: 'Based on a Song', value: '70' }, + { label: 'Based on a TV Show', value: '71' }, + { label: 'Based on a Video Game', value: '72' }, + { label: 'Based on a Visual Novel', value: '73' }, + { label: 'Based on an Anime', value: '74' }, + { label: 'Basketball', value: '809' }, + { label: 'Battle Academy', value: '75' }, + { label: 'Battle Competition', value: '76' }, + { label: 'BDSM', value: '77' }, + { label: 'Beast Companions', value: '78' }, + { label: 'Beastkin', value: '79' }, + { label: 'Beasts', value: '80' }, + { label: 'Beautiful Female Lead', value: '81' }, + { label: 'Bestiality', value: '82' }, + { label: 'Betrayal', value: '83' }, + { label: 'Bickering Couple', value: '84' }, + { label: 'Biochip', value: '85' }, + { label: 'Bisexual Protagonist', value: '86' }, + { label: 'Black Belly', value: '87' }, + { label: 'Blackmail', value: '88' }, + { label: 'Blacksmith', value: '89' }, + { label: 'Bleach', value: '770' }, + { label: 'Blind Dates', value: '90' }, + { label: 'Blind Protagonist', value: '91' }, + { label: 'Blood Manipulation', value: '92' }, + { label: 'Bloodlines', value: '93' }, + { label: 'Body Swap', value: '94' }, + { label: 'Body Tempering', value: '95' }, + { label: 'Body-double', value: '96' }, + { label: 'Bodyguards', value: '97' }, + { label: 'Books', value: '98' }, + { label: 'Bookworm', value: '99' }, + { label: 'Boss-Subordinate Relationship', value: '100' }, + { label: 'Brainwashing', value: '101' }, + { label: 'Breast Fetish', value: '102' }, + { label: 'Broken Engagement', value: '103' }, + { label: 'Brother Complex', value: '104' }, + { label: 'Brotherhood', value: '105' }, + { label: 'Buddhism', value: '106' }, + { label: 'Bullying', value: '107' }, + { label: 'Business Management', value: '108' }, + { label: 'Business Wars', value: '806' }, + { label: 'Businessmen', value: '109' }, + { label: 'Butlers', value: '110' }, + { label: 'Calm Protagonist', value: '111' }, + { label: 'Cannibalism', value: '112' }, + { label: 'Card Games', value: '113' }, + { label: 'Carefree Protagonist', value: '114' }, + { label: 'Caring Protagonist', value: '115' }, + { label: 'Cautious Protagonist', value: '116' }, + { label: 'Celebrities', value: '117' }, + { label: 'Character Growth', value: '118' }, + { label: 'Charismatic Protagonist', value: '119' }, + { label: 'Charming Protagonist', value: '120' }, + { label: 'Chat Rooms', value: '121' }, + { label: 'Cheats', value: '122' }, + { label: 'Chefs', value: '123' }, + { label: 'Child Abuse', value: '124' }, + { label: 'Child Protagonist', value: '125' }, + { label: 'Childcare', value: '126' }, + { label: 'Childhood Friends', value: '127' }, + { label: 'Childhood Love', value: '128' }, + { label: 'Childhood Promise', value: '129' }, + { label: 'Childish Protagonist', value: '130' }, + { label: 'Chuunibyou', value: '131' }, + { label: 'Clan Building', value: '132' }, + { label: 'Class Awakening', value: '827' }, + { label: 'Classic', value: '133' }, + { label: 'Clever Protagonist', value: '134' }, + { label: 'Clingy Lover', value: '135' }, + { label: 'Clones', value: '136' }, + { label: 'Clubs', value: '137' }, + { label: 'Clumsy Love Interests', value: '138' }, + { label: 'Co-Workers', value: '139' }, + { label: 'Cohabitation', value: '140' }, + { label: 'Cold Love Interests', value: '141' }, + { label: 'Cold Protagonist', value: '142' }, + { label: 'Collection of Short Stories', value: '143' }, + { label: 'College/University', value: '144' }, + { label: 'Coma', value: '145' }, + { label: 'Comedic Undertone', value: '146' }, + { label: 'Coming of Age', value: '147' }, + { label: 'Complex Family Relationships', value: '148' }, + { label: 'Conditional Power', value: '149' }, + { label: 'Conferred Gods', value: '800' }, + { label: 'Confident Protagonist', value: '150' }, + { label: 'Confinement', value: '151' }, + { label: 'Conflicting Loyalties', value: '152' }, + { label: 'Contracts', value: '153' }, + { label: 'Cooking', value: '154' }, + { label: 'Copy', value: '807' }, + { label: 'Corruption', value: '155' }, + { label: 'Cosmic Wars', value: '156' }, + { label: 'Cosplay', value: '157' }, + { label: 'Couple Growth', value: '158' }, + { label: 'Court Official', value: '159' }, + { label: 'Cousins', value: '160' }, + { label: 'Cowardly Protagonist', value: '161' }, + { label: 'Crafting', value: '162' }, + { label: 'Crime', value: '163' }, + { label: 'Criminals', value: '164' }, + { label: 'Cross-dressing', value: '165' }, + { label: 'Crossover', value: '166' }, + { label: 'Cruel Characters', value: '167' }, + { label: 'Cryostasis', value: '168' }, + { label: 'Cultivation', value: '169' }, + { label: 'Cunnilingus', value: '170' }, + { label: 'Cunning Protagonist', value: '171' }, + { label: 'Curious Protagonist', value: '172' }, + { label: 'Curses', value: '173' }, + { label: 'Cute Children', value: '174' }, + { label: 'Cute Protagonist', value: '175' }, + { label: 'Cute Story', value: '176' }, + { label: 'Cyberpunk 2077', value: '783' }, + { label: 'Dancers', value: '177' }, + { label: 'Dao Companion', value: '178' }, + { label: 'Dao Comprehension', value: '179' }, + { label: 'Daoism', value: '180' }, + { label: 'Dark', value: '181' }, + { label: 'Dark Fantasy', value: '789' }, + { label: 'DC Universe', value: '778' }, + { label: 'Dead Protagonist', value: '182' }, + { label: 'Death', value: '183' }, + { label: 'Death of Loved Ones', value: '184' }, + { label: 'Debts', value: '185' }, + { label: 'Delinquents', value: '186' }, + { label: 'Delusions', value: '187' }, + { label: 'Demi-Humans', value: '188' }, + { label: 'Demon Lord', value: '189' }, + { label: 'Demon Slayer', value: '812' }, + { label: 'Demonic Cultivation Technique', value: '190' }, + { label: 'Demons', value: '191' }, + { label: 'Dense Protagonist', value: '192' }, + { label: 'Depictions of Cruelty', value: '193' }, + { label: 'Depression', value: '194' }, + { label: 'Destiny', value: '195' }, + { label: 'Detective Conan', value: '804' }, + { label: 'Detectives', value: '196' }, + { label: 'Determined Protagonist', value: '197' }, + { label: 'Devoted Love Interests', value: '198' }, + { label: 'Devouring', value: '797' }, + { label: 'Different Social Status', value: '199' }, + { label: 'Disabilities', value: '200' }, + { label: 'Discrimination', value: '201' }, + { label: 'Disfigurement', value: '202' }, + { label: 'Dishonest Protagonist', value: '203' }, + { label: 'Distrustful Protagonist', value: '204' }, + { label: 'Divination', value: '205' }, + { label: 'Divine Protection', value: '206' }, + { label: 'Divorce', value: '207' }, + { label: 'DnD', value: '794' }, + { label: 'Doctors', value: '208' }, + { label: 'Dolls/Puppets', value: '209' }, + { label: 'Domestic Affairs', value: '210' }, + { label: 'Doting Love Interests', value: '211' }, + { label: 'Doting Older Siblings', value: '212' }, + { label: 'Doting Parents', value: '213' }, + { label: 'Douluo Dalu', value: '772' }, + { label: 'Dragon Ball', value: '773' }, + { label: 'Dragon Riders', value: '214' }, + { label: 'Dragon Slayers', value: '215' }, + { label: 'Dragons', value: '216' }, + { label: 'Dreams', value: '217' }, + { label: 'Drugs', value: '218' }, + { label: 'Druids', value: '219' }, + { label: 'Dungeon Master', value: '220' }, + { label: 'Dungeons', value: '221' }, + { label: 'Dwarfs', value: '222' }, + { label: 'Dystopia', value: '223' }, + { label: 'e-Sports', value: '224' }, + { label: 'Early Romance', value: '225' }, + { label: 'Earth Invasion', value: '226' }, + { label: 'Easy Going Life', value: '227' }, + { label: 'Eavesdropping', value: '798' }, + { label: 'Economics', value: '228' }, + { label: 'Editors', value: '229' }, + { label: 'Eidetic Memory', value: '230' }, + { label: 'Elderly Protagonist', value: '231' }, + { label: 'Elemental Magic', value: '232' }, + { label: 'Elves', value: '233' }, + { label: 'Emotionally Weak Protagonist', value: '234' }, + { label: 'Empires', value: '235' }, + { label: 'Enemies Become Allies', value: '236' }, + { label: 'Enemies Become Lovers', value: '237' }, + { label: 'Engagement', value: '238' }, + { label: 'Engineer', value: '239' }, + { label: 'Enlightenment', value: '240' }, + { label: 'Episodic', value: '241' }, + { label: 'Eunuch', value: '242' }, + { label: 'European Ambience', value: '243' }, + { label: 'Evil Gods', value: '244' }, + { label: 'Evil Organizations', value: '245' }, + { label: 'Evil Protagonist', value: '246' }, + { label: 'Evil Religions', value: '247' }, + { label: 'Evolution', value: '248' }, + { label: 'Exhibitionism', value: '249' }, + { label: 'Exorcism', value: '250' }, + { label: 'Eye Powers', value: '251' }, + { label: 'Fairies', value: '252' }, + { label: 'Fairy Tail', value: '814' }, + { label: 'Faith Dependent Deities', value: '808' }, + { label: 'Fallen Angels', value: '253' }, + { label: 'Fallen Nobility', value: '254' }, + { label: 'Familial Love', value: '255' }, + { label: 'Familiars', value: '256' }, + { label: 'Family', value: '257' }, + { label: 'Family Business', value: '258' }, + { label: 'Family Conflict', value: '259' }, + { label: 'Famous Parents', value: '260' }, + { label: 'Famous Protagonist', value: '261' }, + { label: 'Fanaticism', value: '262' }, + { label: 'Fanfiction', value: '263' }, + { label: 'Fantasy Creatures', value: '264' }, + { label: 'Fantasy World', value: '265' }, + { label: 'Farming', value: '266' }, + { label: 'Fast Cultivation', value: '267' }, + { label: 'Fast Learner', value: '268' }, + { label: 'Fat Protagonist', value: '269' }, + { label: 'Fat to Fit', value: '270' }, + { label: 'Fated Lovers', value: '271' }, + { label: 'Fearless Protagonist', value: '272' }, + { label: 'Fellatio', value: '273' }, + { label: 'Female Master', value: '274' }, + { label: 'Female Protagonist', value: '275' }, + { label: 'Female to Male', value: '276' }, + { label: 'Feng Shui', value: '277' }, + { label: 'Firearms', value: '278' }, + { label: 'First Love', value: '279' }, + { label: 'First-time Intercourse', value: '280' }, + { label: 'Flashbacks', value: '281' }, + { label: 'Fleet Battles', value: '282' }, + { label: 'Folklore', value: '283' }, + { label: 'Football', value: '780' }, + { label: 'Forced into a Relationship', value: '284' }, + { label: 'Forced Living Arrangements', value: '285' }, + { label: 'Forced Marriage', value: '286' }, + { label: 'Forgetful Protagonist', value: '287' }, + { label: 'Former Hero', value: '288' }, + { label: 'Fox Spirits', value: '289' }, + { label: 'Friends Become Enemies', value: '290' }, + { label: 'Friendship', value: '291' }, + { label: 'Frieren', value: '816' }, + { label: 'Fujoshi', value: '292' }, + { label: 'Futanari', value: '293' }, + { label: 'Futuristic Setting', value: '294' }, + { label: 'Galge', value: '295' }, + { label: 'Gambling', value: '296' }, + { label: 'Game Creator', value: '784' }, + { label: 'Game Elements', value: '297' }, + { label: 'Game of Thrones', value: '813' }, + { label: 'Game Ranking System', value: '298' }, + { label: 'Gamers', value: '299' }, + { label: 'Gangs', value: '300' }, + { label: 'Gao Wu', value: '781' }, + { label: 'Gate to Another World', value: '301' }, + { label: 'Genderless Protagonist', value: '302' }, + { label: 'Generals', value: '303' }, + { label: 'Genetic Modifications', value: '304' }, + { label: 'Genies', value: '305' }, + { label: 'Genius Protagonist', value: '306' }, + { label: 'Genshin Impact', value: '815' }, + { label: 'Ghosts', value: '307' }, + { label: 'Gladiators', value: '308' }, + { label: 'Glasses-wearing Love Interests', value: '309' }, + { label: 'Glasses-wearing Protagonist', value: '310' }, + { label: 'Goblins', value: '311' }, + { label: 'God Protagonist', value: '312' }, + { label: 'God-human Relationship', value: '313' }, + { label: 'Goddesses', value: '314' }, + { label: 'Godly Powers', value: '315' }, + { label: 'Gods', value: '316' }, + { label: 'Golems', value: '317' }, + { label: 'Gore', value: '318' }, + { label: 'Grave Keepers', value: '319' }, + { label: 'Grinding', value: '320' }, + { label: 'Guardian Relationship', value: '321' }, + { label: 'Guilds', value: '322' }, + { label: 'Gunfighters', value: '323' }, + { label: 'Hackers', value: '324' }, + { label: 'Half-human Protagonist', value: '325' }, + { label: 'Handjob', value: '326' }, + { label: 'Handsome Male Lead', value: '327' }, + { label: 'Hard-Working Protagonist', value: '328' }, + { label: 'Harem-seeking Protagonist', value: '329' }, + { label: 'Harry Potter', value: '768' }, + { label: 'Harsh Training', value: '330' }, + { label: 'Hated Protagonist', value: '331' }, + { label: 'Healers', value: '332' }, + { label: 'Heartwarming', value: '333' }, + { label: 'Heaven', value: '334' }, + { label: 'Heavenly Defying Comprehension', value: '803' }, + { label: 'Heavenly Tribulation', value: '335' }, + { label: 'Hell', value: '336' }, + { label: 'Helpful Protagonist', value: '337' }, + { label: 'Herbalist', value: '338' }, + { label: 'Heroes', value: '339' }, + { label: 'Heterochromia', value: '340' }, + { label: 'Hidden Abilities', value: '341' }, + { label: 'Hiding True Abilities', value: '342' }, + { label: 'Hiding True Identity', value: '343' }, + { label: 'Hikikomori', value: '344' }, + { label: 'Hollywood', value: '779' }, + { label: 'Homunculus', value: '345' }, + { label: 'Honest Protagonist', value: '346' }, + { label: 'Hong Kong', value: '821' }, + { label: 'Honghuang', value: '801' }, + { label: 'Honkai', value: '818' }, + { label: 'Hospital', value: '347' }, + { label: 'Hot-blooded Protagonist', value: '348' }, + { label: 'Human Experimentation', value: '349' }, + { label: 'Human Weapon', value: '350' }, + { label: 'Human-Nonhuman Relationship', value: '351' }, + { label: 'Humanoid Protagonist', value: '352' }, + { label: 'Hunter x Hunter', value: '777' }, + { label: 'Hunters', value: '353' }, + { label: 'Hypnotism', value: '354' }, + { label: 'Identity Crisis', value: '355' }, + { label: 'Imaginary Friend', value: '356' }, + { label: 'Immortals', value: '357' }, + { label: 'Imperial Harem', value: '358' }, + { label: 'Incest', value: '359' }, + { label: 'Incubus', value: '360' }, + { label: 'Indecisive Protagonist', value: '361' }, + { label: 'Industrialization', value: '362' }, + { label: 'Inferiority Complex', value: '363' }, + { label: 'Inheritance', value: '364' }, + { label: 'Inscriptions', value: '365' }, + { label: 'Insects', value: '366' }, + { label: 'Interconnected Storylines', value: '367' }, + { label: 'Interdimensional Travel', value: '368' }, + { label: 'Introverted Protagonist', value: '369' }, + { label: 'Investigations', value: '370' }, + { label: 'Invisibility', value: '371' }, + { label: 'Jack of All Trades', value: '372' }, + { label: 'Jealousy', value: '373' }, + { label: 'Jiangshi', value: '374' }, + { label: 'Jobless Class', value: '375' }, + { label: 'Journey to the West', value: '796' }, + { label: 'JSDF', value: '376' }, + { label: 'Jujutsu Kaisen', value: '776' }, + { label: 'Kidnappings', value: '377' }, + { label: 'Kimetsu no Yaiba', value: '805' }, + { label: 'Kind Love Interests', value: '378' }, + { label: 'Kingdom Building', value: '379' }, + { label: 'Kingdoms', value: '380' }, + { label: 'Knights', value: '381' }, + { label: 'Kuudere', value: '382' }, + { label: 'Lack of Common Sense', value: '383' }, + { label: 'Language Barrier', value: '384' }, + { label: 'Late Romance', value: '385' }, + { label: 'Lawyers', value: '386' }, + { label: 'Lazy Protagonist', value: '387' }, + { label: 'Leadership', value: '388' }, + { label: 'League of Legends', value: '791' }, + { label: 'Legends', value: '389' }, + { label: 'Level System', value: '390' }, + { label: 'Library', value: '391' }, + { label: 'Life Script', value: '824' }, + { label: 'Limited Lifespan', value: '392' }, + { label: 'Live Streaming', value: '782' }, + { label: 'Living Abroad', value: '393' }, + { label: 'Living Alone', value: '394' }, + { label: 'Loli', value: '395' }, + { label: 'Loneliness', value: '396' }, + { label: 'Loner Protagonist', value: '397' }, + { label: 'Long Separations', value: '398' }, + { label: 'Long-distance Relationship', value: '399' }, + { label: 'Lord', value: '823' }, + { label: 'Lord of the Mysteries', value: '799' }, + { label: 'Lost Civilizations', value: '400' }, + { label: 'Lottery', value: '401' }, + { label: 'Love at First Sight', value: '402' }, + { label: 'Love Interest Falls in Love First', value: '403' }, + { label: 'Love Rivals', value: '404' }, + { label: 'Love Triangles', value: '405' }, + { label: 'Lovers Reunited', value: '406' }, + { label: 'Low-key Protagonist', value: '407' }, + { label: 'Loyal Subordinates', value: '408' }, + { label: 'Lucky Protagonist', value: '409' }, + { label: 'Magic', value: '410' }, + { label: 'Magic Beasts', value: '411' }, + { label: 'Magic Formations', value: '412' }, + { label: 'Magical Girls', value: '413' }, + { label: 'Magical Space', value: '414' }, + { label: 'Magical Technology', value: '415' }, + { label: 'Maids', value: '416' }, + { label: 'Male Protagonist', value: '417' }, + { label: 'Male to Female', value: '418' }, + { label: 'Male Yandere', value: '419' }, + { label: 'Management', value: '420' }, + { label: 'Mangaka', value: '421' }, + { label: 'Manipulative Characters', value: '422' }, + { label: 'Manly Gay Couple', value: '423' }, + { label: 'Marriage', value: '424' }, + { label: 'Marriage of Convenience', value: '425' }, + { label: 'Martial Spirits', value: '426' }, + { label: 'Marvel', value: '766' }, + { label: 'Masochistic Characters', value: '427' }, + { label: 'Master-Disciple Relationship', value: '428' }, + { label: 'Master-Servant Relationship', value: '429' }, + { label: 'Masturbation', value: '430' }, + { label: 'Matriarchy', value: '431' }, + { label: 'Mature Protagonist', value: '432' }, + { label: 'Medical Knowledge', value: '433' }, + { label: 'Medieval', value: '434' }, + { label: 'Mercenaries', value: '435' }, + { label: 'Merchants', value: '436' }, + { label: 'Military', value: '437' }, + { label: 'Mind Break', value: '438' }, + { label: 'Mind Control', value: '439' }, + { label: 'Minecraft', value: '790' }, + { label: 'Misandry', value: '440' }, + { label: 'Mismatched Couple', value: '441' }, + { label: 'Misunderstandings', value: '442' }, + { label: 'MMORPG', value: '443' }, + { label: 'Mob Protagonist', value: '444' }, + { label: 'Models', value: '445' }, + { label: 'Modern Day', value: '446' }, + { label: 'Modern Knowledge', value: '447' }, + { label: 'Money Grubber', value: '448' }, + { label: 'Monster Girls', value: '449' }, + { label: 'Monster Society', value: '450' }, + { label: 'Monster Tamer', value: '451' }, + { label: 'Monsters', value: '452' }, + { label: 'More Children More Blessings', value: '825' }, + { label: 'Mortal Flow', value: '792' }, + { label: 'Movies', value: '453' }, + { label: 'Mpreg', value: '454' }, + { label: 'Multiple Identities', value: '455' }, + { label: 'Multiple Personalities', value: '456' }, + { label: 'Multiple POV', value: '457' }, + { label: 'Multiple Protagonists', value: '458' }, + { label: 'Multiple Realms', value: '459' }, + { label: 'Multiple Reincarnated Individuals', value: '460' }, + { label: 'Multiple Timelines', value: '461' }, + { label: 'Multiple Transported Individuals', value: '462' }, + { label: 'Murders', value: '463' }, + { label: 'Music', value: '464' }, + { label: 'Mutated Creatures', value: '465' }, + { label: 'Mutations', value: '466' }, + { label: 'Mute Character', value: '467' }, + { label: 'Mysterious Family Background', value: '468' }, + { label: 'Mysterious Illness', value: '469' }, + { label: 'Mysterious Past', value: '470' }, + { label: 'Mystery Solving', value: '471' }, + { label: 'Mythical Beasts', value: '472' }, + { label: 'Mythology', value: '473' }, + { label: 'Naive Protagonist', value: '474' }, + { label: 'Narcissistic Protagonist', value: '475' }, + { label: 'Naruto', value: '769' }, + { label: 'Nationalism', value: '476' }, + { label: 'Near-Death Experience', value: '477' }, + { label: 'Necromancer', value: '478' }, + { label: 'Neet', value: '479' }, + { label: 'Netorare', value: '480' }, + { label: 'Netorase', value: '481' }, + { label: 'Netori', value: '482' }, + { label: 'Nightmares', value: '483' }, + { label: 'Ninjas', value: '484' }, + { label: 'Nobles', value: '485' }, + { label: 'Non-humanoid Protagonist', value: '486' }, + { label: 'Non-linear Storytelling', value: '487' }, + { label: 'Nudity', value: '488' }, + { label: 'Nurses', value: '489' }, + { label: 'Obsessive Love', value: '490' }, + { label: 'Office Romance', value: '491' }, + { label: 'Older Love Interests', value: '492' }, + { label: 'Omegaverse', value: '493' }, + { label: 'One Piece', value: '767' }, + { label: 'Oneshot', value: '494' }, + { label: 'Online Romance', value: '495' }, + { label: 'Onmyouji', value: '496' }, + { label: 'Orcs', value: '497' }, + { label: 'Organized Crime', value: '498' }, + { label: 'Orgy', value: '499' }, + { label: 'Orphans', value: '500' }, + { label: 'Otaku', value: '501' }, + { label: 'Otome Game', value: '502' }, + { label: 'Outcasts', value: '503' }, + { label: 'Outdoor Intercourse', value: '504' }, + { label: 'Outer Space', value: '505' }, + { label: 'Overlord', value: '826' }, + { label: 'Overpowered Protagonist', value: '506' }, + { label: 'Overprotective Siblings', value: '507' }, + { label: 'Pacifist Protagonist', value: '508' }, + { label: 'Paizuri', value: '509' }, + { label: 'Parallel Worlds', value: '510' }, + { label: 'Parasites', value: '511' }, + { label: 'Parent Complex', value: '512' }, + { label: 'Parody', value: '513' }, + { label: 'Part-Time Job', value: '514' }, + { label: 'Past Plays a Big Role', value: '515' }, + { label: 'Past Trauma', value: '516' }, + { label: 'Persistent Love Interests', value: '517' }, + { label: 'Personality Changes', value: '518' }, + { label: 'Perverted Protagonist', value: '519' }, + { label: 'Pets', value: '520' }, + { label: 'Pharmacist', value: '521' }, + { label: 'Philosophical', value: '522' }, + { label: 'Phobias', value: '523' }, + { label: 'Phoenixes', value: '524' }, + { label: 'Photography', value: '525' }, + { label: 'Pill Based Cultivation', value: '526' }, + { label: 'Pill Concocting', value: '527' }, + { label: 'Pilots', value: '528' }, + { label: 'Pirates', value: '529' }, + { label: 'Playboys', value: '530' }, + { label: 'Playful Protagonist', value: '531' }, + { label: 'Poetry', value: '532' }, + { label: 'Poisons', value: '533' }, + { label: 'Pokemon', value: '771' }, + { label: 'Police', value: '534' }, + { label: 'Polite Protagonist', value: '535' }, + { label: 'Politics', value: '536' }, + { label: 'Polyandry', value: '537' }, + { label: 'Polygamy', value: '538' }, + { label: 'Poor Protagonist', value: '539' }, + { label: 'Poor to Rich', value: '540' }, + { label: 'Popular Love Interests', value: '541' }, + { label: 'Possession', value: '542' }, + { label: 'Possessive Characters', value: '543' }, + { label: 'Post-apocalyptic', value: '544' }, + { label: 'Power Couple', value: '545' }, + { label: 'Power Struggle', value: '546' }, + { label: 'Pragmatic Protagonist', value: '547' }, + { label: 'Precognition', value: '548' }, + { label: 'Pregnancy', value: '549' }, + { label: 'Pretend Lovers', value: '550' }, + { label: 'Previous Life Talent', value: '551' }, + { label: 'Priestesses', value: '552' }, + { label: 'Priests', value: '553' }, + { label: 'Prison', value: '554' }, + { label: 'Proactive Protagonist', value: '555' }, + { label: 'Proficiency', value: '793' }, + { label: 'Programmer', value: '556' }, + { label: 'Prophecies', value: '557' }, + { label: 'Prostitutes', value: '558' }, + { label: 'Protagonist Falls in Love First', value: '559' }, + { label: 'Protagonist Strong from the Start', value: '560' }, + { label: 'Protagonist with Multiple Bodies', value: '561' }, + { label: 'Psychic Powers', value: '562' }, + { label: 'Psychopaths', value: '563' }, + { label: 'Puppeteers', value: '564' }, + { label: 'Quiet Characters', value: '565' }, + { label: 'Quirky Characters', value: '566' }, + { label: 'R-15', value: '567' }, + { label: 'R-18', value: '568' }, + { label: 'Race Change', value: '569' }, + { label: 'Racism', value: '570' }, + { label: 'Rape', value: '571' }, + { label: 'Rape Victim Becomes Lover', value: '572' }, + { label: 'Reality-Game Fusion', value: '830' }, + { label: 'Rebellion', value: '573' }, + { label: 'Reborn', value: '829' }, + { label: 'Reborn as the Villain', value: '831' }, + { label: 'Reincarnated as a Monster', value: '574' }, + { label: 'Reincarnated as an Object', value: '575' }, + { label: 'Reincarnated in a Game World', value: '576' }, + { label: 'Reincarnated in Another World', value: '577' }, + { label: 'Reincarnation', value: '578' }, + { label: 'Religions', value: '579' }, + { label: 'Reluctant Protagonist', value: '580' }, + { label: 'Reporters', value: '581' }, + { label: 'Restaurant', value: '582' }, + { label: 'Resurrection', value: '583' }, + { label: 'Returning from Another World', value: '584' }, + { label: 'Revenge', value: '585' }, + { label: 'Reverse Harem', value: '586' }, + { label: 'Reverse Rape', value: '587' }, + { label: 'Reversible Couple', value: '588' }, + { label: 'Rich to Poor', value: '589' }, + { label: 'Righteous Protagonist', value: '590' }, + { label: 'Rivalry', value: '591' }, + { label: 'Romantic Subplot', value: '592' }, + { label: 'Roommates', value: '593' }, + { label: 'Royalty', value: '594' }, + { label: 'Ruthless Protagonist', value: '595' }, + { label: 'Sadistic Characters', value: '596' }, + { label: 'Saints', value: '597' }, + { label: 'Salaryman', value: '598' }, + { label: 'Samurai', value: '599' }, + { label: 'Saving the World', value: '600' }, + { label: 'Schemes And Conspiracies', value: '601' }, + { label: 'Schizophrenia', value: '602' }, + { label: 'Scientists', value: '603' }, + { label: 'Sculptors', value: '604' }, + { label: 'Sealed Power', value: '605' }, + { label: 'Second Chance', value: '606' }, + { label: 'Secret Crush', value: '607' }, + { label: 'Secret Identity', value: '608' }, + { label: 'Secret Organizations', value: '609' }, + { label: 'Secret Relationship', value: '610' }, + { label: 'Secretive Protagonist', value: '611' }, + { label: 'Secrets', value: '612' }, + { label: 'Sect Development', value: '613' }, + { label: 'Seduction', value: '614' }, + { label: "Seeing Things Other Humans Can't", value: '615' }, + { label: 'Selfish Protagonist', value: '616' }, + { label: 'Selfless Protagonist', value: '617' }, + { label: 'Seme Protagonist', value: '618' }, + { label: 'Senpai-Kouhai Relationship', value: '619' }, + { label: 'Sentient Objects', value: '620' }, + { label: 'Sentimental Protagonist', value: '621' }, + { label: 'Serial Killers', value: '622' }, + { label: 'Servants', value: '623' }, + { label: 'Seven Deadly Sins', value: '624' }, + { label: 'Seven Virtues', value: '625' }, + { label: 'Sex Friends', value: '626' }, + { label: 'Sex Slaves', value: '627' }, + { label: 'Sexual Abuse', value: '628' }, + { label: 'Sexual Cultivation Technique', value: '629' }, + { label: 'Shameless Protagonist', value: '630' }, + { label: 'Shapeshifters', value: '631' }, + { label: 'Sharing A Body', value: '632' }, + { label: 'Sharp-tongued Characters', value: '633' }, + { label: 'Shield User', value: '634' }, + { label: 'Shikigami', value: '635' }, + { label: 'Short Story', value: '636' }, + { label: 'Shota', value: '637' }, + { label: 'Shoujo-Ai Subplot', value: '638' }, + { label: 'Shounen-Ai Subplot', value: '639' }, + { label: 'Showbiz', value: '640' }, + { label: 'Shy Characters', value: '641' }, + { label: 'Sibling Rivalry', value: '642' }, + { label: "Sibling's Care", value: '643' }, + { label: 'Siblings', value: '644' }, + { label: 'Siblings Not Related by Blood', value: '645' }, + { label: 'Sickly Characters', value: '646' }, + { label: 'Sign In', value: '811' }, + { label: 'Sign Language', value: '647' }, + { label: 'Siheyuan', value: '820' }, + { label: 'Simulator', value: '786' }, + { label: 'Singers', value: '648' }, + { label: 'Single Female Lead', value: '787' }, + { label: 'Single Parent', value: '649' }, + { label: 'Sister Complex', value: '650' }, + { label: 'Skill Assimilation', value: '651' }, + { label: 'Skill Books', value: '652' }, + { label: 'Skill Creation', value: '653' }, + { label: 'Slave Harem', value: '654' }, + { label: 'Slave Protagonist', value: '655' }, + { label: 'Slaves', value: '656' }, + { label: 'Sleeping', value: '657' }, + { label: 'Slow Growth at Start', value: '658' }, + { label: 'Slow Romance', value: '659' }, + { label: 'Smart Couple', value: '660' }, + { label: 'Social Outcasts', value: '661' }, + { label: 'Soldiers', value: '662' }, + { label: 'Soul Power', value: '663' }, + { label: 'Souls', value: '664' }, + { label: 'Spatial Manipulation', value: '665' }, + { label: 'Spear Wielder', value: '666' }, + { label: 'Special Abilities', value: '667' }, + { label: 'Spies', value: '668' }, + { label: 'Spirit Advisor', value: '669' }, + { label: 'Spirit Users', value: '670' }, + { label: 'Spirits', value: '671' }, + { label: 'Spiritual Energy Revival', value: '828' }, + { label: 'Stalkers', value: '672' }, + { label: 'Star Wars', value: '817' }, + { label: 'Stockholm Syndrome', value: '673' }, + { label: 'Stoic Characters', value: '674' }, + { label: 'Store Owner', value: '675' }, + { label: 'Straight Seme', value: '676' }, + { label: 'Straight Uke', value: '677' }, + { label: 'Strategic Battles', value: '678' }, + { label: 'Strategist', value: '679' }, + { label: 'Strength-based Social Hierarchy', value: '680' }, + { label: 'Strong Love Interests', value: '681' }, + { label: 'Strong to Stronger', value: '682' }, + { label: 'Stubborn Protagonist', value: '683' }, + { label: 'Student Council', value: '684' }, + { label: 'Student-Teacher Relationship', value: '685' }, + { label: 'Succubus', value: '686' }, + { label: 'Sudden Strength Gain', value: '687' }, + { label: 'Sudden Wealth', value: '688' }, + { label: 'Suicides', value: '689' }, + { label: 'Summoned Hero', value: '690' }, + { label: 'Summoning Magic', value: '691' }, + { label: 'Survival', value: '692' }, + { label: 'Survival Game', value: '693' }, + { label: 'Swallowed Star', value: '785' }, + { label: 'Sword And Magic', value: '694' }, + { label: 'Sword Wielder', value: '695' }, + { label: 'System', value: '696' }, + { label: 'Teachers', value: '697' }, + { label: 'Teamwork', value: '698' }, + { label: 'Technological Gap', value: '699' }, + { label: 'Tentacles', value: '700' }, + { label: 'Terminal Illness', value: '701' }, + { label: 'Territory Management', value: '802' }, + { label: 'Terrorists', value: '702' }, + { label: 'Thieves', value: '703' }, + { label: 'Three Kingdoms', value: '795' }, + { label: 'Threesome', value: '704' }, + { label: 'Thriller', value: '705' }, + { label: 'Time Loop', value: '706' }, + { label: 'Time Manipulation', value: '707' }, + { label: 'Time Paradox', value: '708' }, + { label: 'Time Skip', value: '709' }, + { label: 'Time Travel', value: '710' }, + { label: 'Timid Protagonist', value: '711' }, + { label: 'Tomboyish Female Lead', value: '712' }, + { label: 'Torture', value: '713' }, + { label: 'Toys', value: '714' }, + { label: 'Tragic Past', value: '715' }, + { label: 'Transformation Ability', value: '716' }, + { label: 'Transmigration', value: '717' }, + { label: 'Transplanted Memories', value: '718' }, + { label: 'Transported into a Game World', value: '719' }, + { label: 'Transported Modern Structure', value: '720' }, + { label: 'Transported to Another World', value: '721' }, + { label: 'Trap', value: '722' }, + { label: 'Tribal Society', value: '723' }, + { label: 'Trickster', value: '724' }, + { label: 'Tsundere', value: '725' }, + { label: 'Twins', value: '726' }, + { label: 'Twisted Personality', value: '727' }, + { label: 'Ugly Protagonist', value: '728' }, + { label: 'Ugly to Beautiful', value: '729' }, + { label: 'Unconditional Love', value: '730' }, + { label: 'Undead Protagonist', value: '810' }, + { label: 'Underestimated Protagonist', value: '731' }, + { label: 'Unique Cultivation Technique', value: '732' }, + { label: 'Unique Weapon User', value: '733' }, + { label: 'Unique Weapons', value: '734' }, + { label: 'Unlimited Flow', value: '735' }, + { label: 'Unlucky Protagonist', value: '736' }, + { label: 'Unreliable Narrator', value: '737' }, + { label: 'Unrequited Love', value: '738' }, + { label: 'Valkyries', value: '739' }, + { label: 'Vampires', value: '740' }, + { label: 'Villainess Noble Girls', value: '741' }, + { label: 'Virtual Reality', value: '742' }, + { label: 'Vocaloid', value: '743' }, + { label: 'Voice Actors', value: '744' }, + { label: 'Voyeurism', value: '745' }, + { label: 'Waiters', value: '746' }, + { label: 'War Records', value: '747' }, + { label: 'Warhammer', value: '775' }, + { label: 'Wars', value: '748' }, + { label: 'Weak Protagonist', value: '749' }, + { label: 'Weak to Strong', value: '750' }, + { label: 'Wealthy Characters', value: '751' }, + { label: 'Werebeasts', value: '752' }, + { label: 'Western Names', value: '788' }, + { label: 'Wishes', value: '753' }, + { label: 'Witcher', value: '819' }, + { label: 'Witches', value: '754' }, + { label: 'Wizards', value: '755' }, + { label: 'World Hopping', value: '756' }, + { label: 'World Travel', value: '757' }, + { label: 'World Tree', value: '758' }, + { label: 'Writers', value: '759' }, + { label: 'Yandere', value: '760' }, + { label: 'Youkai', value: '761' }, + { label: 'Younger Brothers', value: '762' }, + { label: 'Younger Love Interests', value: '763' }, + { label: 'Younger Sisters', value: '764' }, + { label: 'Yu-Gi-Oh!', value: '774' }, + { label: 'Zombies', value: '765' }, + ], + }, + + folders: { + value: '', + label: 'Library Folders', + options: [ + { label: 'No Filter', value: '' }, + { label: 'Reading', value: '1' }, + { label: 'Read Later', value: '2' }, + { label: 'Completed', value: '3' }, + { label: 'Trash', value: '5' }, + ], + type: FilterTypes.Picker, + }, + library_exclude: { + value: '', + label: 'Library Exclude', + options: [ + { label: 'None', value: '' }, + { label: 'Exclude All', value: 'history' }, + { label: 'Exclude Trash', value: 'trash' }, + { label: 'Exclude Library & Trash', value: 'in_library' }, + ], + type: FilterTypes.Picker, + }, + } satisfies Filters; +} + +type NovelJson = { + props: Props; + page: string; + query?: { raw_id: number }; +}; + +type Props = { + pageProps: PageProps; + __N_SSP: boolean; +}; + +type PageProps = { + serie: Serie; + server_time: Date; +}; + +type Serie = { + serie_data: SerieData; + chapter: Chapter; + recommendation: SerieData[]; + chapter_data: ChapterData; + id: number; + raw_id: number; + slug: string; + data: Data; + is_default: boolean; + raw_type: string; +}; + +type Chapter = { + serie_id: number; + id: number; + raw_id: number; + order: number; + slug: string; + title: string; + name: string; + created_at: string; + updated_at: string; +}; +type ApiChapter = { + serie_id: number; + id: number; + order: number; + title: string; + name: string; + updated_at: string; +}; + +// type GlossaryTerm = { +// index: number; +// english: string; +// chinese: string; +// symbol: string; +// }; +type ChapterData = { + data: ChapterContent; +}; +type ChapterContent = { + title: string; + body: string; + glossary_data?: { + terms: string[][]; + }; +}; + +type SerieData = { + serie_id?: number; + recommendation_id?: number; + score?: string; + id: number; + slug: string; + search_text: string; + status: number; + data: Data; + created_at: string; + updated_at: string; + view: number; + in_library: number; + rating: number | null; + chapter_count: number; + power: number; + total_rate: number; + user_status: number; + verified: boolean; + from: null; + raw_id: number; + genres?: number[]; +}; + +type Data = { + title: string; + author: string; + description: string; + image: string; +}; + +type JsonNovel = { + success: boolean; + data: Datum[]; +}; +type Datum = { + serie: Serie; + chapters: Chapter[]; + updated_at: Date; + raw_id: number; + slug: string; + data: Data; +}; + +export default new WTRLAB(); From 5b4c6507bdf1acc1e1b5937e636111444158a8d7 Mon Sep 17 00:00:00 2001 From: ShakeyHands91 <143759757+ShakeyHands91@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:20:54 +0100 Subject: [PATCH 2/5] Delete wtrlab.ts --- wtrlab.ts | 2022 ----------------------------------------------------- 1 file changed, 2022 deletions(-) delete mode 100644 wtrlab.ts diff --git a/wtrlab.ts b/wtrlab.ts deleted file mode 100644 index 07ebb583f..000000000 --- a/wtrlab.ts +++ /dev/null @@ -1,2022 +0,0 @@ -import { Plugin } from '@/types/plugin'; -import { fetchApi } from '@libs/fetch'; -import { FilterTypes, Filters } from '@libs/filterInputs'; -import { CheerioAPI, load as parseHTML } from 'cheerio'; -import { gcm } from '@libs/aes'; -import { storage } from '@libs/storage'; - -class WTRLAB implements Plugin.PluginBase { - id = 'WTRLAB'; - name = 'WTR-LAB'; - site = 'https://wtr-lab.com/'; - version = '1.2.2'; - icon = 'src/en/wtrlab/icon.png'; - sourceLang = 'en/'; - baggage = ''; - trace = ''; - - pluginSettings = { - signInUrl: { - value: '', - label: - 'Sign-in link — request "Continue with Email" on wtr-lab, then paste the full link from that email here (the plugin reads the token out of it and redeems it). Clear this field once AI chapters load; links are single-use and short-lived.', - type: 'Text', - }, - sessionCookie: { - value: '', - label: - 'Session cookie (fallback) — usually leave EMPTY. Android replaces this header with its own stored cookies whenever it has any, so the sign-in link above is the reliable route.', - type: 'Text', - }, - preferredMode: { - value: 'ai', - label: 'Preferred translation', - type: 'Select', - options: [ - { label: 'AI', value: 'ai' }, - { label: 'Web+', value: 'webplus' }, - { label: 'Web', value: 'web' }, - { label: 'Custom — set the id below', value: 'custom' }, - ], - }, - customMode: { - value: '', - label: - 'Custom translation id — only used when "Custom" is selected above. This is the value wtr-lab sends as "translate" in its /api/reader/get request.', - type: 'Text', - }, - fallbackToWeb: { - value: true, - label: 'Fall back to Web when the preferred translation is unavailable', - type: 'Switch', - }, - showModeNotice: { - value: true, - label: 'Show which translation was used at the top of each chapter', - type: 'Switch', - }, - }; - - /** Full Cookie header value supplied by the user in plugin settings. */ - get sessionCookie(): string { - return (storage.get('sessionCookie') || '').trim(); - } - - /** Only attempt the sign-in link once per app run: the links are single-use. */ - signInAttempted = false; - - /** - * Visits the user's emailed sign-in link once. Any Set-Cookie it returns is - * stored by Android's shared cookie jar, which every later request uses - * automatically — unlike a Cookie header, which the jar overrides. - * - * @returns a short status line to show the user, or null if nothing was tried. - */ - async ensureSignedIn(): Promise { - const raw = (storage.get('signInUrl') || '').trim(); - if (!raw || this.signInAttempted) return null; - this.signInAttempted = true; - - // A full URL must be wtr-lab's own; a bare token is accepted too. - if ( - /^[a-z]+:\/\//i.test(raw) && - !/^https:\/\/([a-z0-9-]+\.)*wtr-lab\.com\//i.test(raw) - ) { - return 'Sign-in link ignored — it is not an https wtr-lab.com address.'; - } - - // The emailed link points at a PAGE whose JavaScript redeems the token. - // Nothing runs that script here, so pull the token out and call the - // redeem endpoint directly. - let token = ''; - const fromQuery = raw.match(/[?&]token=([^&\s]+)/); - if (fromQuery) { - token = decodeURIComponent(fromQuery[1]); - } else if (/^[A-Za-z0-9_.-]{16,}$/.test(raw)) { - token = raw; - } - - if (!token) { - return 'Sign-in link ignored — no token found. Paste the whole link from the email (it contains "token=").'; - } - - const verifyUrl = `${this.site}api/auth/magic-link/verify?token=${encodeURIComponent( - token, - )}&callbackURL=/`; - - try { - const res = await fetchApi(verifyUrl, { - headers: { - 'Accept': 'application/json,text/html,*/*', - 'Referer': this.site, - }, - }); - const landedOn = (res.url || '').replace(this.site, '/') || 'unknown'; - return `Sign-in: redeem token HTTP ${res.status}, ended at ${landedOn}`; - } catch (e) { - return `Sign-in failed: ${String(e)}`; - } - } - - /** - * Asks wtr-lab who it thinks we are. This is the ground truth for whether a - * session actually survived into the plugin's requests. - */ - async checkSession(): Promise { - try { - const cookie = this.sessionCookie; - const res = await fetchApi(`${this.site}api/auth/get-session`, { - headers: { - 'Accept': 'application/json', - ...(cookie ? { Cookie: cookie } : {}), - }, - }); - const text = await res.text(); - let body = null; - try { - body = JSON.parse(text); - } catch (e) { - body = null; - } - // wtr-lab nests this differently in places, so check the likely shapes. - const user = - body?.user || - body?.session?.user || - body?.data?.user || - body?.session?.session?.user; - if (typeof user === 'string' && user) { - return `signed in (${user})`; - } - if (user && typeof user === 'object') { - const label = - user.user_name || - user.name || - user.username || - user.email || - user.id || - ''; - return label ? `signed in as ${label}` : 'signed in'; - } - return `NOT signed in (get-session HTTP ${res.status}: ${ - text - .slice(0, 60) - .replace(/<[^>]*>/g, ' ') - .trim() || 'empty response' - })`; - } catch (e) { - return `session check failed: ${String(e)}`; - } - } - - /** Translation modes to try, in order, based on plugin settings. */ - get translationModes(): string[] { - const preferred = (storage.get('preferredMode') || 'ai').trim(); - const custom = (storage.get('customMode') || '').trim(); - const fallback = storage.get('fallbackToWeb'); - - const modes: string[] = []; - if (preferred === 'custom') { - if (custom) modes.push(custom); - } else if (preferred) { - modes.push(preferred); - } - - // `false` means the user turned it off; `undefined` means never set, so default to on. - if (fallback !== false && !modes.includes('web')) modes.push('web'); - if (modes.length === 0) modes.push('web'); - - return modes; - } - - get headers(): Record { - const headers: Record = { - baggage: this.baggage, - 'sentry-trace': this.trace, - }; - if (this.sessionCookie) headers.Cookie = this.sessionCookie; - return headers; - } - - async popularNovels( - page: number, - { - showLatestNovels, - filters, - }: Plugin.PopularNovelsOptions, - ): Promise { - let link = this.site + this.sourceLang + 'novel-list?'; - - const params = new URLSearchParams(); - params.append('orderBy', filters.orderBy.value); - params.append('order', filters.order.value); - params.append('status', filters.status.value); - params.append('release_status', filters.release_status.value); - params.append('addition_age', filters.addition_age.value); - params.append('page', page.toString()); - - if (filters.search.value) { - params.append('text', filters.search.value); - } - - if ( - filters.genres.value?.include && - filters.genres.value.include.length > 0 - ) { - params.append('gi', filters.genres.value.include.join(',')); - params.append('gc', filters.genre_operator.value); - } - if ( - filters.genres.value?.exclude && - filters.genres.value.exclude.length > 0 - ) { - params.append('ge', filters.genres.value.exclude.join(',')); - } - - if (filters.tags.value?.include && filters.tags.value.include.length > 0) { - params.append('ti', filters.tags.value.include.join(',')); - params.append('tc', filters.tag_operator.value); - } - if (filters.tags.value?.exclude && filters.tags.value.exclude.length > 0) { - params.append('te', filters.tags.value.exclude.join(',')); - } - - if (filters.folders.value) { - params.append('folders', filters.folders.value); - } - if (filters.library_exclude.value) { - params.append('le', filters.library_exclude.value); - } - - if (filters.min_chapters.value) { - params.append('count_value', filters.min_chapters.value); - } - if (filters.min_rating.value) { - params.append('minr', filters.min_rating.value); - } - if (filters.min_review_count.value) { - params.append('minrc', filters.min_review_count.value); - } - - if (showLatestNovels) { - const response = await fetchApi(this.site + 'api/home/recent', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ page: page }), - }); - - const recentNovel: JsonNovel = await response.json(); - - // Parse novels from JSON - const novels: Plugin.NovelItem[] = recentNovel.data.map( - (datum: Datum) => ({ - name: datum.serie.data.title || datum.serie.slug || '', - cover: datum.serie.data.image, - path: - this.sourceLang + - 'serie-' + - datum.serie.raw_id + - '/' + - datum.serie.slug || '', - }), - ); - - return novels; - } else { - const finderPage = await fetchApi(this.site + 'en/novel-finder').then( - res => res.text(), - ); - const finderCheerio = parseHTML(finderPage); - const nextData = finderCheerio('#__NEXT_DATA__').html(); - if (!nextData) { - throw new Error('Could not find __NEXT_DATA__ on novel finder page'); - } - const buildId = JSON.parse(nextData).buildId; - - link = `${this.site}_next/data/${buildId}/en/novel-finder.json?${params.toString()}`; - - const response = await fetchApi(link); - const json = await response.json(); - const seenIds = new Set(); - - const novels: Plugin.NovelItem[] = json.pageProps.series - .filter((novel: Datum) => { - if (seenIds.has(novel.raw_id)) { - return false; - } - seenIds.add(novel.raw_id); - return true; - }) - .map((novel: Datum) => ({ - name: novel.data.title, - cover: novel.data.image, - path: `${this.sourceLang}serie-${novel.raw_id}/${novel.slug}`, - })); - - return novels; - } - } - - async fetchTokens() { - const body = await fetchApi(this.site + this.sourceLang).then(res => - res.text(), - ); - const $ = parseHTML(body); - - this.baggage = $('meta[name="baggage"]').attr('content') ?? ''; - this.trace = $('meta[name="sentry-trace"]').attr('content') ?? ''; - } - - async parseNovel(novelPath: string): Promise { - const body = await fetchApi(this.site + novelPath).then(res => res.text()); - const loadedCheerio = parseHTML(body); - - const baggage = loadedCheerio('meta[name="baggage"]').attr('content'); - const trace = loadedCheerio('meta[name="sentry-trace"]').attr('content'); - - if (baggage && trace) { - this.baggage = baggage; - this.trace = trace; - } else if (!this.baggage || !this.trace) { - await this.fetchTokens(); - } - - const nextDataElement = loadedCheerio('#__NEXT_DATA__'); - const nextDataText = nextDataElement.html(); - - let rawId: number | null = null; - let slug: string | null = null; - let chapterCount = 0; - - const novel: Plugin.SourceNovel = { - path: novelPath, - name: loadedCheerio('h1.text-uppercase').text(), - summary: loadedCheerio('.lead').text().trim(), - }; - - if (nextDataText) { - try { - const jsonData = JSON.parse(nextDataText); - const serieData = jsonData?.props?.pageProps?.serie?.serie_data; - - // console.log('Parsed novel JSON data:', serieData); - - if (serieData) { - novel.name = serieData.data?.title || ''; - novel.cover = serieData.data?.image || ''; - novel.summary = serieData.data?.description || ''; - novel.author = serieData.data?.author || ''; - rawId = serieData.raw_id || null; - slug = serieData.slug || null; - - switch (serieData.status) { - case 0: - novel.status = 'Ongoing'; - break; - case 1: - novel.status = 'Completed'; - break; - default: - novel.status = 'Unknown'; - } - } - } catch (error) { - console.error('Failed to parse __NEXT_DATA__:', error); - } - } - - if (!novel.name) { - novel.name = - loadedCheerio('h1.text-uppercase').text() || - loadedCheerio('h1.long-title').text() || - loadedCheerio('.title-wrap h1').text().trim(); - } - - if (!novel.cover) { - novel.cover = - loadedCheerio('.image-wrap img').attr('src') || - loadedCheerio('.img-wrap > img').attr('src'); - } - - if (!novel.summary) { - novel.summary = - loadedCheerio('.description').text().trim() || - loadedCheerio('.desc-wrap .description').text().trim() || - loadedCheerio('.lead').text().trim(); - } - - // Genres and tags live in __NEXT_DATA__ as numeric ids, not in the page - // markup. Tag names are served already resolved in pageProps.tags; genre - // names appear only as the text of their chip links. - const labels: string[] = []; - - if (nextDataText) { - try { - const tagData = JSON.parse(nextDataText); - const pageProps = tagData?.props?.pageProps; - const ids: number[] = pageProps?.serie?.serie_data?.genres || []; - - const genreNames = new Map(); - loadedCheerio('a[href*="novel-list?genre="]').each((i, el) => { - const href = loadedCheerio(el).attr('href') || ''; - const matched = href.match(/genre=(\d+)/); - const name = loadedCheerio(el).text().trim(); - if (matched && name) genreNames.set(parseInt(matched[1], 10), name); - }); - - for (const id of ids) { - const name = genreNames.get(id); - if (name) { - labels.push(name.charAt(0).toUpperCase() + name.slice(1)); - } - } - - if (Array.isArray(pageProps?.tags)) { - for (const tag of pageProps.tags) { - const title = tag?.title && String(tag.title).trim(); - if (title) labels.push(title); - } - } - } catch (error) { - console.error('Failed to read genres/tags from __NEXT_DATA__:', error); - } - } - - if (labels.length > 0) { - novel.genres = labels - .filter((label, index) => labels.indexOf(label) === index) - .join(', '); - } - - if (!novel.author) { - novel.author = - loadedCheerio('td:contains("Author")') - .next() - .text() - .replace(/[\t\n]/g, '') - .trim() || - loadedCheerio('td:contains("Author") + td') - .text() - .replace(/[\t\n]/g, '') - .trim(); - } - - if (!novel.status) { - novel.status = - loadedCheerio('td:contains("Status")') - .next() - .text() - .replace(/[\t\n]/g, '') - .trim() || - loadedCheerio('td:contains("Status") + td') - .text() - .replace(/[\t\n]/g, '') - .trim() || - loadedCheerio('.detail-line:contains("•")') - .text() - .match(/•\s*(\w+)/)?.[1] || - ''; - } - - const urlMatch = novelPath.match(/(?:serie|novel)-?(\d+)\/([^/]+)/); - if (urlMatch) { - rawId = parseInt(urlMatch[1]); - slug = urlMatch[2]; - } - - const chapterCountText = - loadedCheerio('.detail-line:contains("Chapters")').text() || - loadedCheerio('div:contains("Chapters")').text(); - const chapterCountMatch = chapterCountText.match(/(\d+)\s+Chapters?/i); - if (chapterCountMatch) { - chapterCount = parseInt(chapterCountMatch[1]); - } - if (chapterCount === 0 && nextDataText) { - try { - const jsonData = JSON.parse(nextDataText); - - chapterCount = - jsonData?.props?.pageProps?.serie?.serie_data?.chapter_count ?? 0; - } catch (error) { - console.error( - 'Failed to parse chapter_count from __NEXT_DATA__:', - error, - ); - } - } - let chapters: Plugin.ChapterItem[] = []; - - if (rawId && slug) { - try { - chapters = await this.fetchAllChapters(rawId, slug); - } catch (error) { - console.error('Failed to fetch chapters via API:', error); - chapters = []; - } - } else { - console.warn('Could not extract rawId or slug from page', { - rawId, - slug, - }); - } - - novel.chapters = chapters; - - return novel; - } - - async decrypt(encrypted: string, encKey: string) { - try { - // t is set to false here; true if arr: - // If true we parse as json - let t = !1, - u = encrypted; - // t true if arr:, str: straight, else error - encrypted.startsWith('arr:') - ? ((t = !0), (u = encrypted.substring(4))) - : encrypted.startsWith('str:') && (u = encrypted.substring(4)); - const r = u.split(':'); - if (3 !== r.length) throw Error('Invalid encrypted data format'); - - // Remove base64, setup vars - const [iv, tag, ciphertext] = r.map(part => - Uint8Array.from(atob(part), e => e.charCodeAt(0)), - ), - combined = new Uint8Array(ciphertext.length + tag.length); - - // Make the ciphertext + tag format expected for decryption - combined.set(ciphertext), combined.set(tag, ciphertext.length); - - // Decrypt with encKey - // Convert the key to bytes (first 32 characters of encKey) - const keyBytes = new TextEncoder().encode(encKey.slice(0, 32)); - - // Create AES-GCM cipher instance - const aes = gcm(keyBytes, iv); - - // Decrypt the combined ciphertext - const decrypted = aes.decrypt(combined); - - // Convert decrypted bytes to string - const m = new TextDecoder().decode(decrypted); - - // const D = new TextEncoder().encode(encKey.slice(0, 32)); - // const d = await crypto.subtle.importKey( - // 'raw', - // D, - // { name: 'AES-GCM' }, - // !1, - // ['decrypt'], - // ); - // const h = await crypto.subtle.decrypt( - // { name: 'AES-GCM', iv: iv }, - // d, - // combined, - // ); - // const m = new TextDecoder().decode(h); - - // If it was arr:, parse as json - if (t) return JSON.parse(m); - // Otherwise (str:) return straight - return m; - } catch (error) { - console.error('Client-side decryption error:', error); - const msg = { 'error': `

Client-side decryption error:

${error}` }; - return msg; - } - } - - async getKey($: CheerioAPI): Promise { - // Fetch the novel's data in JSON format - const searchKey = 'TextEncoder().encode("'; - - const URLs: string[] = []; - let code: string | undefined; - let index = -1; - - // Find URL with API Key - const scripts = $('head').find('script').toArray(); - for (const el of scripts) { - const src = $(el).attr('src'); - if (!src) continue; - if (URLs.includes(src)) continue; - URLs.push(src); - } - - for (const src of URLs) { - const script = await fetchApi(`${this.site}${src}`); - const raw = await script.text(); - index = raw.indexOf(searchKey); - if (index >= 0) { - code = raw; - break; - } - } - if (!code) { - throw new Error('Failed to find Encryption Key'); - } - // Get right segment of code - const encKey = code.substring(index + 22, index + 54); - return encKey; - } - - async translate(data: string[]): Promise { - const contained = data.map((line, i) => `${line}`); - - const response = await fetchApi( - 'https://translate-pa.googleapis.com/v1/translateHtml', - { - 'credentials': 'omit', - 'headers': { - 'content-type': 'application/json+protobuf', - // Generic public API key source also uses - // Seen all over google - 'X-Goog-API-Key': 'AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520', - }, - 'referrer': 'https://wtr-lab.com/', - 'body': `[[${JSON.stringify(contained)},"zh-CN","en"],"te_lib"]`, - 'method': 'POST', - }, - ); - const translated = await response.json(); - const out = translated && translated[0] ? translated[0] : []; - return out as string[]; - } - - async parseChapter(chapterPath: string): Promise { - const url = this.site + chapterPath; - let rawId: number | null = null; - let chapterNo: number | null = null; - let loadedCheerio = null; - - const urlMatch = chapterPath.match( - /(?:serie|novel)-?(\d+)\/[^/]+\/chapter-(\d+)/, - ); - if (urlMatch) { - rawId = parseInt(urlMatch[1], 10); - chapterNo = parseInt(urlMatch[2], 10); - // console.log('Extracted from URL - rawId:', rawId, 'chapterNo:', chapterNo); - } - - if (!rawId || !chapterNo) { - const body = await fetchApi(url).then(res => res.text()); - - loadedCheerio = parseHTML(body); - const chapterJson = loadedCheerio('#__NEXT_DATA__').html() + ''; - const jsonData: NovelJson = JSON.parse(chapterJson); - - // const chapterID = jsonData.props.pageProps.serie.chapter.id; - rawId = jsonData.props.pageProps.serie.chapter.raw_id; - chapterNo = jsonData.props.pageProps.serie.chapter.order; - } - - if (!rawId || !chapterNo) { - const errorMsg = `Missing required parameters for API call from URL '${chapterPath}' - rawId: ${rawId}, chapterNo: ${chapterNo}. Please check the URL format.`; - console.error(errorMsg); - throw new Error(errorMsg); - } - - const translationTypes = this.translationModes; - const cookie = this.sessionCookie; - - const attemptLog: string[] = []; - let parsedJson; - let usedType: string | null = null; - - // Establish a session from the emailed link before asking for a chapter. - const signInNote = await this.ensureSignedIn(); - if (signInNote) attemptLog.push(signInNote); - - for (const type of translationTypes) { - const apiResponse = await fetchApi(`${this.site}api/reader/get`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - ...(cookie ? { Cookie: cookie } : {}), - }, - referrer: url, - body: JSON.stringify({ - translate: type, - language: this.sourceLang.replace('/', ''), - raw_id: rawId, - chapter_no: chapterNo, - retry: false, - force_retry: false, - }), - }); - - // Read as text first: an auth redirect or a Cloudflare challenge returns - // HTML, and .json() would throw before we could report what came back. - const rawBody = await apiResponse.text(); - let candidate = null; - try { - candidate = JSON.parse(rawBody); - } catch (e) { - candidate = null; - } - - if (!candidate) { - attemptLog.push( - `"${type}": HTTP ${apiResponse.status} — response was not JSON: ${rawBody - .slice(0, 150) - .replace(/<[^>]*>/g, ' ') - .trim()}`, - ); - continue; - } - - parsedJson = candidate; - - if (!apiResponse.ok) { - attemptLog.push( - `"${type}": HTTP ${apiResponse.status}${ - candidate.error ? ' — ' + candidate.error : '' - }${candidate.message ? ' — ' + candidate.message : ''}`, - ); - continue; - } - if (candidate.error) { - attemptLog.push(`"${type}": ${candidate.error}`); - continue; - } - if (candidate.success === false) { - attemptLog.push( - `"${type}": ${candidate.message || 'request was not successful'}`, - ); - continue; - } - - usedType = type; - break; - } - - const showNotice = storage.get('showModeNotice') !== false; - const cookieState = showNotice - ? await this.checkSession() - : cookie - ? `session cookie sent (${cookie.length} chars)` - : 'no session cookie set'; - - if (!usedType || !parsedJson?.data?.data) { - const errorMsg = - `None of the requested translations could be loaded [${cookieState}]. ` + - (attemptLog.length - ? attemptLog.join(' | ') - : 'The server returned no usable response.'); - console.error(errorMsg); - throw new Error(errorMsg); - } - let chapterContent: any = parsedJson.data.data.body; - const chapterTitle: string | undefined = parsedJson?.chapter?.title; - const chapterGlossary: ChapterContent['glossary_data'] | undefined = - parsedJson?.data?.data?.glossary_data; - - let htmlString = ''; - if (chapterTitle) { - htmlString += `

${chapterTitle}

`; - } - - if ( - chapterContent.toString().startsWith('arr:') || - chapterContent.toString().startsWith('str:') - ) { - if (!loadedCheerio) { - const body = await fetchApi(url).then(res => res.text()); - - loadedCheerio = parseHTML(body); - } - const encKey = await this.getKey(loadedCheerio); - chapterContent = await this.decrypt(chapterContent, encKey); - if (Object.prototype.hasOwnProperty.call(chapterContent, 'error')) { - htmlString += `

${chapterContent.error.toString()}

`; - return htmlString; - } - chapterContent = await this.translate(chapterContent); - usedType = `${usedType} + Google Translate (on-device)`; - } - - if (storage.get('showModeNotice') !== false && usedType) { - htmlString += `

Translation: ${usedType} — ${cookieState}

`; - } - - if (attemptLog.length) { - htmlString += `

Skipped: ${attemptLog.join( - ' | ', - )}

`; - } - - const dictionary = chapterGlossary?.terms?.map(t => t[0]) || []; - - for (let text of chapterContent) { - if (dictionary.length > 0) { - text = text.replaceAll( - /(?:wtr-lab\s+)?※([0-9]+)[⛬〓]/g, - (m: string, index: string) => dictionary[parseInt(index)] || m, - ); - } - htmlString += `

${text}

`; - } - - return htmlString; - } - - async fetchAllChapters( - rawId: number, - slug: string, - ): Promise { - const allChapters: Plugin.ChapterItem[] = []; - const batchSize = 500; - let start = 1; - let hasMore = true; - - while (hasMore) { - const end = start + batchSize - 1; - - try { - const response = await fetchApi( - `${this.site}api/chapters/${rawId}?start=${start}&end=${end}`, - { - headers: { - ...this.headers, - }, - }, - ); - - const data = await response.json(); - const chapters = data.chapters ?? data.data?.chapters ?? []; - - if (!Array.isArray(chapters) || chapters.length === 0) { - hasMore = false; - break; - } - - const batchChapters: Plugin.ChapterItem[] = chapters.map( - (apiChapter: ApiChapter) => ({ - name: - apiChapter.title || - apiChapter.name || - `Chapter ${apiChapter.order}`, - path: `${this.sourceLang}serie-${rawId}/${slug}/chapter-${apiChapter.order}`, - releaseTime: apiChapter.updated_at?.substring(0, 10), - chapterNumber: apiChapter.order, - }), - ); - - allChapters.push(...batchChapters); - - if (chapters.length < batchSize) { - hasMore = false; - break; - } - - start += batchSize; - } catch (error) { - console.error(`Failed to fetch chapters ${start}-${end}:`, error); - hasMore = false; - break; - } - } - - return allChapters.sort( - (a, b) => (a.chapterNumber || 0) - (b.chapterNumber || 0), - ); - } - - async searchNovels( - searchTerm: string, - page: number, - ): Promise { - const filters = this.filters; - filters.search.value = searchTerm; - return this.popularNovels(page, { showLatestNovels: false, filters }); - } - - filters = { - search: { - value: '', - label: 'Search', - type: FilterTypes.TextInput, - }, - orderBy: { - value: 'update', - label: 'Order by', - options: [ - { label: 'Update Date', value: 'update' }, - { label: 'Addition Date', value: 'date' }, - { label: 'Random', value: 'random' }, - { label: 'Weekly View', value: 'weekly_rank' }, - { label: 'Monthly View', value: 'monthly_rank' }, - { label: 'All-Time View', value: 'view' }, - { label: 'Name', value: 'name' }, - { label: 'Reader', value: 'reader' }, - { label: 'Chapter', value: 'chapter' }, - { label: 'Rating', value: 'rating' }, - { label: 'Review Count', value: 'total_rate' }, - { label: 'Vote Count', value: 'vote' }, - ], - type: FilterTypes.Picker, - }, - order: { - value: 'desc', - label: 'Order', - options: [ - { label: 'Descending', value: 'desc' }, - { label: 'Ascending', value: 'asc' }, - ], - type: FilterTypes.Picker, - }, - status: { - value: 'all', - label: 'Status', - options: [ - { label: 'All', value: 'all' }, - { label: 'Ongoing', value: 'ongoing' }, - { label: 'Completed', value: 'completed' }, - { label: 'Hiatus', value: 'hiatus' }, - { label: 'Dropped', value: 'dropped' }, - ], - type: FilterTypes.Picker, - }, - release_status: { - value: 'all', - label: 'Release Status', - options: [ - { label: 'All', value: 'all' }, - { label: 'Released', value: 'released' }, - { label: 'On Voting', value: 'voting' }, - ], - type: FilterTypes.Picker, - }, - addition_age: { - value: 'all', - label: 'Addition Age', - options: [ - { label: 'All', value: 'all' }, - { label: '< 2 Days', value: 'day' }, - { label: '< 1 Week', value: 'week' }, - { label: '< 1 Month', value: 'month' }, - ], - type: FilterTypes.Picker, - }, - min_chapters: { - value: '', - label: 'Minimum Chapters', - type: FilterTypes.TextInput, - }, - min_rating: { - value: '', - label: 'Minimum Rating (0.0-5.0)', - type: FilterTypes.TextInput, - }, - min_review_count: { - value: '', - label: 'Minimum Review Count', - type: FilterTypes.TextInput, - }, - genre_operator: { - value: 'and', - label: 'Genre (And/Or)', - options: [ - { label: 'And', value: 'and' }, - { label: 'Or', value: 'or' }, - ], - type: FilterTypes.Picker, - }, - genres: { - label: 'Genres', - type: FilterTypes.ExcludableCheckboxGroup, - value: { include: [], exclude: [] }, - options: [ - { label: 'Action', value: '1' }, - { label: 'Adult', value: '2' }, - { label: 'Adventure', value: '3' }, - { label: 'Comedy', value: '4' }, - { label: 'Drama', value: '5' }, - { label: 'Ecchi', value: '6' }, - { label: 'Erciyuan', value: '7' }, - { label: 'Fan-Fiction', value: '8' }, - { label: 'Fantasy', value: '9' }, - { label: 'Game', value: '10' }, - { label: 'Gender-Bender', value: '11' }, - { label: 'Harem', value: '12' }, - { label: 'Historical', value: '13' }, - { label: 'Horror', value: '14' }, - { label: 'Josei', value: '15' }, - { label: 'Martial-Arts', value: '16' }, - { label: 'Mature', value: '17' }, - { label: 'Mecha', value: '18' }, - { label: 'Military', value: '19' }, - { label: 'Mystery', value: '20' }, - { label: 'Psychological', value: '21' }, - { label: 'Romance', value: '22' }, - { label: 'School-Life', value: '23' }, - { label: 'Sci-Fi', value: '24' }, - { label: 'Seinen', value: '25' }, - { label: 'Shoujo', value: '26' }, - { label: 'Shoujo-Ai', value: '27' }, - { label: 'Shounen', value: '28' }, - { label: 'Shounen-Ai', value: '29' }, - { label: 'Slice-Of-Life', value: '30' }, - { label: 'Smut', value: '31' }, - { label: 'Sports', value: '32' }, - { label: 'Supernatural', value: '33' }, - { label: 'Tragedy', value: '34' }, - { label: 'Urban-Life', value: '35' }, - { label: 'Wuxia', value: '36' }, - { label: 'Xianxia', value: '37' }, - { label: 'Xuanhuan', value: '38' }, - { label: 'Yaoi', value: '39' }, - { label: 'Yuri', value: '40' }, - ], - }, - tag_operator: { - value: 'and', - label: 'Tag (And/Or)', - options: [ - { label: 'And', value: 'and' }, - { label: 'Or', value: 'or' }, - ], - type: FilterTypes.Picker, - }, - - tags: { - label: 'Tags', - type: FilterTypes.ExcludableCheckboxGroup, - value: { - include: [], - exclude: [], - }, - options: [ - { label: 'Abandoned Children', value: '1' }, - { label: 'Ability Steal', value: '2' }, - { label: 'Absent Parents', value: '3' }, - { label: 'Abusive Characters', value: '4' }, - { label: 'Academy', value: '5' }, - { label: 'Accelerated Growth', value: '6' }, - { label: 'Acting', value: '7' }, - { label: 'Adapted from Manga', value: '8' }, - { label: 'Adapted from Manhua', value: '9' }, - { label: 'Adapted to Anime', value: '10' }, - { label: 'Adapted to Drama', value: '11' }, - { label: 'Adapted to Drama CD', value: '12' }, - { label: 'Adapted to Game', value: '13' }, - { label: 'Adapted to Manga', value: '14' }, - { label: 'Adapted to Manhua', value: '15' }, - { label: 'Adapted to Manhwa', value: '16' }, - { label: 'Adapted to Movie', value: '17' }, - { label: 'Adapted to Visual Novel', value: '18' }, - { label: 'Adopted Children', value: '19' }, - { label: 'Adopted Protagonist', value: '20' }, - { label: 'Adultery', value: '21' }, - { label: 'Adventurers', value: '22' }, - { label: 'Affair', value: '23' }, - { label: 'Age Progression', value: '24' }, - { label: 'Age Regression', value: '25' }, - { label: 'Aggressive Characters', value: '26' }, - { label: 'Alchemy', value: '27' }, - { label: 'Aliens', value: '28' }, - { label: 'All-Girls School', value: '29' }, - { label: 'Alternate World', value: '30' }, - { label: 'Amnesia', value: '31' }, - { label: 'Amusement Park', value: '32' }, - { label: 'Anal', value: '33' }, - { label: 'Ancient China', value: '34' }, - { label: 'Ancient Times', value: '35' }, - { label: 'Androgynous Characters', value: '36' }, - { label: 'Androids', value: '37' }, - { label: 'Angels', value: '38' }, - { label: 'Animal Characteristics', value: '39' }, - { label: 'Animal Rearing', value: '40' }, - { label: 'Anti-Magic', value: '41' }, - { label: 'Anti-social Protagonist', value: '42' }, - { label: 'Antihero Protagonist', value: '43' }, - { label: 'Antique Shop', value: '44' }, - { label: 'Apartment Life', value: '45' }, - { label: 'Apathetic Protagonist', value: '46' }, - { label: 'Apocalypse', value: '47' }, - { label: 'Appearance Changes', value: '48' }, - { label: 'Appearance Different from Actual Age', value: '49' }, - { label: 'Archery', value: '50' }, - { label: 'Aristocracy', value: '51' }, - { label: 'Arms Dealers', value: '52' }, - { label: 'Army', value: '53' }, - { label: 'Army Building', value: '54' }, - { label: 'Arranged Marriage', value: '55' }, - { label: 'Array', value: '822' }, - { label: 'Arrogant Characters', value: '56' }, - { label: 'Artifact Crafting', value: '57' }, - { label: 'Artifacts', value: '58' }, - { label: 'Artificial Intelligence', value: '59' }, - { label: 'Artists', value: '60' }, - { label: 'Assassins', value: '61' }, - { label: 'Astrologers', value: '62' }, - { label: 'Autism', value: '63' }, - { label: 'Automatons', value: '64' }, - { label: 'Average-looking Protagonist', value: '65' }, - { label: 'Award-winning Work', value: '66' }, - { label: 'Awkward Protagonist', value: '67' }, - { label: 'Bands', value: '68' }, - { label: 'Based on a Movie', value: '69' }, - { label: 'Based on a Song', value: '70' }, - { label: 'Based on a TV Show', value: '71' }, - { label: 'Based on a Video Game', value: '72' }, - { label: 'Based on a Visual Novel', value: '73' }, - { label: 'Based on an Anime', value: '74' }, - { label: 'Basketball', value: '809' }, - { label: 'Battle Academy', value: '75' }, - { label: 'Battle Competition', value: '76' }, - { label: 'BDSM', value: '77' }, - { label: 'Beast Companions', value: '78' }, - { label: 'Beastkin', value: '79' }, - { label: 'Beasts', value: '80' }, - { label: 'Beautiful Female Lead', value: '81' }, - { label: 'Bestiality', value: '82' }, - { label: 'Betrayal', value: '83' }, - { label: 'Bickering Couple', value: '84' }, - { label: 'Biochip', value: '85' }, - { label: 'Bisexual Protagonist', value: '86' }, - { label: 'Black Belly', value: '87' }, - { label: 'Blackmail', value: '88' }, - { label: 'Blacksmith', value: '89' }, - { label: 'Bleach', value: '770' }, - { label: 'Blind Dates', value: '90' }, - { label: 'Blind Protagonist', value: '91' }, - { label: 'Blood Manipulation', value: '92' }, - { label: 'Bloodlines', value: '93' }, - { label: 'Body Swap', value: '94' }, - { label: 'Body Tempering', value: '95' }, - { label: 'Body-double', value: '96' }, - { label: 'Bodyguards', value: '97' }, - { label: 'Books', value: '98' }, - { label: 'Bookworm', value: '99' }, - { label: 'Boss-Subordinate Relationship', value: '100' }, - { label: 'Brainwashing', value: '101' }, - { label: 'Breast Fetish', value: '102' }, - { label: 'Broken Engagement', value: '103' }, - { label: 'Brother Complex', value: '104' }, - { label: 'Brotherhood', value: '105' }, - { label: 'Buddhism', value: '106' }, - { label: 'Bullying', value: '107' }, - { label: 'Business Management', value: '108' }, - { label: 'Business Wars', value: '806' }, - { label: 'Businessmen', value: '109' }, - { label: 'Butlers', value: '110' }, - { label: 'Calm Protagonist', value: '111' }, - { label: 'Cannibalism', value: '112' }, - { label: 'Card Games', value: '113' }, - { label: 'Carefree Protagonist', value: '114' }, - { label: 'Caring Protagonist', value: '115' }, - { label: 'Cautious Protagonist', value: '116' }, - { label: 'Celebrities', value: '117' }, - { label: 'Character Growth', value: '118' }, - { label: 'Charismatic Protagonist', value: '119' }, - { label: 'Charming Protagonist', value: '120' }, - { label: 'Chat Rooms', value: '121' }, - { label: 'Cheats', value: '122' }, - { label: 'Chefs', value: '123' }, - { label: 'Child Abuse', value: '124' }, - { label: 'Child Protagonist', value: '125' }, - { label: 'Childcare', value: '126' }, - { label: 'Childhood Friends', value: '127' }, - { label: 'Childhood Love', value: '128' }, - { label: 'Childhood Promise', value: '129' }, - { label: 'Childish Protagonist', value: '130' }, - { label: 'Chuunibyou', value: '131' }, - { label: 'Clan Building', value: '132' }, - { label: 'Class Awakening', value: '827' }, - { label: 'Classic', value: '133' }, - { label: 'Clever Protagonist', value: '134' }, - { label: 'Clingy Lover', value: '135' }, - { label: 'Clones', value: '136' }, - { label: 'Clubs', value: '137' }, - { label: 'Clumsy Love Interests', value: '138' }, - { label: 'Co-Workers', value: '139' }, - { label: 'Cohabitation', value: '140' }, - { label: 'Cold Love Interests', value: '141' }, - { label: 'Cold Protagonist', value: '142' }, - { label: 'Collection of Short Stories', value: '143' }, - { label: 'College/University', value: '144' }, - { label: 'Coma', value: '145' }, - { label: 'Comedic Undertone', value: '146' }, - { label: 'Coming of Age', value: '147' }, - { label: 'Complex Family Relationships', value: '148' }, - { label: 'Conditional Power', value: '149' }, - { label: 'Conferred Gods', value: '800' }, - { label: 'Confident Protagonist', value: '150' }, - { label: 'Confinement', value: '151' }, - { label: 'Conflicting Loyalties', value: '152' }, - { label: 'Contracts', value: '153' }, - { label: 'Cooking', value: '154' }, - { label: 'Copy', value: '807' }, - { label: 'Corruption', value: '155' }, - { label: 'Cosmic Wars', value: '156' }, - { label: 'Cosplay', value: '157' }, - { label: 'Couple Growth', value: '158' }, - { label: 'Court Official', value: '159' }, - { label: 'Cousins', value: '160' }, - { label: 'Cowardly Protagonist', value: '161' }, - { label: 'Crafting', value: '162' }, - { label: 'Crime', value: '163' }, - { label: 'Criminals', value: '164' }, - { label: 'Cross-dressing', value: '165' }, - { label: 'Crossover', value: '166' }, - { label: 'Cruel Characters', value: '167' }, - { label: 'Cryostasis', value: '168' }, - { label: 'Cultivation', value: '169' }, - { label: 'Cunnilingus', value: '170' }, - { label: 'Cunning Protagonist', value: '171' }, - { label: 'Curious Protagonist', value: '172' }, - { label: 'Curses', value: '173' }, - { label: 'Cute Children', value: '174' }, - { label: 'Cute Protagonist', value: '175' }, - { label: 'Cute Story', value: '176' }, - { label: 'Cyberpunk 2077', value: '783' }, - { label: 'Dancers', value: '177' }, - { label: 'Dao Companion', value: '178' }, - { label: 'Dao Comprehension', value: '179' }, - { label: 'Daoism', value: '180' }, - { label: 'Dark', value: '181' }, - { label: 'Dark Fantasy', value: '789' }, - { label: 'DC Universe', value: '778' }, - { label: 'Dead Protagonist', value: '182' }, - { label: 'Death', value: '183' }, - { label: 'Death of Loved Ones', value: '184' }, - { label: 'Debts', value: '185' }, - { label: 'Delinquents', value: '186' }, - { label: 'Delusions', value: '187' }, - { label: 'Demi-Humans', value: '188' }, - { label: 'Demon Lord', value: '189' }, - { label: 'Demon Slayer', value: '812' }, - { label: 'Demonic Cultivation Technique', value: '190' }, - { label: 'Demons', value: '191' }, - { label: 'Dense Protagonist', value: '192' }, - { label: 'Depictions of Cruelty', value: '193' }, - { label: 'Depression', value: '194' }, - { label: 'Destiny', value: '195' }, - { label: 'Detective Conan', value: '804' }, - { label: 'Detectives', value: '196' }, - { label: 'Determined Protagonist', value: '197' }, - { label: 'Devoted Love Interests', value: '198' }, - { label: 'Devouring', value: '797' }, - { label: 'Different Social Status', value: '199' }, - { label: 'Disabilities', value: '200' }, - { label: 'Discrimination', value: '201' }, - { label: 'Disfigurement', value: '202' }, - { label: 'Dishonest Protagonist', value: '203' }, - { label: 'Distrustful Protagonist', value: '204' }, - { label: 'Divination', value: '205' }, - { label: 'Divine Protection', value: '206' }, - { label: 'Divorce', value: '207' }, - { label: 'DnD', value: '794' }, - { label: 'Doctors', value: '208' }, - { label: 'Dolls/Puppets', value: '209' }, - { label: 'Domestic Affairs', value: '210' }, - { label: 'Doting Love Interests', value: '211' }, - { label: 'Doting Older Siblings', value: '212' }, - { label: 'Doting Parents', value: '213' }, - { label: 'Douluo Dalu', value: '772' }, - { label: 'Dragon Ball', value: '773' }, - { label: 'Dragon Riders', value: '214' }, - { label: 'Dragon Slayers', value: '215' }, - { label: 'Dragons', value: '216' }, - { label: 'Dreams', value: '217' }, - { label: 'Drugs', value: '218' }, - { label: 'Druids', value: '219' }, - { label: 'Dungeon Master', value: '220' }, - { label: 'Dungeons', value: '221' }, - { label: 'Dwarfs', value: '222' }, - { label: 'Dystopia', value: '223' }, - { label: 'e-Sports', value: '224' }, - { label: 'Early Romance', value: '225' }, - { label: 'Earth Invasion', value: '226' }, - { label: 'Easy Going Life', value: '227' }, - { label: 'Eavesdropping', value: '798' }, - { label: 'Economics', value: '228' }, - { label: 'Editors', value: '229' }, - { label: 'Eidetic Memory', value: '230' }, - { label: 'Elderly Protagonist', value: '231' }, - { label: 'Elemental Magic', value: '232' }, - { label: 'Elves', value: '233' }, - { label: 'Emotionally Weak Protagonist', value: '234' }, - { label: 'Empires', value: '235' }, - { label: 'Enemies Become Allies', value: '236' }, - { label: 'Enemies Become Lovers', value: '237' }, - { label: 'Engagement', value: '238' }, - { label: 'Engineer', value: '239' }, - { label: 'Enlightenment', value: '240' }, - { label: 'Episodic', value: '241' }, - { label: 'Eunuch', value: '242' }, - { label: 'European Ambience', value: '243' }, - { label: 'Evil Gods', value: '244' }, - { label: 'Evil Organizations', value: '245' }, - { label: 'Evil Protagonist', value: '246' }, - { label: 'Evil Religions', value: '247' }, - { label: 'Evolution', value: '248' }, - { label: 'Exhibitionism', value: '249' }, - { label: 'Exorcism', value: '250' }, - { label: 'Eye Powers', value: '251' }, - { label: 'Fairies', value: '252' }, - { label: 'Fairy Tail', value: '814' }, - { label: 'Faith Dependent Deities', value: '808' }, - { label: 'Fallen Angels', value: '253' }, - { label: 'Fallen Nobility', value: '254' }, - { label: 'Familial Love', value: '255' }, - { label: 'Familiars', value: '256' }, - { label: 'Family', value: '257' }, - { label: 'Family Business', value: '258' }, - { label: 'Family Conflict', value: '259' }, - { label: 'Famous Parents', value: '260' }, - { label: 'Famous Protagonist', value: '261' }, - { label: 'Fanaticism', value: '262' }, - { label: 'Fanfiction', value: '263' }, - { label: 'Fantasy Creatures', value: '264' }, - { label: 'Fantasy World', value: '265' }, - { label: 'Farming', value: '266' }, - { label: 'Fast Cultivation', value: '267' }, - { label: 'Fast Learner', value: '268' }, - { label: 'Fat Protagonist', value: '269' }, - { label: 'Fat to Fit', value: '270' }, - { label: 'Fated Lovers', value: '271' }, - { label: 'Fearless Protagonist', value: '272' }, - { label: 'Fellatio', value: '273' }, - { label: 'Female Master', value: '274' }, - { label: 'Female Protagonist', value: '275' }, - { label: 'Female to Male', value: '276' }, - { label: 'Feng Shui', value: '277' }, - { label: 'Firearms', value: '278' }, - { label: 'First Love', value: '279' }, - { label: 'First-time Intercourse', value: '280' }, - { label: 'Flashbacks', value: '281' }, - { label: 'Fleet Battles', value: '282' }, - { label: 'Folklore', value: '283' }, - { label: 'Football', value: '780' }, - { label: 'Forced into a Relationship', value: '284' }, - { label: 'Forced Living Arrangements', value: '285' }, - { label: 'Forced Marriage', value: '286' }, - { label: 'Forgetful Protagonist', value: '287' }, - { label: 'Former Hero', value: '288' }, - { label: 'Fox Spirits', value: '289' }, - { label: 'Friends Become Enemies', value: '290' }, - { label: 'Friendship', value: '291' }, - { label: 'Frieren', value: '816' }, - { label: 'Fujoshi', value: '292' }, - { label: 'Futanari', value: '293' }, - { label: 'Futuristic Setting', value: '294' }, - { label: 'Galge', value: '295' }, - { label: 'Gambling', value: '296' }, - { label: 'Game Creator', value: '784' }, - { label: 'Game Elements', value: '297' }, - { label: 'Game of Thrones', value: '813' }, - { label: 'Game Ranking System', value: '298' }, - { label: 'Gamers', value: '299' }, - { label: 'Gangs', value: '300' }, - { label: 'Gao Wu', value: '781' }, - { label: 'Gate to Another World', value: '301' }, - { label: 'Genderless Protagonist', value: '302' }, - { label: 'Generals', value: '303' }, - { label: 'Genetic Modifications', value: '304' }, - { label: 'Genies', value: '305' }, - { label: 'Genius Protagonist', value: '306' }, - { label: 'Genshin Impact', value: '815' }, - { label: 'Ghosts', value: '307' }, - { label: 'Gladiators', value: '308' }, - { label: 'Glasses-wearing Love Interests', value: '309' }, - { label: 'Glasses-wearing Protagonist', value: '310' }, - { label: 'Goblins', value: '311' }, - { label: 'God Protagonist', value: '312' }, - { label: 'God-human Relationship', value: '313' }, - { label: 'Goddesses', value: '314' }, - { label: 'Godly Powers', value: '315' }, - { label: 'Gods', value: '316' }, - { label: 'Golems', value: '317' }, - { label: 'Gore', value: '318' }, - { label: 'Grave Keepers', value: '319' }, - { label: 'Grinding', value: '320' }, - { label: 'Guardian Relationship', value: '321' }, - { label: 'Guilds', value: '322' }, - { label: 'Gunfighters', value: '323' }, - { label: 'Hackers', value: '324' }, - { label: 'Half-human Protagonist', value: '325' }, - { label: 'Handjob', value: '326' }, - { label: 'Handsome Male Lead', value: '327' }, - { label: 'Hard-Working Protagonist', value: '328' }, - { label: 'Harem-seeking Protagonist', value: '329' }, - { label: 'Harry Potter', value: '768' }, - { label: 'Harsh Training', value: '330' }, - { label: 'Hated Protagonist', value: '331' }, - { label: 'Healers', value: '332' }, - { label: 'Heartwarming', value: '333' }, - { label: 'Heaven', value: '334' }, - { label: 'Heavenly Defying Comprehension', value: '803' }, - { label: 'Heavenly Tribulation', value: '335' }, - { label: 'Hell', value: '336' }, - { label: 'Helpful Protagonist', value: '337' }, - { label: 'Herbalist', value: '338' }, - { label: 'Heroes', value: '339' }, - { label: 'Heterochromia', value: '340' }, - { label: 'Hidden Abilities', value: '341' }, - { label: 'Hiding True Abilities', value: '342' }, - { label: 'Hiding True Identity', value: '343' }, - { label: 'Hikikomori', value: '344' }, - { label: 'Hollywood', value: '779' }, - { label: 'Homunculus', value: '345' }, - { label: 'Honest Protagonist', value: '346' }, - { label: 'Hong Kong', value: '821' }, - { label: 'Honghuang', value: '801' }, - { label: 'Honkai', value: '818' }, - { label: 'Hospital', value: '347' }, - { label: 'Hot-blooded Protagonist', value: '348' }, - { label: 'Human Experimentation', value: '349' }, - { label: 'Human Weapon', value: '350' }, - { label: 'Human-Nonhuman Relationship', value: '351' }, - { label: 'Humanoid Protagonist', value: '352' }, - { label: 'Hunter x Hunter', value: '777' }, - { label: 'Hunters', value: '353' }, - { label: 'Hypnotism', value: '354' }, - { label: 'Identity Crisis', value: '355' }, - { label: 'Imaginary Friend', value: '356' }, - { label: 'Immortals', value: '357' }, - { label: 'Imperial Harem', value: '358' }, - { label: 'Incest', value: '359' }, - { label: 'Incubus', value: '360' }, - { label: 'Indecisive Protagonist', value: '361' }, - { label: 'Industrialization', value: '362' }, - { label: 'Inferiority Complex', value: '363' }, - { label: 'Inheritance', value: '364' }, - { label: 'Inscriptions', value: '365' }, - { label: 'Insects', value: '366' }, - { label: 'Interconnected Storylines', value: '367' }, - { label: 'Interdimensional Travel', value: '368' }, - { label: 'Introverted Protagonist', value: '369' }, - { label: 'Investigations', value: '370' }, - { label: 'Invisibility', value: '371' }, - { label: 'Jack of All Trades', value: '372' }, - { label: 'Jealousy', value: '373' }, - { label: 'Jiangshi', value: '374' }, - { label: 'Jobless Class', value: '375' }, - { label: 'Journey to the West', value: '796' }, - { label: 'JSDF', value: '376' }, - { label: 'Jujutsu Kaisen', value: '776' }, - { label: 'Kidnappings', value: '377' }, - { label: 'Kimetsu no Yaiba', value: '805' }, - { label: 'Kind Love Interests', value: '378' }, - { label: 'Kingdom Building', value: '379' }, - { label: 'Kingdoms', value: '380' }, - { label: 'Knights', value: '381' }, - { label: 'Kuudere', value: '382' }, - { label: 'Lack of Common Sense', value: '383' }, - { label: 'Language Barrier', value: '384' }, - { label: 'Late Romance', value: '385' }, - { label: 'Lawyers', value: '386' }, - { label: 'Lazy Protagonist', value: '387' }, - { label: 'Leadership', value: '388' }, - { label: 'League of Legends', value: '791' }, - { label: 'Legends', value: '389' }, - { label: 'Level System', value: '390' }, - { label: 'Library', value: '391' }, - { label: 'Life Script', value: '824' }, - { label: 'Limited Lifespan', value: '392' }, - { label: 'Live Streaming', value: '782' }, - { label: 'Living Abroad', value: '393' }, - { label: 'Living Alone', value: '394' }, - { label: 'Loli', value: '395' }, - { label: 'Loneliness', value: '396' }, - { label: 'Loner Protagonist', value: '397' }, - { label: 'Long Separations', value: '398' }, - { label: 'Long-distance Relationship', value: '399' }, - { label: 'Lord', value: '823' }, - { label: 'Lord of the Mysteries', value: '799' }, - { label: 'Lost Civilizations', value: '400' }, - { label: 'Lottery', value: '401' }, - { label: 'Love at First Sight', value: '402' }, - { label: 'Love Interest Falls in Love First', value: '403' }, - { label: 'Love Rivals', value: '404' }, - { label: 'Love Triangles', value: '405' }, - { label: 'Lovers Reunited', value: '406' }, - { label: 'Low-key Protagonist', value: '407' }, - { label: 'Loyal Subordinates', value: '408' }, - { label: 'Lucky Protagonist', value: '409' }, - { label: 'Magic', value: '410' }, - { label: 'Magic Beasts', value: '411' }, - { label: 'Magic Formations', value: '412' }, - { label: 'Magical Girls', value: '413' }, - { label: 'Magical Space', value: '414' }, - { label: 'Magical Technology', value: '415' }, - { label: 'Maids', value: '416' }, - { label: 'Male Protagonist', value: '417' }, - { label: 'Male to Female', value: '418' }, - { label: 'Male Yandere', value: '419' }, - { label: 'Management', value: '420' }, - { label: 'Mangaka', value: '421' }, - { label: 'Manipulative Characters', value: '422' }, - { label: 'Manly Gay Couple', value: '423' }, - { label: 'Marriage', value: '424' }, - { label: 'Marriage of Convenience', value: '425' }, - { label: 'Martial Spirits', value: '426' }, - { label: 'Marvel', value: '766' }, - { label: 'Masochistic Characters', value: '427' }, - { label: 'Master-Disciple Relationship', value: '428' }, - { label: 'Master-Servant Relationship', value: '429' }, - { label: 'Masturbation', value: '430' }, - { label: 'Matriarchy', value: '431' }, - { label: 'Mature Protagonist', value: '432' }, - { label: 'Medical Knowledge', value: '433' }, - { label: 'Medieval', value: '434' }, - { label: 'Mercenaries', value: '435' }, - { label: 'Merchants', value: '436' }, - { label: 'Military', value: '437' }, - { label: 'Mind Break', value: '438' }, - { label: 'Mind Control', value: '439' }, - { label: 'Minecraft', value: '790' }, - { label: 'Misandry', value: '440' }, - { label: 'Mismatched Couple', value: '441' }, - { label: 'Misunderstandings', value: '442' }, - { label: 'MMORPG', value: '443' }, - { label: 'Mob Protagonist', value: '444' }, - { label: 'Models', value: '445' }, - { label: 'Modern Day', value: '446' }, - { label: 'Modern Knowledge', value: '447' }, - { label: 'Money Grubber', value: '448' }, - { label: 'Monster Girls', value: '449' }, - { label: 'Monster Society', value: '450' }, - { label: 'Monster Tamer', value: '451' }, - { label: 'Monsters', value: '452' }, - { label: 'More Children More Blessings', value: '825' }, - { label: 'Mortal Flow', value: '792' }, - { label: 'Movies', value: '453' }, - { label: 'Mpreg', value: '454' }, - { label: 'Multiple Identities', value: '455' }, - { label: 'Multiple Personalities', value: '456' }, - { label: 'Multiple POV', value: '457' }, - { label: 'Multiple Protagonists', value: '458' }, - { label: 'Multiple Realms', value: '459' }, - { label: 'Multiple Reincarnated Individuals', value: '460' }, - { label: 'Multiple Timelines', value: '461' }, - { label: 'Multiple Transported Individuals', value: '462' }, - { label: 'Murders', value: '463' }, - { label: 'Music', value: '464' }, - { label: 'Mutated Creatures', value: '465' }, - { label: 'Mutations', value: '466' }, - { label: 'Mute Character', value: '467' }, - { label: 'Mysterious Family Background', value: '468' }, - { label: 'Mysterious Illness', value: '469' }, - { label: 'Mysterious Past', value: '470' }, - { label: 'Mystery Solving', value: '471' }, - { label: 'Mythical Beasts', value: '472' }, - { label: 'Mythology', value: '473' }, - { label: 'Naive Protagonist', value: '474' }, - { label: 'Narcissistic Protagonist', value: '475' }, - { label: 'Naruto', value: '769' }, - { label: 'Nationalism', value: '476' }, - { label: 'Near-Death Experience', value: '477' }, - { label: 'Necromancer', value: '478' }, - { label: 'Neet', value: '479' }, - { label: 'Netorare', value: '480' }, - { label: 'Netorase', value: '481' }, - { label: 'Netori', value: '482' }, - { label: 'Nightmares', value: '483' }, - { label: 'Ninjas', value: '484' }, - { label: 'Nobles', value: '485' }, - { label: 'Non-humanoid Protagonist', value: '486' }, - { label: 'Non-linear Storytelling', value: '487' }, - { label: 'Nudity', value: '488' }, - { label: 'Nurses', value: '489' }, - { label: 'Obsessive Love', value: '490' }, - { label: 'Office Romance', value: '491' }, - { label: 'Older Love Interests', value: '492' }, - { label: 'Omegaverse', value: '493' }, - { label: 'One Piece', value: '767' }, - { label: 'Oneshot', value: '494' }, - { label: 'Online Romance', value: '495' }, - { label: 'Onmyouji', value: '496' }, - { label: 'Orcs', value: '497' }, - { label: 'Organized Crime', value: '498' }, - { label: 'Orgy', value: '499' }, - { label: 'Orphans', value: '500' }, - { label: 'Otaku', value: '501' }, - { label: 'Otome Game', value: '502' }, - { label: 'Outcasts', value: '503' }, - { label: 'Outdoor Intercourse', value: '504' }, - { label: 'Outer Space', value: '505' }, - { label: 'Overlord', value: '826' }, - { label: 'Overpowered Protagonist', value: '506' }, - { label: 'Overprotective Siblings', value: '507' }, - { label: 'Pacifist Protagonist', value: '508' }, - { label: 'Paizuri', value: '509' }, - { label: 'Parallel Worlds', value: '510' }, - { label: 'Parasites', value: '511' }, - { label: 'Parent Complex', value: '512' }, - { label: 'Parody', value: '513' }, - { label: 'Part-Time Job', value: '514' }, - { label: 'Past Plays a Big Role', value: '515' }, - { label: 'Past Trauma', value: '516' }, - { label: 'Persistent Love Interests', value: '517' }, - { label: 'Personality Changes', value: '518' }, - { label: 'Perverted Protagonist', value: '519' }, - { label: 'Pets', value: '520' }, - { label: 'Pharmacist', value: '521' }, - { label: 'Philosophical', value: '522' }, - { label: 'Phobias', value: '523' }, - { label: 'Phoenixes', value: '524' }, - { label: 'Photography', value: '525' }, - { label: 'Pill Based Cultivation', value: '526' }, - { label: 'Pill Concocting', value: '527' }, - { label: 'Pilots', value: '528' }, - { label: 'Pirates', value: '529' }, - { label: 'Playboys', value: '530' }, - { label: 'Playful Protagonist', value: '531' }, - { label: 'Poetry', value: '532' }, - { label: 'Poisons', value: '533' }, - { label: 'Pokemon', value: '771' }, - { label: 'Police', value: '534' }, - { label: 'Polite Protagonist', value: '535' }, - { label: 'Politics', value: '536' }, - { label: 'Polyandry', value: '537' }, - { label: 'Polygamy', value: '538' }, - { label: 'Poor Protagonist', value: '539' }, - { label: 'Poor to Rich', value: '540' }, - { label: 'Popular Love Interests', value: '541' }, - { label: 'Possession', value: '542' }, - { label: 'Possessive Characters', value: '543' }, - { label: 'Post-apocalyptic', value: '544' }, - { label: 'Power Couple', value: '545' }, - { label: 'Power Struggle', value: '546' }, - { label: 'Pragmatic Protagonist', value: '547' }, - { label: 'Precognition', value: '548' }, - { label: 'Pregnancy', value: '549' }, - { label: 'Pretend Lovers', value: '550' }, - { label: 'Previous Life Talent', value: '551' }, - { label: 'Priestesses', value: '552' }, - { label: 'Priests', value: '553' }, - { label: 'Prison', value: '554' }, - { label: 'Proactive Protagonist', value: '555' }, - { label: 'Proficiency', value: '793' }, - { label: 'Programmer', value: '556' }, - { label: 'Prophecies', value: '557' }, - { label: 'Prostitutes', value: '558' }, - { label: 'Protagonist Falls in Love First', value: '559' }, - { label: 'Protagonist Strong from the Start', value: '560' }, - { label: 'Protagonist with Multiple Bodies', value: '561' }, - { label: 'Psychic Powers', value: '562' }, - { label: 'Psychopaths', value: '563' }, - { label: 'Puppeteers', value: '564' }, - { label: 'Quiet Characters', value: '565' }, - { label: 'Quirky Characters', value: '566' }, - { label: 'R-15', value: '567' }, - { label: 'R-18', value: '568' }, - { label: 'Race Change', value: '569' }, - { label: 'Racism', value: '570' }, - { label: 'Rape', value: '571' }, - { label: 'Rape Victim Becomes Lover', value: '572' }, - { label: 'Reality-Game Fusion', value: '830' }, - { label: 'Rebellion', value: '573' }, - { label: 'Reborn', value: '829' }, - { label: 'Reborn as the Villain', value: '831' }, - { label: 'Reincarnated as a Monster', value: '574' }, - { label: 'Reincarnated as an Object', value: '575' }, - { label: 'Reincarnated in a Game World', value: '576' }, - { label: 'Reincarnated in Another World', value: '577' }, - { label: 'Reincarnation', value: '578' }, - { label: 'Religions', value: '579' }, - { label: 'Reluctant Protagonist', value: '580' }, - { label: 'Reporters', value: '581' }, - { label: 'Restaurant', value: '582' }, - { label: 'Resurrection', value: '583' }, - { label: 'Returning from Another World', value: '584' }, - { label: 'Revenge', value: '585' }, - { label: 'Reverse Harem', value: '586' }, - { label: 'Reverse Rape', value: '587' }, - { label: 'Reversible Couple', value: '588' }, - { label: 'Rich to Poor', value: '589' }, - { label: 'Righteous Protagonist', value: '590' }, - { label: 'Rivalry', value: '591' }, - { label: 'Romantic Subplot', value: '592' }, - { label: 'Roommates', value: '593' }, - { label: 'Royalty', value: '594' }, - { label: 'Ruthless Protagonist', value: '595' }, - { label: 'Sadistic Characters', value: '596' }, - { label: 'Saints', value: '597' }, - { label: 'Salaryman', value: '598' }, - { label: 'Samurai', value: '599' }, - { label: 'Saving the World', value: '600' }, - { label: 'Schemes And Conspiracies', value: '601' }, - { label: 'Schizophrenia', value: '602' }, - { label: 'Scientists', value: '603' }, - { label: 'Sculptors', value: '604' }, - { label: 'Sealed Power', value: '605' }, - { label: 'Second Chance', value: '606' }, - { label: 'Secret Crush', value: '607' }, - { label: 'Secret Identity', value: '608' }, - { label: 'Secret Organizations', value: '609' }, - { label: 'Secret Relationship', value: '610' }, - { label: 'Secretive Protagonist', value: '611' }, - { label: 'Secrets', value: '612' }, - { label: 'Sect Development', value: '613' }, - { label: 'Seduction', value: '614' }, - { label: "Seeing Things Other Humans Can't", value: '615' }, - { label: 'Selfish Protagonist', value: '616' }, - { label: 'Selfless Protagonist', value: '617' }, - { label: 'Seme Protagonist', value: '618' }, - { label: 'Senpai-Kouhai Relationship', value: '619' }, - { label: 'Sentient Objects', value: '620' }, - { label: 'Sentimental Protagonist', value: '621' }, - { label: 'Serial Killers', value: '622' }, - { label: 'Servants', value: '623' }, - { label: 'Seven Deadly Sins', value: '624' }, - { label: 'Seven Virtues', value: '625' }, - { label: 'Sex Friends', value: '626' }, - { label: 'Sex Slaves', value: '627' }, - { label: 'Sexual Abuse', value: '628' }, - { label: 'Sexual Cultivation Technique', value: '629' }, - { label: 'Shameless Protagonist', value: '630' }, - { label: 'Shapeshifters', value: '631' }, - { label: 'Sharing A Body', value: '632' }, - { label: 'Sharp-tongued Characters', value: '633' }, - { label: 'Shield User', value: '634' }, - { label: 'Shikigami', value: '635' }, - { label: 'Short Story', value: '636' }, - { label: 'Shota', value: '637' }, - { label: 'Shoujo-Ai Subplot', value: '638' }, - { label: 'Shounen-Ai Subplot', value: '639' }, - { label: 'Showbiz', value: '640' }, - { label: 'Shy Characters', value: '641' }, - { label: 'Sibling Rivalry', value: '642' }, - { label: "Sibling's Care", value: '643' }, - { label: 'Siblings', value: '644' }, - { label: 'Siblings Not Related by Blood', value: '645' }, - { label: 'Sickly Characters', value: '646' }, - { label: 'Sign In', value: '811' }, - { label: 'Sign Language', value: '647' }, - { label: 'Siheyuan', value: '820' }, - { label: 'Simulator', value: '786' }, - { label: 'Singers', value: '648' }, - { label: 'Single Female Lead', value: '787' }, - { label: 'Single Parent', value: '649' }, - { label: 'Sister Complex', value: '650' }, - { label: 'Skill Assimilation', value: '651' }, - { label: 'Skill Books', value: '652' }, - { label: 'Skill Creation', value: '653' }, - { label: 'Slave Harem', value: '654' }, - { label: 'Slave Protagonist', value: '655' }, - { label: 'Slaves', value: '656' }, - { label: 'Sleeping', value: '657' }, - { label: 'Slow Growth at Start', value: '658' }, - { label: 'Slow Romance', value: '659' }, - { label: 'Smart Couple', value: '660' }, - { label: 'Social Outcasts', value: '661' }, - { label: 'Soldiers', value: '662' }, - { label: 'Soul Power', value: '663' }, - { label: 'Souls', value: '664' }, - { label: 'Spatial Manipulation', value: '665' }, - { label: 'Spear Wielder', value: '666' }, - { label: 'Special Abilities', value: '667' }, - { label: 'Spies', value: '668' }, - { label: 'Spirit Advisor', value: '669' }, - { label: 'Spirit Users', value: '670' }, - { label: 'Spirits', value: '671' }, - { label: 'Spiritual Energy Revival', value: '828' }, - { label: 'Stalkers', value: '672' }, - { label: 'Star Wars', value: '817' }, - { label: 'Stockholm Syndrome', value: '673' }, - { label: 'Stoic Characters', value: '674' }, - { label: 'Store Owner', value: '675' }, - { label: 'Straight Seme', value: '676' }, - { label: 'Straight Uke', value: '677' }, - { label: 'Strategic Battles', value: '678' }, - { label: 'Strategist', value: '679' }, - { label: 'Strength-based Social Hierarchy', value: '680' }, - { label: 'Strong Love Interests', value: '681' }, - { label: 'Strong to Stronger', value: '682' }, - { label: 'Stubborn Protagonist', value: '683' }, - { label: 'Student Council', value: '684' }, - { label: 'Student-Teacher Relationship', value: '685' }, - { label: 'Succubus', value: '686' }, - { label: 'Sudden Strength Gain', value: '687' }, - { label: 'Sudden Wealth', value: '688' }, - { label: 'Suicides', value: '689' }, - { label: 'Summoned Hero', value: '690' }, - { label: 'Summoning Magic', value: '691' }, - { label: 'Survival', value: '692' }, - { label: 'Survival Game', value: '693' }, - { label: 'Swallowed Star', value: '785' }, - { label: 'Sword And Magic', value: '694' }, - { label: 'Sword Wielder', value: '695' }, - { label: 'System', value: '696' }, - { label: 'Teachers', value: '697' }, - { label: 'Teamwork', value: '698' }, - { label: 'Technological Gap', value: '699' }, - { label: 'Tentacles', value: '700' }, - { label: 'Terminal Illness', value: '701' }, - { label: 'Territory Management', value: '802' }, - { label: 'Terrorists', value: '702' }, - { label: 'Thieves', value: '703' }, - { label: 'Three Kingdoms', value: '795' }, - { label: 'Threesome', value: '704' }, - { label: 'Thriller', value: '705' }, - { label: 'Time Loop', value: '706' }, - { label: 'Time Manipulation', value: '707' }, - { label: 'Time Paradox', value: '708' }, - { label: 'Time Skip', value: '709' }, - { label: 'Time Travel', value: '710' }, - { label: 'Timid Protagonist', value: '711' }, - { label: 'Tomboyish Female Lead', value: '712' }, - { label: 'Torture', value: '713' }, - { label: 'Toys', value: '714' }, - { label: 'Tragic Past', value: '715' }, - { label: 'Transformation Ability', value: '716' }, - { label: 'Transmigration', value: '717' }, - { label: 'Transplanted Memories', value: '718' }, - { label: 'Transported into a Game World', value: '719' }, - { label: 'Transported Modern Structure', value: '720' }, - { label: 'Transported to Another World', value: '721' }, - { label: 'Trap', value: '722' }, - { label: 'Tribal Society', value: '723' }, - { label: 'Trickster', value: '724' }, - { label: 'Tsundere', value: '725' }, - { label: 'Twins', value: '726' }, - { label: 'Twisted Personality', value: '727' }, - { label: 'Ugly Protagonist', value: '728' }, - { label: 'Ugly to Beautiful', value: '729' }, - { label: 'Unconditional Love', value: '730' }, - { label: 'Undead Protagonist', value: '810' }, - { label: 'Underestimated Protagonist', value: '731' }, - { label: 'Unique Cultivation Technique', value: '732' }, - { label: 'Unique Weapon User', value: '733' }, - { label: 'Unique Weapons', value: '734' }, - { label: 'Unlimited Flow', value: '735' }, - { label: 'Unlucky Protagonist', value: '736' }, - { label: 'Unreliable Narrator', value: '737' }, - { label: 'Unrequited Love', value: '738' }, - { label: 'Valkyries', value: '739' }, - { label: 'Vampires', value: '740' }, - { label: 'Villainess Noble Girls', value: '741' }, - { label: 'Virtual Reality', value: '742' }, - { label: 'Vocaloid', value: '743' }, - { label: 'Voice Actors', value: '744' }, - { label: 'Voyeurism', value: '745' }, - { label: 'Waiters', value: '746' }, - { label: 'War Records', value: '747' }, - { label: 'Warhammer', value: '775' }, - { label: 'Wars', value: '748' }, - { label: 'Weak Protagonist', value: '749' }, - { label: 'Weak to Strong', value: '750' }, - { label: 'Wealthy Characters', value: '751' }, - { label: 'Werebeasts', value: '752' }, - { label: 'Western Names', value: '788' }, - { label: 'Wishes', value: '753' }, - { label: 'Witcher', value: '819' }, - { label: 'Witches', value: '754' }, - { label: 'Wizards', value: '755' }, - { label: 'World Hopping', value: '756' }, - { label: 'World Travel', value: '757' }, - { label: 'World Tree', value: '758' }, - { label: 'Writers', value: '759' }, - { label: 'Yandere', value: '760' }, - { label: 'Youkai', value: '761' }, - { label: 'Younger Brothers', value: '762' }, - { label: 'Younger Love Interests', value: '763' }, - { label: 'Younger Sisters', value: '764' }, - { label: 'Yu-Gi-Oh!', value: '774' }, - { label: 'Zombies', value: '765' }, - ], - }, - - folders: { - value: '', - label: 'Library Folders', - options: [ - { label: 'No Filter', value: '' }, - { label: 'Reading', value: '1' }, - { label: 'Read Later', value: '2' }, - { label: 'Completed', value: '3' }, - { label: 'Trash', value: '5' }, - ], - type: FilterTypes.Picker, - }, - library_exclude: { - value: '', - label: 'Library Exclude', - options: [ - { label: 'None', value: '' }, - { label: 'Exclude All', value: 'history' }, - { label: 'Exclude Trash', value: 'trash' }, - { label: 'Exclude Library & Trash', value: 'in_library' }, - ], - type: FilterTypes.Picker, - }, - } satisfies Filters; -} - -type NovelJson = { - props: Props; - page: string; - query?: { raw_id: number }; -}; - -type Props = { - pageProps: PageProps; - __N_SSP: boolean; -}; - -type PageProps = { - serie: Serie; - server_time: Date; -}; - -type Serie = { - serie_data: SerieData; - chapter: Chapter; - recommendation: SerieData[]; - chapter_data: ChapterData; - id: number; - raw_id: number; - slug: string; - data: Data; - is_default: boolean; - raw_type: string; -}; - -type Chapter = { - serie_id: number; - id: number; - raw_id: number; - order: number; - slug: string; - title: string; - name: string; - created_at: string; - updated_at: string; -}; -type ApiChapter = { - serie_id: number; - id: number; - order: number; - title: string; - name: string; - updated_at: string; -}; - -// type GlossaryTerm = { -// index: number; -// english: string; -// chinese: string; -// symbol: string; -// }; -type ChapterData = { - data: ChapterContent; -}; -type ChapterContent = { - title: string; - body: string; - glossary_data?: { - terms: string[][]; - }; -}; - -type SerieData = { - serie_id?: number; - recommendation_id?: number; - score?: string; - id: number; - slug: string; - search_text: string; - status: number; - data: Data; - created_at: string; - updated_at: string; - view: number; - in_library: number; - rating: number | null; - chapter_count: number; - power: number; - total_rate: number; - user_status: number; - verified: boolean; - from: null; - raw_id: number; - genres?: number[]; -}; - -type Data = { - title: string; - author: string; - description: string; - image: string; -}; - -type JsonNovel = { - success: boolean; - data: Datum[]; -}; -type Datum = { - serie: Serie; - chapters: Chapter[]; - updated_at: Date; - raw_id: number; - slug: string; - data: Data; -}; - -export default new WTRLAB(); From 8ffaaa5c69baf86cbf91b527a3b99592fa79a249 Mon Sep 17 00:00:00 2001 From: ShakeyHands91 <143759757+ShakeyHands91@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:22:21 +0100 Subject: [PATCH 3/5] Add files via upload --- plugins/english/wtrlab.ts | 92 +++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/plugins/english/wtrlab.ts b/plugins/english/wtrlab.ts index f42024258..f6949d52f 100644 --- a/plugins/english/wtrlab.ts +++ b/plugins/english/wtrlab.ts @@ -9,7 +9,7 @@ class WTRLAB implements Plugin.PluginBase { id = 'WTRLAB'; name = 'WTR-LAB'; site = 'https://wtr-lab.com/'; - version = '1.2.1'; + version = '1.2.2'; icon = 'src/en/wtrlab/icon.png'; sourceLang = 'en/'; baggage = ''; @@ -888,8 +888,14 @@ class WTRLAB implements Plugin.PluginBase { searchTerm: string, page: number, ): Promise { - const filters = this.filters; - filters.search.value = searchTerm; + // Copy rather than mutate: this.filters is the plugin's own filter + // definition object, which the app also reads. Writing the term into it + // leaves it there, so every later browse is narrowed to that search and + // Reset restores the polluted value. + const filters = { + ...this.filters, + search: { ...this.filters.search, value: searchTerm }, + }; return this.popularNovels(page, { showLatestNovels: false, filters }); } @@ -989,46 +995,46 @@ class WTRLAB implements Plugin.PluginBase { 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: 'Erciyuan', value: 'erciyuan' }, - { label: 'Fan-Fiction', value: 'fan-fiction' }, - { label: 'Fantasy', value: 'fantasy' }, - { label: 'Game', value: 'game' }, - { label: 'Gender-Bender', value: 'gender-bender' }, - { label: 'Harem', value: 'harem' }, - { label: 'Historical', value: 'historical' }, - { label: 'Horror', value: 'horror' }, - { label: 'Josei', value: 'josei' }, - { 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: 'Tragedy', value: 'tragedy' }, - { label: 'Urban-Life', value: 'urban-life' }, - { label: 'Wuxia', value: 'wuxia' }, - { label: 'Xianxia', value: 'xianxia' }, - { label: 'Xuanhuan', value: 'xuanhuan' }, - { label: 'Yaoi', value: 'yaoi' }, - { label: 'Yuri', value: 'yuri' }, + { label: 'Action', value: '1' }, + { label: 'Adult', value: '2' }, + { label: 'Adventure', value: '3' }, + { label: 'Comedy', value: '4' }, + { label: 'Drama', value: '5' }, + { label: 'Ecchi', value: '6' }, + { label: 'Erciyuan', value: '7' }, + { label: 'Fan-Fiction', value: '8' }, + { label: 'Fantasy', value: '9' }, + { label: 'Game', value: '10' }, + { label: 'Gender-Bender', value: '11' }, + { label: 'Harem', value: '12' }, + { label: 'Historical', value: '13' }, + { label: 'Horror', value: '14' }, + { label: 'Josei', value: '15' }, + { label: 'Martial-Arts', value: '16' }, + { label: 'Mature', value: '17' }, + { label: 'Mecha', value: '18' }, + { label: 'Military', value: '19' }, + { label: 'Mystery', value: '20' }, + { label: 'Psychological', value: '21' }, + { label: 'Romance', value: '22' }, + { label: 'School-Life', value: '23' }, + { label: 'Sci-Fi', value: '24' }, + { label: 'Seinen', value: '25' }, + { label: 'Shoujo', value: '26' }, + { label: 'Shoujo-Ai', value: '27' }, + { label: 'Shounen', value: '28' }, + { label: 'Shounen-Ai', value: '29' }, + { label: 'Slice-Of-Life', value: '30' }, + { label: 'Smut', value: '31' }, + { label: 'Sports', value: '32' }, + { label: 'Supernatural', value: '33' }, + { label: 'Tragedy', value: '34' }, + { label: 'Urban-Life', value: '35' }, + { label: 'Wuxia', value: '36' }, + { label: 'Xianxia', value: '37' }, + { label: 'Xuanhuan', value: '38' }, + { label: 'Yaoi', value: '39' }, + { label: 'Yuri', value: '40' }, ], }, tag_operator: { From 1bb1203e0edb93384af2153cc84de11b5420c90d Mon Sep 17 00:00:00 2001 From: ShakeyHands91 <143759757+ShakeyHands91@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:16:36 +0100 Subject: [PATCH 4/5] Add files via upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checklist Update version code if an existing plugin was modified Test changes in Plugin Playground or the app Reference related issues in the PR body (e.g. Closes #xyz) Commit messages follow type(scope): description Fixes https://github.com/lnreader/lnreader-plugins/issues/2627 Closes #2627 WTR-LAB chapters stopped loading — the reader API moved the content wtr-lab changed /api/reader/get today. It no longer carries the chapter inline; it returns an envelope with a pointer to the payload: before { success, data: { data: { body, glossary_data, model, … } } } after { success, chapter: { title, locked, … }, tasks: [], content_url } GET content_url → { success, data: { …, data: { body, glossary_data, model, … } } } The plugin reads data.data.body off the first response, which no longer exists there, so every chapter fails. The request itself still returns 200 with success: true — it just has no body in it, which is why this surfaces as "no usable response" rather than an HTTP error. Verified live against serie 98170, chapter 2: mode payload at content_url ai body array of 136 strings, glossary_data with 20 terms, model: gemini-3.5-flash-lite web body string prefixed arr: plus encrypted — the existing decrypt path webplus same as web So the payload shape is unchanged — only its location moved. The existing decryption, glossary substitution and translation-mode handling all still apply once the pointer is followed. The change Follow content_url when present. It is returned relative, so it is resolved against the site. Fall back to the old inline data.data shape when that is what comes back, so the plugin works whichever the server returns — useful if this is still rolling out, and harmless afterwards. Chapter title now comes from the envelope (chapter.title, as added in #2538), with the payload's own title as a fallback. The failure message distinguishes "the server accepted the request but returned no chapter content" from "no usable response", so the next shape change is easier to spot. Testing Built and read in the app against the live site: AI, Web and Web+ all load again, glossary terms substitute, and the chapter title still renders. Eight regression checks cover this specifically — the new shape renders, content_url is followed exactly once and resolved to an absolute URL, the old inline shape still works without a second request, and a payload with neither raises an explicit error instead of throwing on undefined. Note on formatting prettier --check already fails on this file on master — one line in the AES helper, which looks like a prettier version difference rather than anything in this change. I've left it untouched rather than reformatting an unrelated line. --- plugins/english/wtrlab.ts | 63 +++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/plugins/english/wtrlab.ts b/plugins/english/wtrlab.ts index f6949d52f..d14b24d1d 100644 --- a/plugins/english/wtrlab.ts +++ b/plugins/english/wtrlab.ts @@ -9,7 +9,7 @@ class WTRLAB implements Plugin.PluginBase { id = 'WTRLAB'; name = 'WTR-LAB'; site = 'https://wtr-lab.com/'; - version = '1.2.2'; + version = '1.2.3'; icon = 'src/en/wtrlab/icon.png'; sourceLang = 'en/'; baggage = ''; @@ -62,6 +62,42 @@ class WTRLAB implements Plugin.PluginBase { return (storage.get('sessionCookie') || '').trim(); } + /** + * Resolve the chapter payload. + * + * The reader API used to carry the body inline at data.data. It now returns + * an envelope with a `content_url` pointing at the real payload, whose inner + * shape (body / glossary_data / model) is unchanged. Handle both so the + * plugin works whichever the server returns. + */ + async resolveChapterContent( + parsed: ReaderResponse, + cookie: string, + ): Promise { + const inline = parsed?.data?.data; + if (inline && inline.body !== undefined) return inline; + + const contentUrl = parsed?.content_url; + if (!contentUrl) return null; + + const url = /^https?:\/\//i.test(contentUrl) + ? contentUrl + : this.site.replace(/\/$/, '') + contentUrl; + + const res = await fetchApi(url, { + headers: { + 'Accept': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + }, + }); + const text = await res.text(); + try { + return JSON.parse(text)?.data?.data ?? null; + } catch (e) { + return null; + } + } + /** Only attempt the sign-in link once per app run: the links are single-use. */ signInAttempted = false; @@ -759,19 +795,27 @@ class WTRLAB implements Plugin.PluginBase { ? `session cookie sent (${cookie.length} chars)` : 'no session cookie set'; - if (!usedType || !parsedJson?.data?.data) { + const content = usedType + ? await this.resolveChapterContent(parsedJson, cookie) + : null; + + if (!usedType || !content || content.body === undefined) { const errorMsg = `None of the requested translations could be loaded [${cookieState}]. ` + (attemptLog.length ? attemptLog.join(' | ') - : 'The server returned no usable response.'); + : usedType + ? 'The server accepted the request but returned no chapter content.' + : 'The server returned no usable response.'); console.error(errorMsg); throw new Error(errorMsg); } - let chapterContent: any = parsedJson.data.data.body; - const chapterTitle: string | undefined = parsedJson?.chapter?.title; + + let chapterContent: any = content.body; + const chapterTitle: string | undefined = + parsedJson?.chapter?.title || content.title; const chapterGlossary: ChapterContent['glossary_data'] | undefined = - parsedJson?.data?.data?.glossary_data; + content.glossary_data; let htmlString = ''; if (chapterTitle) { @@ -1973,6 +2017,13 @@ type ApiChapter = { type ChapterData = { data: ChapterContent; }; +type ReaderResponse = { + success?: boolean; + content_url?: string; + chapter?: { title?: string; locked?: boolean }; + data?: { data?: ChapterContent }; +}; + type ChapterContent = { title: string; body: string; From 9f6eabac1ec164a59810f8ca60dc2d7769e2be1f Mon Sep 17 00:00:00 2001 From: ShakeyHands91 <143759757+ShakeyHands91@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:25:48 +0100 Subject: [PATCH 5/5] Add files via upload --- plugins/english/wtrlab.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/plugins/english/wtrlab.ts b/plugins/english/wtrlab.ts index d14b24d1d..0bb43044c 100644 --- a/plugins/english/wtrlab.ts +++ b/plugins/english/wtrlab.ts @@ -84,10 +84,20 @@ class WTRLAB implements Plugin.PluginBase { ? contentUrl : this.site.replace(/\/$/, '') + contentUrl; + // Only ever send the session cookie to the source's own hosts. The server + // controls content_url, and an absolute one could point anywhere. + const hostOf = (value: string) => + (value.match(/^https?:\/\/([^/?#]+)/i)?.[1] || '').toLowerCase(); + const siteHost = hostOf(this.site); + const targetHost = hostOf(url); + const sameSite = + !!siteHost && + (targetHost === siteHost || targetHost.endsWith('.' + siteHost)); + const res = await fetchApi(url, { headers: { 'Accept': 'application/json', - ...(cookie ? { Cookie: cookie } : {}), + ...(cookie && sameSite ? { Cookie: cookie } : {}), }, }); const text = await res.text(); @@ -718,6 +728,7 @@ class WTRLAB implements Plugin.PluginBase { const attemptLog: string[] = []; let parsedJson; + let content: ChapterContent | null = null; let usedType: string | null = null; // Establish a session from the emailed link before asking for a chapter. @@ -784,6 +795,18 @@ class WTRLAB implements Plugin.PluginBase { continue; } + // Resolve the body here, not after the loop: a healthy envelope does + // not guarantee a payload, and if this mode has none the next one + // (typically Web) should still get its turn. + const resolved = await this.resolveChapterContent(candidate, cookie); + if (!resolved || resolved.body === undefined) { + attemptLog.push( + `"${type}": request succeeded but no chapter content was returned`, + ); + continue; + } + + content = resolved; usedType = type; break; } @@ -795,10 +818,6 @@ class WTRLAB implements Plugin.PluginBase { ? `session cookie sent (${cookie.length} chars)` : 'no session cookie set'; - const content = usedType - ? await this.resolveChapterContent(parsedJson, cookie) - : null; - if (!usedType || !content || content.body === undefined) { const errorMsg = `None of the requested translations could be loaded [${cookieState}]. ` +