From 649bea35461d86af7e255d71dc7f27006803e0ad Mon Sep 17 00:00:00 2001 From: Vaibhav <183315953+D3ICIDE@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:26:19 +0530 Subject: [PATCH] (fix) Separate plugin for Hangul scans (#2562) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- plugins/english/HangulPlanet.ts | 223 ++++++++++++++++++ plugins/multisrc/madara/sources.json | 9 +- .../madara => src/en}/hangulplanet/icon.png | Bin 3 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 plugins/english/HangulPlanet.ts rename public/static/{multisrc/madara => src/en}/hangulplanet/icon.png (100%) diff --git a/plugins/english/HangulPlanet.ts b/plugins/english/HangulPlanet.ts new file mode 100644 index 0000000..45027bf --- /dev/null +++ b/plugins/english/HangulPlanet.ts @@ -0,0 +1,223 @@ +import { fetchApi } from '@libs/fetch'; +import { Filters } from '@libs/filterInputs'; +import { Plugin } from '@/types/plugin'; +import { CheerioAPI, load as parseHTML } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +class HangulPlanetPlugin implements Plugin.PluginBase { + id = 'hangulplanet'; + name = 'HangulPlanet'; + icon = 'src/en/hangulplanet/icon.png'; + site = 'https://hangulplanet.com'; + version = '2.3.0'; + + private async fetchPage( + url: string, + search = false, + ): Promise<{ $: CheerioAPI; text: string }> { + const r = await fetchApi(url); + if (!r.ok && !search) { + throw new Error( + 'Could not reach site (' + r.status + ') try to open in webview.', + ); + } + const text = await r.text(); + console.log('response byte length:', text.length); + const $ = parseHTML(text); + const title = $('title').text().trim(); + if ( + title === 'Bot Verification' || + title === 'You are being redirected...' || + title === 'Un instant...' || + title === 'Just a moment...' || + title === 'Redirecting...' + ) { + throw new Error('Captcha error, please open in webview'); + } + return { $, text }; + } + + async getCheerio(url: string, search = false): Promise { + const { $ } = await this.fetchPage(url, search); + return $; + } + + private parseNovelCards($: CheerioAPI): Plugin.NovelItem[] { + const novels: Plugin.NovelItem[] = []; + $('a[href^="/novel/"]').each((_, el) => { + const href = $(el).attr('href') || ''; + const img = $(el).find('img.object-cover').first(); + const name = img.attr('alt')?.trim() || ''; + const src = img.attr('src'); + const cover = src ? this.site + src : defaultCover; + if (!href || !name) return; + novels.push({ name, cover, path: href }); + }); + return novels; + } + + async popularNovels( + pageNo: number, + { showLatestNovels }: Plugin.PopularNovelsOptions, + ): Promise { + if (pageNo > 1) return []; + + const sort = showLatestNovels ? 'latest' : 'popular'; + const $ = await this.getCheerio(`${this.site}/browse?sort=${sort}`); + return this.parseNovelCards($); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (pageNo > 1) return []; + const url = `${this.site}/browse?q=${encodeURIComponent(searchTerm)}`; + const $ = await this.getCheerio(url, true); + return this.parseNovelCards($); + } + + /** + IMPORTANT: this site does NOT render its full chapter list as real + DOM elements. Beyond a small SSR-visible slice, the list lives + inside a Next.js RSC ("flight") payload + */ + private parseChaptersFromText( + novelPath: string, + text: string, + chapters: Plugin.ChapterItem[], + seen: Set, + ): number { + let added = 0; + + // Primary: escaped-JSON form from the RSC payload. + const hrefPattern = /\\"href\\":\\"([^"\\]*\/chapter-(\d+))\\"/g; + const WINDOW = 800; + let match: RegExpExecArray | null; + while ((match = hrefPattern.exec(text)) !== null) { + const chapterNumber = parseInt(match[2], 10); + if (seen.has(chapterNumber)) continue; + seen.add(chapterNumber); + + const windowText = text.slice(match.index, match.index + WINDOW); + + const nameMatch = windowText.match( + /\\"line-clamp-1 flex-1 text-sm\\",\\"children\\":\\"([^\\]*)\\"/, + ); + const dateMatch = windowText.match(/\\"dateTime\\":\\"([^\\]*)\\"/); + + chapters.push({ + name: nameMatch ? nameMatch[1] : `Chapter ${chapterNumber}`, + path: match[1], + chapterNumber, + releaseTime: dateMatch ? dateMatch[1] : null, + }); + added++; + } + + const $ = parseHTML(text); + $('#chapters a[href*="/chapter-"]').each((_, el) => { + const href = $(el).attr('href') || ''; + const chapterNumMatch = href.match(/chapter-(\d+)$/); + if (!href || !chapterNumMatch) return; + const chapterNumber = parseInt(chapterNumMatch[1], 10); + if (seen.has(chapterNumber)) return; + seen.add(chapterNumber); + const name = $(el).find('.line-clamp-1').text().trim(); + const releaseTime = $(el).find('time').attr('datetime') || null; + chapters.push({ + name: name || `Chapter ${chapterNumber}`, + path: href, + chapterNumber, + releaseTime, + }); + added++; + }); + + return added; + } + + async parseNovel(novelPath: string): Promise { + const { $, text } = await this.fetchPage(this.site + novelPath); + + const coverSrc = $('img.object-cover').first().attr('src'); + const heading = $('h1').first(); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: heading.text().trim(), + author: heading.next('p').text().trim() || undefined, + cover: coverSrc ? this.site + coverSrc : defaultCover, + summary: $('.prose').first().text().trim(), + status: NovelStatus.Unknown, + }; + + const statusText = $('span[data-slot="badge"]').first().text().trim(); + novel.status = statusText.includes('Ongoing') + ? NovelStatus.Ongoing + : statusText.includes('Completed') + ? NovelStatus.Completed + : NovelStatus.Unknown; + + novel.genres = $('a[href*="browse?genre="]') + .map((_, el) => $(el).text().trim()) + .get() + .join(', '); + + const chapters: Plugin.ChapterItem[] = []; + const seen = new Set(); + + const foundOnPage1 = this.parseChaptersFromText( + novelPath, + text, + chapters, + seen, + ); + console.log('chapters found on page 1 (RSC + DOM):', foundOnPage1); + + const MAX_PAGES = 100; // safety cap so a parsing quirk can't loop forever + let page = 2; + while (page <= MAX_PAGES) { + const { text: pageText } = await this.fetchPage( + `${this.site}${novelPath}?cpage=${page}`, + ); + const added = this.parseChaptersFromText( + novelPath, + pageText, + chapters, + seen, + ); + console.log(`cpage=${page} contributed ${added} new chapters`); + if (added === 0) break; + page++; + } + + console.log('total chapter links found:', chapters.length); + + novel.chapters = chapters.sort( + (a, b) => (a.chapterNumber ?? 0) - (b.chapterNumber ?? 0), + ); + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const $ = await this.getCheerio(this.site + chapterPath); + + const content = $('article[data-reader-article="true"] .reader-prose'); + + //Remove the TL/ED credit(annoying for TTS) + content + .find('p') + .first() + .each((_, el) => { + if ($(el).text().trim().startsWith('TL/ED')) { + $(el).remove(); + } + }); + + return content.html() || ''; + } +} + +export default new HangulPlanetPlugin(); diff --git a/plugins/multisrc/madara/sources.json b/plugins/multisrc/madara/sources.json index 7258a11..12ad730 100644 --- a/plugins/multisrc/madara/sources.json +++ b/plugins/multisrc/madara/sources.json @@ -661,14 +661,7 @@ "useNewChapterEndpoint": true } }, - { - "id": "hangulplanet", - "sourceSite": "https://hangulplanet.com/", - "sourceName": "HangulPlanet", - "options": { - "useNewChapterEndpoint": true - } - }, + { "id": "lovelyblossoms", "sourceSite": "https://lovelyblossoms.com/", diff --git a/public/static/multisrc/madara/hangulplanet/icon.png b/public/static/src/en/hangulplanet/icon.png similarity index 100% rename from public/static/multisrc/madara/hangulplanet/icon.png rename to public/static/src/en/hangulplanet/icon.png