From a613237fdae955f615dba96f5e680b8d14445223 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Wed, 30 Sep 2026 17:25:08 +0800 Subject: [PATCH 1/2] feat(english): add ReChapters source plugin Closes #2485 --- plugins/english/rechapters.ts | 297 +++++++++++++++++++++++ public/static/src/en/rechapters/icon.png | Bin 0 -> 7622 bytes 2 files changed, 297 insertions(+) create mode 100644 plugins/english/rechapters.ts create mode 100644 public/static/src/en/rechapters/icon.png diff --git a/plugins/english/rechapters.ts b/plugins/english/rechapters.ts new file mode 100644 index 000000000..f62d52a8c --- /dev/null +++ b/plugins/english/rechapters.ts @@ -0,0 +1,297 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { load as loadCheerio } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +const SITE = 'https://www.rechapters.com'; +const PAGE_SIZE = 20; +// Chapter-list buckets fetched concurrently per round. +const BUCKET_BATCH = 5; + +type ApiResponse = { + success: boolean; + message?: string; + data: T; +}; + +type SearchItem = { + nanoId: string; + slug: string; + title: string; + coverUrl?: string; + hasCover?: boolean; +}; + +type SearchData = { + items: SearchItem[]; + hasMore: boolean; +}; + +type BucketsData = { + buckets: { index: number }[]; +}; + +type ChapterListItem = { + chapterNanoId: string; + title: string; + orderNum: string; + createdAt: string; +}; + +type ChapterContentData = { + pageBlocks: { block: { type: string; content?: string } }[]; + pageMetas: unknown[]; +}; + +type LdBook = { + name?: string; + description?: string; + image?: string; + author?: { name?: string }[]; +}; + +/** + * The search API pages with an opaque cursor that is base64 of + * `{"offset":N}`; building it directly lets any page be requested without + * walking the previous ones. The payload is ASCII, so this minimal encoder + * is enough (btoa is not guaranteed in the app runtime). + */ +function offsetCursor(offset: number): string { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const input = '{"offset":' + offset + '}'; + let out = ''; + for (let i = 0; i < input.length; i += 3) { + const a = input.charCodeAt(i); + const b = i + 1 < input.length ? input.charCodeAt(i + 1) : NaN; + const c = i + 2 < input.length ? input.charCodeAt(i + 2) : NaN; + const n = (a << 16) | ((b || 0) << 8) | (c || 0); + out += chars[(n >> 18) & 63] + chars[(n >> 12) & 63]; + out += isNaN(b) ? '=' : chars[(n >> 6) & 63]; + out += isNaN(c) ? '=' : chars[n & 63]; + } + return out; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Book paths end in `-<12-char nanoId>`, e.g. `book/shadow-slave-r2k2ivbd6ez4`. */ +function bookNanoId(novelPath: string): string { + const m = /-([23456789a-z]{12})\/?$/.exec(novelPath); + if (!m) throw new Error('Invalid ReChapters novel path: ' + novelPath); + return m[1]; +} + +async function getApi(path: string): Promise { + const res = await fetchApi(SITE + path); + if (!res.ok) throw new Error('ReChapters API error ' + res.status); + const json = (await res.json()) as ApiResponse; + if (!json || !json.success) { + throw new Error((json && json.message) || 'ReChapters API error'); + } + return json.data; +} + +class ReChapters implements Plugin.PluginBase { + id = 'rechapters'; + name = 'ReChapters'; + icon = 'src/en/rechapters/icon.png'; + site = SITE; + version = '1.0.0'; + + filters = { + sort: { + type: FilterTypes.Picker, + label: 'Sort by', + value: 'popularity_score', + options: [ + { label: 'Popularity', value: 'popularity_score' }, + { label: 'Latest update', value: 'last_chapter_at_ts' }, + { label: 'Rating', value: 'rating_average' }, + { label: 'Rating count', value: 'rating_count' }, + { label: 'Chapter count', value: 'chapter_count' }, + { label: 'Word count', value: 'word_count' }, + ], + }, + writing: { + type: FilterTypes.Picker, + label: 'Status', + value: '', + options: [ + { label: 'All', value: '' }, + { label: 'Ongoing', value: '1' }, + { label: 'Completed', value: '2' }, + ], + }, + } satisfies Filters; + + private async search( + params: string, + pageNo: number, + ): Promise { + let query = params + '&pageSize=' + PAGE_SIZE; + if (pageNo > 1) { + query += '&cursor=' + offsetCursor((pageNo - 1) * PAGE_SIZE); + } + const data = await getApi('/api/search?' + query); + return data.items.map(item => ({ + name: item.title, + path: 'book/' + item.slug + '-' + item.nanoId, + cover: item.hasCover && item.coverUrl ? item.coverUrl : defaultCover, + })); + } + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + const sort = showLatestNovels + ? 'last_chapter_at_ts' + : filters?.sort?.value || 'popularity_score'; + let params = 'sort=' + encodeURIComponent(sort); + const writing = filters?.writing?.value; + if (writing) params += '&writing=' + encodeURIComponent(writing); + return this.search(params, pageNo); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + return this.search( + 'q=' + encodeURIComponent(searchTerm) + '&sort=relevance', + pageNo, + ); + } + + async parseNovel(novelPath: string): Promise { + const nanoId = bookNanoId(novelPath); + const res = await fetchApi(this.resolveUrl(novelPath)); + if (!res.ok) throw new Error('Could not load novel: HTTP ' + res.status); + const $ = loadCheerio(await res.text()); + + let ld: LdBook = {}; + $('script[type="application/ld+json"]').each((_i, el) => { + try { + const parsed = JSON.parse($(el).html() || ''); + if (parsed && parsed['@type'] === 'Book') ld = parsed; + } catch { + // ignore unrelated or malformed JSON-LD + } + }); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: $('h1').first().text().trim() || ld.name || 'Untitled', + cover: ld.image || defaultCover, + }; + const authors = (ld.author || []) + .map(a => (a.name || '').trim()) + .filter(Boolean); + if (authors.length) novel.author = authors.join(', '); + if (ld.description) novel.summary = ld.description.trim(); + + const genres = $('ul[aria-label="Book tags"] li[data-tag-item] a') + .map((_i, el) => $(el).attr('aria-label') || $(el).text().trim()) + .get() + .filter(Boolean); + if (genres.length) novel.genres = genres.join(', '); + + // The status is one of the metric pills, e.g. Completed. + const metrics = $('[aria-label="Book metrics"] span') + .map((_i, el) => $(el).text().trim().toLowerCase()) + .get(); + if (metrics.includes('completed')) novel.status = NovelStatus.Completed; + else if (metrics.includes('ongoing')) novel.status = NovelStatus.Ongoing; + else if (metrics.includes('hiatus')) novel.status = NovelStatus.OnHiatus; + else novel.status = NovelStatus.Unknown; + + // The chapter list is split into buckets of ~100 chapters (the last + // bucket absorbs the remainder); each bucket is one request. + const { buckets } = await getApi( + '/api/book/' + nanoId + '/chapters/buckets', + ); + const lists: ChapterListItem[][] = []; + for (let i = 0; i < buckets.length; i += BUCKET_BATCH) { + const batch = await Promise.all( + buckets + .slice(i, i + BUCKET_BATCH) + .map(b => + getApi<{ items: ChapterListItem[] }>( + '/api/book/' + + nanoId + + '/chapters?bucket=' + + b.index + + '&order=asc', + ).then(d => d.items), + ), + ); + lists.push(...batch); + } + + const chapters: Plugin.ChapterItem[] = []; + const seen: Record = {}; + for (const list of lists) { + for (const c of list) { + if (seen[c.chapterNanoId]) continue; + seen[c.chapterNanoId] = true; + const num = parseFloat(c.orderNum); + chapters.push({ + name: c.title, + path: novelPath + '/' + c.chapterNanoId, + releaseTime: c.createdAt, + chapterNumber: isNaN(num) ? undefined : num, + }); + } + } + chapters.sort((a, b) => (a.chapterNumber || 0) - (b.chapterNumber || 0)); + novel.chapters = chapters; + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const parts = chapterPath.split('/'); + const chapterId = parts.pop() || ''; + const nanoId = bookNanoId(parts.join('/')); + const base = + '/api/account/chapter/' + + nanoId + + '/' + + encodeURIComponent(chapterId) + + '/content'; + + // Long chapters are split into several "weight pages" (1-based). + const first = await getApi(base); + const pages = [first]; + for (let p = 2; p <= first.pageMetas.length; p++) { + pages.push(await getApi(base + '?weightPage=' + p)); + } + + // Blocks are plain text, so escaping them is enough to keep markup, + // event handlers and javascript: URLs out of the reader. + const html: string[] = []; + for (const page of pages) { + for (const { block } of page.pageBlocks) { + if (block.type === 'text' && block.content) { + html.push('

' + escapeHtml(block.content) + '

'); + } + } + } + return html.join('\n'); + } + + resolveUrl = (path: string): string => SITE + '/' + path; +} + +export default new ReChapters(); diff --git a/public/static/src/en/rechapters/icon.png b/public/static/src/en/rechapters/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9f5d1b486f9f9c7bc61e6c1bb3d351919c4c3dfe GIT binary patch literal 7622 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 o91x;%<_(wc)qp@f=r Date: Wed, 30 Sep 2026 17:33:44 +0800 Subject: [PATCH 2/2] fix(english/rechapters): accept any JSON-LD author shape --- plugins/english/rechapters.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/plugins/english/rechapters.ts b/plugins/english/rechapters.ts index f62d52a8c..fafbb0934 100644 --- a/plugins/english/rechapters.ts +++ b/plugins/english/rechapters.ts @@ -49,9 +49,23 @@ type LdBook = { name?: string; description?: string; image?: string; - author?: { name?: string }[]; + author?: unknown; }; +/** JSON-LD `author` may be an array, a single Person object, or a string. */ +function ldAuthorNames(author: unknown): string[] { + const list = Array.isArray(author) ? author : author ? [author] : []; + return list + .map(a => { + if (typeof a === 'string') return a.trim(); + if (a && typeof a === 'object' && typeof a.name === 'string') { + return a.name.trim(); + } + return ''; + }) + .filter(Boolean); +} + /** * The search API pages with an opaque cursor that is base64 of * `{"offset":N}`; building it directly lets any page be requested without @@ -196,9 +210,7 @@ class ReChapters implements Plugin.PluginBase { name: $('h1').first().text().trim() || ld.name || 'Untitled', cover: ld.image || defaultCover, }; - const authors = (ld.author || []) - .map(a => (a.name || '').trim()) - .filter(Boolean); + const authors = ldAuthorNames(ld.author); if (authors.length) novel.author = authors.join(', '); if (ld.description) novel.summary = ld.description.trim();