Repository navigation
fix(english): rewrite KDT Novels as standalone plugin for new site #2696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rajarsheechatterjee
merged 2 commits into
lnreader:master
from
RibatTRW:fm/lnreader-kdtnovels-fix
Oct 19, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,326 @@ | ||
| import { CheerioAPI, load as parseHTML } from 'cheerio'; | ||
| import { fetchApi } from '@libs/fetch'; | ||
| import { Plugin } from '@/types/plugin'; | ||
| import { Filters, FilterTypes } from '@libs/filterInputs'; | ||
| import { defaultCover } from '@libs/defaultCover'; | ||
| import { NovelStatus } from '@libs/novelStatus'; | ||
|
|
||
| type ChapterGroup = { | ||
| label: string; | ||
| chapters: { | ||
| slug: string; | ||
| chapter_no: string; | ||
| subtitle?: string | null; | ||
| locked?: boolean; | ||
| }[]; | ||
| }; | ||
|
|
||
| /** | ||
| * KDT Novels replaced its LightNovelWP WordPress theme with its own Next.js | ||
| * site, so it no longer fits the lightnovelwp multisrc template. | ||
| * | ||
| * - Listing/search: `/search/?page=<n>&genre=<slug>&q=<term>`, 24 cards | ||
| * (`a.group`) per page, most recently updated first. | ||
| * - Novel page: `/series/<slug>/`. The HTML only lists the chapters of | ||
| * the expanded volume; every volume's chapters are in the | ||
| * embedded React Server Components payload. | ||
| * - Chapter page: `/<chapter-slug>/`, text in `article.reader-content`. | ||
| * VIP chapters render an unlock prompt instead. | ||
| */ | ||
| class KDTNovels implements Plugin.PluginBase { | ||
| id = 'kdtnovels'; | ||
| name = 'KDT Novels'; | ||
| version = '2.0.0'; | ||
| icon = 'src/en/kdtnovels/icon.png'; | ||
| site = 'https://kdtnovels.net/'; | ||
|
|
||
| private async fetchHtml(url: string): Promise<string> { | ||
| const res = await fetchApi(url); | ||
| // Throw (carrying the HTTP status) so a runner-side block is reported | ||
| // INCONCLUSIVE per docs/testing.md instead of an empty-result FAIL. | ||
| if (!res.ok) { | ||
| throw Object.assign(new Error('Request failed: ' + res.status), { | ||
| status: res.status, | ||
| }); | ||
| } | ||
| return res.text(); | ||
| } | ||
|
|
||
| private async fetchPage(url: string): Promise<CheerioAPI> { | ||
| return parseHTML(await this.fetchHtml(url)); | ||
| } | ||
|
|
||
| private absolute(url: string | undefined): string | undefined { | ||
| if (!url) return undefined; | ||
| return url.startsWith('/') ? this.site + url.slice(1) : url; | ||
| } | ||
|
|
||
| private parseCards($: CheerioAPI): Plugin.NovelItem[] { | ||
| const novels: Plugin.NovelItem[] = []; | ||
| $('a.group[href^="/series/"]').each((_, el) => { | ||
| const card = $(el); | ||
| const title = card.find('h3'); | ||
| novels.push({ | ||
| name: title.attr('title') || title.text().trim(), | ||
| path: card.attr('href')!.slice(1), | ||
| cover: this.absolute(card.find('img').attr('src')) || defaultCover, | ||
| }); | ||
| }); | ||
| return novels; | ||
| } | ||
|
|
||
| async popularNovels( | ||
| pageNo: number, | ||
| { | ||
| showLatestNovels, | ||
| filters, | ||
| }: Plugin.PopularNovelsOptions<typeof this.filters>, | ||
| ): Promise<Plugin.NovelItem[]> { | ||
| let url = this.site + 'search/?page=' + pageNo; | ||
| if (!showLatestNovels && filters.genre.value) { | ||
| url += '&genre=' + filters.genre.value; | ||
| } | ||
| return this.parseCards(await this.fetchPage(url)); | ||
| } | ||
|
|
||
| /** | ||
| * Reads the volume groups from the Next.js flight payload, which the page | ||
| * streams as `self.__next_f.push([1, "<chunk>"])` script calls. | ||
| */ | ||
| private parseChapterGroups(html: string): ChapterGroup[] | undefined { | ||
| const pushRegex = /self\.__next_f\.push\(\[1,("(?:[^"\\]|\\.)*")\]\)/g; | ||
| let flight = ''; | ||
| let match; | ||
| while ((match = pushRegex.exec(html))) { | ||
| flight += JSON.parse(match[1]); | ||
| } | ||
| for (const line of flight.split('\n')) { | ||
| if (!line.includes('"groups":[')) continue; | ||
| let row; | ||
| try { | ||
| row = JSON.parse(line.slice(line.indexOf(':') + 1)); | ||
| } catch { | ||
| continue; // a text row, not a JSON one | ||
| } | ||
| const found = findGroups(row); | ||
| if (found) return found; | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { | ||
| const html = await this.fetchHtml(this.site + novelPath); | ||
| const $ = parseHTML(html); | ||
| const title = $('main h1').first(); | ||
|
|
||
| const novel: Plugin.SourceNovel = { | ||
| path: novelPath, | ||
| name: title.text().trim() || 'Untitled', | ||
| cover: | ||
| this.absolute($('meta[property="og:image"]').attr('content')) || | ||
| defaultCover, | ||
| genres: $('main a[href^="/search/?genre="]') | ||
| .map((_, el) => $(el).text().trim()) | ||
| .get() | ||
| .join(', '), | ||
| }; | ||
|
|
||
| const summary = $('main .reader-content').first(); | ||
| const paragraphs = summary | ||
| .find('p') | ||
| .map((_, el) => $(el).text().trim()) | ||
| .get() | ||
| .filter(Boolean); | ||
| novel.summary = paragraphs.length | ||
| ? paragraphs.join('\n\n') | ||
| : summary.text().trim(); | ||
|
|
||
| // "<author> · Art: <artist>", right below the title; empty when the site | ||
| // has no credits for the series. | ||
| // The view counter that follows is a `.flex` paragraph. | ||
| const credits = title | ||
| .parent() | ||
| .next('p:not(.flex)') | ||
| .text() | ||
| .split(' · Art: '); | ||
| if (credits[0].trim()) novel.author = credits[0].trim(); | ||
| if (credits[1]?.trim()) novel.artist = credits[1].trim(); | ||
|
|
||
| // Only finished or paused series carry a badge next to the title; the | ||
| // site's series index lists the rest as ongoing. | ||
| const badge = title.siblings('span').first().text().trim().toLowerCase(); | ||
| switch (badge) { | ||
| case '': | ||
| novel.status = NovelStatus.Ongoing; | ||
| break; | ||
| case 'completed': | ||
| novel.status = NovelStatus.Completed; | ||
| break; | ||
| case 'hiatus': | ||
| novel.status = NovelStatus.OnHiatus; | ||
| break; | ||
| default: | ||
| novel.status = NovelStatus.Unknown; | ||
| } | ||
|
|
||
| const groups = this.parseChapterGroups(html); | ||
| if (!groups) throw new Error('Could not find the chapter list.'); | ||
| const chapters: Plugin.ChapterItem[] = []; | ||
| for (const group of groups) { | ||
| for (const chapter of group.chapters) { | ||
| let name = group.label + ' · Ch. ' + chapter.chapter_no; | ||
| if (chapter.subtitle) name += ': ' + chapter.subtitle; | ||
| chapters.push({ | ||
| name: chapter.locked ? '🔒 ' + name : name, | ||
| path: chapter.slug + '/', | ||
| chapterNumber: chapters.length + 1, | ||
| }); | ||
| } | ||
| } | ||
| novel.chapters = chapters; | ||
|
|
||
| return novel; | ||
| } | ||
|
|
||
| async parseChapter(chapterPath: string): Promise<string> { | ||
| const $ = await this.fetchPage(this.site + chapterPath); | ||
| const content = $('article.reader-content').first(); | ||
| if (!content.length) { | ||
| if ($('main h2').text().includes('This chapter is locked')) { | ||
| throw new Error( | ||
| 'This chapter is locked. Unlock it on KDT Novels to read it.', | ||
| ); | ||
| } | ||
| throw new Error('Could not find the chapter text.'); | ||
| } | ||
|
|
||
| content.find('script, style, noscript').remove(); | ||
| // Illustrations are placeholders that the site's script swaps for images. | ||
| content.find('.kdt-ilus[data-ilus]').each((_, el) => { | ||
| const src = this.absolute($(el).attr('data-ilus')); | ||
| $(el).replaceWith(src ? $('<img>').attr('src', src) : ''); | ||
| }); | ||
|
|
||
| // The app renders this HTML as is, so drop event handlers and URLs that | ||
| // would run script. | ||
| content.find('*').each((_, el) => { | ||
| const node = $(el); | ||
| for (const name of Object.keys(el.attribs)) { | ||
| if (/^on/i.test(name)) { | ||
| node.removeAttr(name); | ||
| } else if (name === 'href' || name === 'src') { | ||
| const value = el.attribs[name] | ||
| .split('') | ||
| .filter(c => c.charCodeAt(0) > 32) | ||
| .join('') | ||
| .toLowerCase(); | ||
| if ( | ||
| /^(javascript|vbscript):/.test(value) || | ||
| (value.startsWith('data:') && !value.startsWith('data:image/')) | ||
| ) { | ||
| node.removeAttr(name); | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| return content.html() || ''; | ||
| } | ||
|
|
||
| async searchNovels( | ||
| searchTerm: string, | ||
| pageNo: number, | ||
| ): Promise<Plugin.NovelItem[]> { | ||
| const $ = await this.fetchPage( | ||
| this.site + | ||
| 'search/?q=' + | ||
| encodeURIComponent(searchTerm.trim()) + | ||
| '&page=' + | ||
| pageNo, | ||
| ); | ||
| return this.parseCards($); | ||
| } | ||
|
|
||
| resolveUrl = (path: string) => this.site + path; | ||
|
|
||
| filters = { | ||
| genre: { | ||
| label: 'Genre', | ||
| value: '', | ||
| options: [ | ||
| { label: 'All', value: '' }, | ||
| { label: 'Action', value: 'action' }, | ||
| { label: 'Adult', value: 'adult' }, | ||
| { label: 'Adventure', value: 'adventure' }, | ||
| { label: 'Battle Royale', value: 'battle-royale' }, | ||
| { label: 'Childhood Friends', value: 'childhood-friends' }, | ||
| { label: 'Cohabitation', value: 'cohabitation' }, | ||
| { label: 'Comedy', value: 'comedy' }, | ||
| { label: 'Drama', value: 'drama' }, | ||
| { label: 'Ecchi', value: 'ecchi' }, | ||
| { label: 'Fantasy', value: 'fantasy' }, | ||
| { label: 'Gender Bender', value: 'gender-bender' }, | ||
| { label: 'Genderswap', value: 'genderswap' }, | ||
| { label: 'Gore', value: 'gore' }, | ||
| { label: 'Harem', value: 'harem' }, | ||
| { label: 'Horror', value: 'horror' }, | ||
| { label: 'Idol', value: 'idol' }, | ||
| { label: 'Isekai', value: 'isekai' }, | ||
| { label: 'Isekai reincarnation', value: 'isekai-reincarnation' }, | ||
| { label: 'Magic', value: 'magic' }, | ||
| { label: 'Martial Arts', value: 'martial-arts' }, | ||
| { label: 'Mature', value: 'mature' }, | ||
| { label: 'Mecha', value: 'mecha' }, | ||
| { label: 'Monster Girls', value: 'monster-girls' }, | ||
| { label: 'Monsters', value: 'monsters' }, | ||
| { label: 'Mystery', value: 'mystery' }, | ||
| { label: 'Parallel world', value: 'parallel-world' }, | ||
| { label: 'Psychological', value: 'psychological' }, | ||
| { label: 'Psychological drama', value: 'psychological-drama' }, | ||
| { label: 'Reincarnation', value: 'reincarnation' }, | ||
| { label: 'Romance', value: 'romance' }, | ||
| { label: 'Romance school', value: 'romance-school' }, | ||
| { label: 'Romantic Comedy', value: 'romantic-comedy' }, | ||
| { label: 'School', value: 'school' }, | ||
| { label: 'School Life', value: 'school-life' }, | ||
| { label: 'Sci-fi', value: 'sci-fi' }, | ||
| { label: 'Seinen', value: 'seinen' }, | ||
| { label: 'Sexual Violence', value: 'sexual-violence' }, | ||
| { label: 'Shonen', value: 'shonen' }, | ||
| { label: 'Shota', value: 'shota' }, | ||
| { label: 'Shoujo Ai', value: 'shoujo-ai' }, | ||
| { label: 'Shounen', value: 'shounen' }, | ||
| { label: 'Slice of Life', value: 'slice-of-life' }, | ||
| { label: 'Smut', value: 'smut' }, | ||
| { label: 'Supernatural', value: 'supernatural' }, | ||
| { label: 'Survival', value: 'survival' }, | ||
| { label: 'Time Travel', value: 'time-travel' }, | ||
| { label: 'Tragedy', value: 'tragedy' }, | ||
| { label: 'Villainess Otome Game', value: 'villainess-otome-game' }, | ||
| { label: 'Yamikawa', value: 'yamikawa' }, | ||
| { label: 'Youth', value: 'youth' }, | ||
| { label: 'Yuri', value: 'yuri' }, | ||
| ], | ||
| type: FilterTypes.Picker, | ||
| }, | ||
| } satisfies Filters; | ||
| } | ||
|
|
||
| /** Finds the first `groups` array of chapter groups in a flight row. */ | ||
| function findGroups(node: unknown): ChapterGroup[] | undefined { | ||
| if (!node || typeof node !== 'object') return undefined; | ||
| const record = node as Record<string, unknown>; | ||
| const groups = record.groups; | ||
| if ( | ||
| Array.isArray(groups) && | ||
| groups.every(group => Array.isArray(group?.chapters)) | ||
| ) { | ||
| return groups as ChapterGroup[]; | ||
| } | ||
| for (const key in record) { | ||
| const found = findGroups(record[key]); | ||
| if (found) return found; | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| export default new KDTNovels(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parseChapterdrops the event-handler and script-URL checks that the previousLightNovelWPPluginapplied. A malicious or compromised chapter page can now return<img src=x onerror="alert(1)">unchanged. The playground inserts that HTML withdangerouslySetInnerHTML, allowing it to run code inside the playground page.The
data-ilusreplacement also turns encoded quotes into executable attributes:x" onerror="alert(1)becomes<img src="x" onerror="alert(1)">.Restore the previous checks after replacing illustrations. Build each image with
.attr('src', src)rather than inserting the value into HTML.How this was verified: The old template removes event attributes and script URLs, while the new method returns them to a page that inserts the result directly with
dangerouslySetInnerHTML.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in b9ca6e5.
parseChapternow builds each illustration with$('<img>').attr('src', src)instead of interpolatingdata-ilusinto an HTML string. After that it applies the same attribute sanitizing as the LightNovelWP template: it removeson*attributes, and it removeshref/srcvalues that start withjavascript:orvbscript:(control characters and spaces are stripped first) or that aredata:URLs not of an image type.I checked this by running
parseChapteron a mock chapter page containing<img src=x onerror="alert(2)">,javascript:and tab-obfuscatedJaVa Script:links, adata:text/htmllink, anonloadon adata:imageimg, anonclickparagraph, anddata-ilus="x" onerror="alert(1)". The previous commit returned 7 handlers or script URLs; this one returns none. The encoded-quote value now ends up only as the literalsrctext. Real chapters are unchanged: the live check passes, and text, illustration and locked chapters behave as before.