-
Notifications
You must be signed in to change notification settings - Fork 385
feat(english): add ReChapters source plugin #2613
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
Open
RibatTRW
wants to merge
2
commits into
lnreader:master
Choose a base branch
from
RibatTRW:fm/lnreader-rechapters-2485
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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,309 @@ | ||
| 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<T> = { | ||
| 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?: 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 | ||
| * 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, '>') | ||
| .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<T>(path: string): Promise<T> { | ||
| const res = await fetchApi(SITE + path); | ||
| if (!res.ok) throw new Error('ReChapters API error ' + res.status); | ||
| const json = (await res.json()) as ApiResponse<T>; | ||
| 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<Plugin.NovelItem[]> { | ||
| let query = params + '&pageSize=' + PAGE_SIZE; | ||
| if (pageNo > 1) { | ||
| query += '&cursor=' + offsetCursor((pageNo - 1) * PAGE_SIZE); | ||
| } | ||
| const data = await getApi<SearchData>('/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<typeof this.filters>, | ||
| ): Promise<Plugin.NovelItem[]> { | ||
| 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<Plugin.NovelItem[]> { | ||
| return this.search( | ||
| 'q=' + encodeURIComponent(searchTerm) + '&sort=relevance', | ||
| pageNo, | ||
| ); | ||
| } | ||
|
|
||
| async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> { | ||
| 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 = ldAuthorNames(ld.author); | ||
| 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. <span>Completed</span>. | ||
| 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<BucketsData>( | ||
| '/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<string, boolean> = {}; | ||
| 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<string> { | ||
| 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<ChapterContentData>(base); | ||
| const pages = [first]; | ||
| for (let p = 2; p <= first.pageMetas.length; p++) { | ||
| pages.push(await getApi<ChapterContentData>(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('<p>' + escapeHtml(block.content) + '</p>'); | ||
| } | ||
| } | ||
| } | ||
| return html.join('\n'); | ||
| } | ||
|
|
||
| resolveUrl = (path: string): string => SITE + '/' + path; | ||
| } | ||
|
|
||
| export default new ReChapters(); | ||
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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.
The routine plugin check reads one sample chapter but does not check a fixed two-page response. A later change to
pageMetasorweightPagecould drop the rest of a long chapter while that check still passes. Add a test that verifies text from both pages.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
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.
Not adding a test here: this repo has no unit-test harness for plugins, and building one is out of scope for this PR. I checked the two-page path against the live site instead:
book/shadow-slave-r2k2ivbd6ez4/gpp58s44jcis split over two weight pages (50 + 53 blocks), andparseChapterreturned all 103 paragraphs.