From 094a198cd3f16b59b4ded19e60dd75d6968203e1 Mon Sep 17 00:00:00 2001 From: Gourab0002 Date: Sat, 24 Oct 2026 21:32:03 +0530 Subject: [PATCH 1/2] feat(russian): add HexNovels New source for hexnovels.me (TanStack Start SSR). Novel lists, search, novel info and the full chapter list are read from the server-rendered page and its embedded router state; chapter text from .typeset-content. Site pages are 0-based. With several translation branches, the branch with the most chapters is used. Closes #2709 Co-Authored-By: Grok Bot (AI agent) --- plugins/russian/hexnovels.ts | 148 ++++++++++++++++++++++++ public/static/src/ru/hexnovels/icon.png | Bin 0 -> 3958 bytes 2 files changed, 148 insertions(+) create mode 100644 plugins/russian/hexnovels.ts create mode 100644 public/static/src/ru/hexnovels/icon.png diff --git a/plugins/russian/hexnovels.ts b/plugins/russian/hexnovels.ts new file mode 100644 index 000000000..eecc6fc39 --- /dev/null +++ b/plugins/russian/hexnovels.ts @@ -0,0 +1,148 @@ +import { load as parseHTML } from 'cheerio'; +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; + +type ChapterData = { + id: string; + title: string; + number: number; + volume: number; + branchId: string; + createdAt: string; +}; + +class HexNovels implements Plugin.PluginBase { + id = 'hexnovels'; + name = 'HexNovels'; + icon = 'src/ru/hexnovels/icon.png'; + site = 'https://hexnovels.me'; + version = '1.0.0'; + + private async fetchHtml(url: string): Promise { + const res = await fetchApi(url); + if (!res.ok) throw new Error(`Could not reach site (${res.status})`); + return res.text(); + } + + private parseNovels(html: string): Plugin.NovelItem[] { + const $ = parseHTML(html); + const novels: Plugin.NovelItem[] = []; + const seen = new Set(); + $('a[href^="/content/"]').each((_, el) => { + const a = $(el); + const path = a.attr('href')?.substring(1); + const name = a.find('p.line-clamp-2').text().trim(); + const cover = a.find('img').attr('src'); + if (!path || !name || !cover || path.split('/').length !== 2) return; + if (seen.has(path)) return; + seen.add(path); + novels.push({ name, path, cover }); + }); + return novels; + } + + async popularNovels(pageNo: number): Promise { + return this.parseNovels( + // The site's pages are 0-based + await this.fetchHtml(`${this.site}/content?page=${pageNo - 1}`), + ); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const params = new URLSearchParams({ + search: searchTerm, + page: (pageNo - 1).toString(), + }); + return this.parseNovels( + await this.fetchHtml(`${this.site}/content?${params.toString()}`), + ); + } + + async parseNovel(novelPath: string): Promise { + const html = await this.fetchHtml(`${this.site}/${novelPath}`); + const $ = parseHTML(html); + const slug = novelPath.split('/')[1]; + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: $('h1').first().text().trim(), + cover: $('meta[property="og:image"]').attr('content'), + summary: $('meta[name="description"]').attr('content')?.trim(), + status: NovelStatus.Unknown, + chapters: [], + }; + + // Book data is embedded in the page's serialized router state + const bookStart = html.indexOf(`slug:"${slug}",type:`); + if (bookStart !== -1) { + const status = html + .substring(bookStart, bookStart + 4000) + .match(/status:"(\w+)"/)?.[1]; + novel.status = + status === 'ONGOING' + ? NovelStatus.Ongoing + : status === 'DONE' + ? NovelStatus.Completed + : status === 'FROZEN' + ? NovelStatus.OnHiatus + : NovelStatus.Unknown; + } + + // All chapters (for every translation branch) are embedded in the chapters page + const chaptersHtml = await this.fetchHtml( + `${this.site}/${novelPath}/chapters`, + ); + const unescape = (s: string) => JSON.parse(`"${s}"`) as string; + const re = + /\{id:"([0-9a-f-]{36})",name:"(?:[^"\\]|\\.)*",title:"((?:[^"\\]|\\.)*)",number:([0-9.]+),volume:(\d+)[^}]*?branchId:"([0-9a-f-]{36})"[^}]*?createdAt:"([^"]+)"/g; + const byId = new Map(); + for (const m of chaptersHtml.matchAll(re)) { + byId.set(m[1], { + id: m[1], + title: unescape(m[2]), + number: parseFloat(m[3]), + volume: parseInt(m[4], 10), + branchId: m[5], + createdAt: m[6], + }); + } + + // Several translation branches can exist; use the one with the most chapters + const branches = new Map(); + for (const ch of byId.values()) { + const list = branches.get(ch.branchId) || []; + list.push(ch); + branches.set(ch.branchId, list); + } + const chapters = [...branches.values()].sort( + (a, b) => b.length - a.length, + )[0]; + + novel.chapters = (chapters || []) + .sort((a, b) => a.volume - b.volume || a.number - b.number) + .map((ch, i) => ({ + name: `Том ${ch.volume}. ${ch.title}`, + path: `${novelPath}/${ch.id}`, + releaseTime: ch.createdAt, + chapterNumber: i + 1, + })); + + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const $ = parseHTML(await this.fetchHtml(`${this.site}/${chapterPath}`)); + const content = $('.typeset-content').first(); + // Illustration-only chapters load their (encrypted) images client-side + if (!content.text().trim() && !content.find('img').length) { + return '

Эта глава содержит только иллюстрации, они недоступны в приложении.

'; + } + return content.html() || ''; + } +} + +export default new HexNovels(); diff --git a/public/static/src/ru/hexnovels/icon.png b/public/static/src/ru/hexnovels/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b0576ef5753cce28242ea722eb593d258d9c8607 GIT binary patch literal 3958 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 O8la`7r&_II6ZwB4b(A>( literal 0 HcmV?d00001 From b93aa8e84af34fd1261e661591d4256f7137f663 Mon Sep 17 00:00:00 2001 From: Gourab0002 Date: Sat, 24 Oct 2026 21:37:59 +0530 Subject: [PATCH 2/2] fix(russian/hexnovels): avoid iterator loops in the ES5 build build:compile targets ES5 without downlevelIteration, so for...of over matchAll()/Map.values() and spreading Map.values() compiled to .length loops that never ran, leaving parseNovel with no chapters. Wrap them in Array.from(). Also throw when the chapter container is missing instead of returning the illustration notice. Co-Authored-By: Grok Bot (AI agent) --- plugins/russian/hexnovels.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/russian/hexnovels.ts b/plugins/russian/hexnovels.ts index eecc6fc39..1abd8f2e7 100644 --- a/plugins/russian/hexnovels.ts +++ b/plugins/russian/hexnovels.ts @@ -100,7 +100,7 @@ class HexNovels implements Plugin.PluginBase { const re = /\{id:"([0-9a-f-]{36})",name:"(?:[^"\\]|\\.)*",title:"((?:[^"\\]|\\.)*)",number:([0-9.]+),volume:(\d+)[^}]*?branchId:"([0-9a-f-]{36})"[^}]*?createdAt:"([^"]+)"/g; const byId = new Map(); - for (const m of chaptersHtml.matchAll(re)) { + for (const m of Array.from(chaptersHtml.matchAll(re))) { byId.set(m[1], { id: m[1], title: unescape(m[2]), @@ -113,12 +113,12 @@ class HexNovels implements Plugin.PluginBase { // Several translation branches can exist; use the one with the most chapters const branches = new Map(); - for (const ch of byId.values()) { + for (const ch of Array.from(byId.values())) { const list = branches.get(ch.branchId) || []; list.push(ch); branches.set(ch.branchId, list); } - const chapters = [...branches.values()].sort( + const chapters = Array.from(branches.values()).sort( (a, b) => b.length - a.length, )[0]; @@ -137,6 +137,7 @@ class HexNovels implements Plugin.PluginBase { async parseChapter(chapterPath: string): Promise { const $ = parseHTML(await this.fetchHtml(`${this.site}/${chapterPath}`)); const content = $('.typeset-content').first(); + if (!content.length) throw new Error('Chapter content not found'); // Illustration-only chapters load their (encrypted) images client-side if (!content.text().trim() && !content.find('img').length) { return '

Эта глава содержит только иллюстрации, они недоступны в приложении.

';