From 7fac2a4acaabc0c1137c2e3a021a8dc897301ee2 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sun, 25 Oct 2026 13:58:33 +0800 Subject: [PATCH 1/5] feat(english/novelito): add Novelito source Adds the Novelito (noveli.to) plugin with popular browsing, search, novel details, the full chapter list and chapter text from the site's JSON API. Co-Authored-By: OpenCode --- plugins/english/novelito.ts | 479 +++++++++++++++++++++++++ public/static/src/en/novelito/icon.png | Bin 0 -> 9122 bytes 2 files changed, 479 insertions(+) create mode 100644 plugins/english/novelito.ts create mode 100644 public/static/src/en/novelito/icon.png diff --git a/plugins/english/novelito.ts b/plugins/english/novelito.ts new file mode 100644 index 000000000..46bc2a9da --- /dev/null +++ b/plugins/english/novelito.ts @@ -0,0 +1,479 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { NovelStatus } from '@libs/novelStatus'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { defaultCover } from '@libs/defaultCover'; + +// Novelito -- https://noveli.to +// A public library front-end with a plain JSON API behind the SPA. No +// account, cookie or JS challenge is needed for the catalogue, the +// search, the novel detail, the chapter list or the chapter text: +// GET /api/novels catalogue (paged + filtered) +// GET /api/novels/ novel detail +// GET /api/novels//chapters full chapter list +// GET /api/novels//chapters/ chapter paragraphs +// The chapter endpoint refuses requests without browser-like headers +// (403 "direct chapter extraction is not permitted"), so every request +// sends a browser User-Agent and a site Referer, as the site's own +// reader does. + +const SITE = 'https://noveli.to'; +const API = SITE + '/api'; + +/** Same page size the site itself uses for its browse grid. */ +const PAGE_SIZE = 36; + +/** + * Upper bound on a response this plugin will parse. The largest + * payload is a full chapter list (~800 KB for a 3,200-chapter novel); + * anything far above legitimate responses is broken or hostile, and + * parsing it would bill the reader's phone. + */ +const MAX_RESPONSE_CHARS = 8000000; + +const BROWSER_HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + 'Accept': 'application/json, text/plain, */*', + 'Referer': SITE + '/', +}; + +/** + * Error with the HTTP status attached. Tooling (e.g. the repo's live + * check) reads `error.status` to tell a site block or outage (403/429/503) + * apart from a plugin bug, so always throw fetch failures this way. + */ +function httpError(message: string, status: number): Error { + const err = new Error(message) as Error & { status?: number }; + err.status = status; + return err; +} + +async function fetchJson(url: string, what: string): Promise { + const res = await fetchApi(url, { headers: BROWSER_HEADERS }); + if (!res.ok) { + throw httpError(`${what}: HTTP ${res.status}`, res.status); + } + const text = await res.text(); + if (text.length > MAX_RESPONSE_CHARS) { + throw new Error(`${what}: response too large (${text.length} chars)`); + } + let data: unknown; + try { + data = JSON.parse(text); + } catch { + throw new Error(`${what}: response was not valid JSON`); + } + if (!data || typeof data !== 'object') { + throw new Error(`${what}: response had an unexpected shape`); + } + return data; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asString(value: unknown, what: string): string { + if (typeof value !== 'string') { + throw new Error('Novelito response is missing a valid ' + what); + } + return value; +} + +function asNumber(value: unknown, what: string): number { + if (typeof value !== 'number' || isNaN(value) || !isFinite(value)) { + throw new Error('Novelito response is missing a valid ' + what); + } + return value; +} + +function decodeEntities(text: string): string { + return text + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/�?39;/gi, "'"); +} + +/** Novel descriptions are sometimes plain text, sometimes HTML. */ +function plainText(raw: string): string { + const withoutTags = raw + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<[^>]*>/g, ''); + return decodeEntities(withoutTags) + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .replace(/^\s+|\s+$/g, ''); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Covers come back either as an absolute URL or as a path below the +// site root, and are occasionally empty. +function coverUrl(value: unknown): string | undefined { + if (typeof value !== 'string' || value === '') return undefined; + if (/^https?:\/\//i.test(value)) return value; + if (value.indexOf('//') === 0) return 'https:' + value; + return SITE + (value.charAt(0) === '/' ? value : '/' + value); +} + +function novelPath(novelId: number): string { + return '/novel/' + novelId; +} + +function chapterPath(novelId: number, chapterId: number): string { + return '/novel/' + novelId + '/chapter/' + chapterId; +} + +function stripQuery(path: string): string { + const query = path.indexOf('?'); + return query === -1 ? path : path.slice(0, query); +} + +function parseIdSegment(segment: string, what: string): number { + if (!/^\d+$/.test(segment)) { + throw new Error('Invalid Novelito ' + what + ' in path'); + } + const value = parseInt(segment, 10); + if (isNaN(value) || !isFinite(value)) { + throw new Error('Invalid Novelito ' + what + ' in path'); + } + return value; +} + +function parseNovelPath(path: string): number { + const parts = stripQuery(path).split('/'); + if (parts.length !== 3 || parts[0] !== '' || parts[1] !== 'novel') { + throw new Error('Invalid Novelito novel path: ' + path); + } + return parseIdSegment(parts[2], 'novel id'); +} + +function parseChapterPath(path: string): { + novelId: number; + chapterId: number; +} { + const parts = stripQuery(path).split('/'); + if ( + parts.length !== 5 || + parts[0] !== '' || + parts[1] !== 'novel' || + parts[3] !== 'chapter' + ) { + throw new Error('Invalid Novelito chapter path: ' + path); + } + return { + novelId: parseIdSegment(parts[2], 'novel id'), + chapterId: parseIdSegment(parts[4], 'chapter id'), + }; +} + +// The list endpoint reports lowercase statuses ("ongoing", +// "completed"), the detail endpoint title-cases them. Anything else +// maps to Unknown, never to a guessed status. +function mapStatus(siteStatus: string): string { + const value = siteStatus.toLowerCase(); + if (value === 'ongoing' || value === 'releasing') return NovelStatus.Ongoing; + if (value === 'completed') return NovelStatus.Completed; + if (value === 'hiatus' || value === 'on hiatus' || value === 'paused') { + return NovelStatus.OnHiatus; + } + if (value === 'dropped' || value === 'cancelled' || value === 'canceled') { + return NovelStatus.Cancelled; + } + return NovelStatus.Unknown; +} + +function toNovelItem(record: unknown): Plugin.NovelItem { + if (!isRecord(record)) { + throw new Error('Novelito novel entry is not an object'); + } + const id = asNumber(record['id'], 'novel id'); + return { + name: asString(record['title'], 'novel title'), + path: novelPath(id), + cover: coverUrl(record['cover_image']) || defaultCover, + }; +} + +async function fetchNovelList( + params: Record, +): Promise { + const query = Object.keys(params) + .map(key => key + '=' + encodeURIComponent(params[key])) + .join('&'); + const data = await fetchJson( + `${API}/novels?${query}`, + 'Failed to load novels', + ); + const res = data as { success?: unknown; data?: unknown }; + if (res.success !== true || !Array.isArray(res.data)) { + throw new Error('Novelito novel list is missing its data array'); + } + return res.data; +} + +function parseChapters(data: unknown, novelId: number): Plugin.ChapterItem[] { + const res = data as { chapters?: unknown }; + if (!Array.isArray(res.chapters)) { + throw new Error('Novelito chapter list is missing its chapters array'); + } + const chapters: Plugin.ChapterItem[] = []; + const seen: Record = {}; + for (const entry of res.chapters) { + if (!isRecord(entry)) { + throw new Error('Novelito chapter entry is not an object'); + } + const id = asNumber(entry['id'], 'chapter id'); + if (seen[String(id)]) continue; + seen[String(id)] = true; + const number = entry['chapter_number']; + const chapterNumber = + typeof number === 'number' && isFinite(number) ? number : undefined; + const rawTitle = typeof entry['title'] === 'string' ? entry['title'] : ''; + const title = rawTitle.replace(/^\s+|\s+$/g, ''); + const chapter: Plugin.ChapterItem = { + name: + title !== '' + ? title + : chapterNumber !== undefined + ? 'Chapter ' + chapterNumber + : 'Chapter', + path: chapterPath(novelId, id), + }; + if (chapterNumber !== undefined) chapter.chapterNumber = chapterNumber; + const createdAt = entry['created_at']; + if (typeof createdAt === 'string' && /^\d{4}-\d{2}-\d{2}/.test(createdAt)) { + chapter.releaseTime = createdAt.slice(0, 10); + } + chapters.push(chapter); + } + // The API returns chapters in reading order already; sorting here + // keeps the list correct even if a volume-based response interleaves + // them. + const order: Record = {}; + for (let i = 0; i < chapters.length; i++) { + const n = chapters[i].chapterNumber; + order[chapters[i].path] = n === undefined ? i : n; + } + chapters.sort((a, b) => order[a.path] - order[b.path]); + return chapters; +} + +function parseChapterBody(data: unknown): string { + const res = data as { chapter?: unknown }; + if (!isRecord(res.chapter)) { + throw new Error('Novelito chapter response is missing its chapter'); + } + const paragraphs = res.chapter['paragraphs']; + if (!Array.isArray(paragraphs)) { + throw new Error('Novelito chapter is missing its paragraphs array'); + } + const parts: string[] = []; + for (const paragraph of paragraphs) { + if (!isRecord(paragraph)) continue; + const html = typeof paragraph['html'] === 'string' ? paragraph['html'] : ''; + // The site injects a "Provided by Noveli.to" notice at the start + // and a support line at the end of every chapter; it also hides a + // couple of site notices inside the payload. None of them are part + // of the story. + if ( + paragraph['isHoneypot'] === true || + paragraph['isMidWatermark'] === true || + html.indexOf('watermark-notice') !== -1 || + html.indexOf('noveli-canary') !== -1 + ) { + continue; + } + const text = + typeof paragraph['text'] === 'string' + ? paragraph['text'] + : plainText(html); + const trimmed = text.replace(/^\s+|\s+$/g, ''); + if (trimmed === '') continue; + parts.push('

' + escapeHtml(trimmed) + '

'); + } + if (parts.length === 0) { + throw new Error('Novelito chapter has no readable content'); + } + return parts.join('\n'); +} + +class Novelito implements Plugin.PluginBase { + id = 'novelito'; + name = 'Novelito'; + icon = 'src/en/novelito/icon.png'; + site = SITE; + version = '1.0.0'; + + filters = { + sort: { + label: 'Sort Results By', + value: 'popular', + options: [ + { label: 'Popular', value: 'popular' }, + { label: 'Trending', value: 'trending' }, + { label: 'Top Rated', value: 'rating' }, + { label: 'Most Viewed', value: 'views' }, + ], + type: FilterTypes.Picker, + }, + status: { + label: 'Status', + value: 'All', + options: [ + { label: 'All', value: 'All' }, + { label: 'Ongoing', value: 'Ongoing' }, + { label: 'Completed', value: 'Completed' }, + ], + type: FilterTypes.Picker, + }, + genres: { + label: 'Genres', + value: [], + options: [ + { label: 'LitRPG', value: 'LitRPG' }, + { label: 'Progression', value: 'Progression' }, + { label: 'Fantasy', value: 'Fantasy' }, + { label: 'Action', value: 'Action' }, + { label: 'Adventure', value: 'Adventure' }, + { label: 'Cultivation', value: 'Cultivation' }, + { label: 'Martial Arts', value: 'Martial Arts' }, + { label: 'Sci-Fi', value: 'Sci-Fi' }, + { label: 'Time Loop', value: 'Time Loop' }, + { label: 'Comedy', value: 'Comedy' }, + { label: 'Romance', value: 'Romance' }, + ], + type: FilterTypes.CheckboxGroup, + }, + } satisfies Filters; + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + const params: Record = { + page: String(pageNo), + limit: String(PAGE_SIZE), + sort: showLatestNovels ? 'newest' : filters.sort.value, + }; + if (filters.status.value !== 'All') { + params.status = filters.status.value; + } + const genres = filters.genres.value.filter(genre => genre !== ''); + if (genres.length > 0) { + params.genres = genres.join(','); + } + const novels = await fetchNovelList(params); + return novels.map(toNovelItem); + } + + async parseNovel(novelPathParam: string): Promise { + const novelId = parseNovelPath(novelPathParam); + const detail = await fetchJson( + `${API}/novels/${novelId}`, + 'Failed to load novel', + ); + const record = detail as { data?: unknown }; + if (!isRecord(record.data)) { + throw new Error('Novelito novel detail is missing its data object'); + } + const info = record.data; + + const chapterList = await fetchJson( + `${API}/novels/${novelId}/chapters`, + 'Failed to load chapter list', + ); + + const novel: Plugin.SourceNovel = { + path: novelPathParam, + name: asString(info['title'], 'novel title'), + chapters: parseChapters(chapterList, novelId), + }; + if (typeof info['author'] === 'string' && info['author'] !== '') { + novel.author = info['author']; + } + const cover = coverUrl(info['cover_image']); + novel.cover = cover || defaultCover; + if (typeof info['description'] === 'string') { + const summary = plainText(info['description']); + if (summary !== '') novel.summary = summary; + } + if (Array.isArray(info['genres'])) { + const genres: string[] = []; + for (const genre of info['genres']) { + if (typeof genre === 'string' && genre !== '') genres.push(genre); + } + if (genres.length > 0) novel.genres = genres.join(', '); + } + if (typeof info['status'] === 'string') { + novel.status = mapStatus(info['status']); + } + const rating = info['rating']; + if (typeof rating === 'number' && isFinite(rating) && rating > 0) { + novel.rating = rating; + } + return novel; + } + + // The app opens a novel already saved in its database but with no + // stored chapters through parsePage, not parseNovel. This plugin has + // no server-side chapter paging, so every page is the whole list. + async parsePage( + novelPathParam: string, + page: string, + ): Promise { + void page; + const novel = await this.parseNovel(novelPathParam); + return { chapters: novel.chapters || [] }; + } + + async parseChapter(path: string): Promise { + const parsed = parseChapterPath(path); + const data = await fetchJson( + `${API}/novels/${parsed.novelId}/chapters/${parsed.chapterId}`, + 'Failed to load chapter', + ); + return parseChapterBody(data); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const term = searchTerm.replace(/^\s+|\s+$/g, ''); + if (term === '') return []; + const novels = await fetchNovelList({ + search: term, + page: String(pageNo), + limit: String(PAGE_SIZE), + }); + return novels.map(toNovelItem); + } + + // resolveUrl MUST pass absolute URLs through unchanged: the app + // passes novel cover URLs through here too (EPUB export), and + // prefixing the site onto an absolute cover breaks it. Plugin paths + // mirror the site's own routes, so they work unchanged. + resolveUrl = (path: string) => { + if (/^https?:\/\//i.test(path)) return path; + if (path.indexOf('//') === 0) return 'https:' + path; + return SITE + path; + }; +} + +export default new Novelito(); diff --git a/public/static/src/en/novelito/icon.png b/public/static/src/en/novelito/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..32550f5e46aeea79e7e3c175e343d4e21d339764 GIT binary patch literal 9122 zcmV;TBVF8yP)Xurg+p;FGV+&&nCwPD& z1cwAeAeab3Yz%~of*}E_5+y0ei8EDFU;`;^hYE>FYy;(jlm|eJiVd<|NV46srnaSS zsoit;>wfRvv)9TW`|LB^so!f^u2`$<^t)%cd$0Xl;~vhw!UpN+Juknt68TwoeWRfR zBj70JiW4w2oztGry@%qiH03b%?7Vv@=Ckj)ck0;4JwnZMs(bdmhPR=kzcF(CH~-<6 zZ~g2>@RapF_@#U9+^3ZP-q7!HR4)ba`JuB?!Nm%^2GEgCgC$*aZNBHdhHBH$-Zd1O z#!p^L=Yltaksfbe-?RE2&Apa4cWJ;^U7#$ND9bIg!0!siO!WOlVL$!OUwHA6uZRE~ z< z%G75sK}9IozPyiJ+pcB*6}NNE-j}g``P!*g;e@m1@^k;?R}Q=jWNt-s1@NVNZrvFe zyvGS|=$So%l?s0Iku&_(p)-8$#KjFYW=i!g`Lj&k+W^axkBB0CzKC0H z{5B5W@EsIk(}Zg^!f&~be)!$*K5%MQ88ch+%-im`-eUNO6K?K;f3Z^D_t0s6?V%H# zUX7Qvy#;+j7?&rA#f5F$dCT{4`@tVT!a`3FxIW@qe(Nv& z(vBymeRpbWj^14go+kMaS@h8u3n3gx>_V!vnmEQH4?9QQS zR^S=i?laP}hwe3hZm#LtYhK5zUilx9u$W3ttUt1<{tf@*-P@a(pdEIk@WFe>!GFs~ zk6teLE{KZ=h>)@YaY1VM(>aJEg5;xnJv}$)P*2CDo@?s%Tu+y$seDcFvoZSOmp;ZH z{L!0+7!;2P-Uv0!eM*v6edFhXvgzxOSowwchET1@XVWYq!b%ScHp9TCF=uChv zsLwz0zxmHi3WL5dn4=&+|Qr=-=D%gHa6rU-*xv}&c3xp3pn=EFTbj) z{PPjrmILm8e&7_p^yw3q1%B7vbbNf9-AMm@;``m}y*AGZ%;OJqZjZ$P$H!bs3ypjK zynenew{c4wc1gG0{*&Bx`#snevtjVP1Ae1T z9qq~*KUdSq?japr7tuXt{cE1-xRPF(^DIN8c9~tCC#_+_F6rTievxO7eWE#cVL8V5 zZvo`UKYHmcQHqE1@WN_T{?>;M^Te68^#Wg)nf>ax)(hE;=?2?zv!0n9P1bd3hNkre zre!<%PFJhFX1vXaCal4I!vT7E`8vM!um28O3{nwbqH;$OrTjTZnz5btK6q+F;0rj7 z*3i*<8m(s*@N+>=*OxaLMSwx!QUn;30|^R29H}Fi8$=gI(W$BtRfJ(BUPU-}K_RO~ z{dlJbP}{3`D+ua#B!;|Ak3;~+9m%b7PmRB{5YC-@l1C1`j~CzimfTo1_n#>o`JV3Z z!mmBFUe_}YzI4WL+TcY8{Ko8iF``AIui@$1Ai$tR2IYW-5??5xD3DS?YXO+S$Y50A z!_k1X5u!4ZwbsJA2z7+v-et5Egw;&COY)gn{c9nl&j`o{gmCC{zsijVe~_YF%1!(` zg`tByTKSugoLcAb!c6eHE@z(Bj|1NWy)M>4D2ss%N_=VHEG{6M22NSRmL=obD}n%g z@mTcp7nR`%xe!1EhXDqNS{*&5tIcD=$bxW5Hmp8w4W900A0(c5-jER1R!?&H@egq0 z!8c{^HYmEIn!k1E%yS06&2Xk_{g|s6&y1)8Z(CPU2n(gNv;doz@I6~i-t;<(SKewm zC=7sM1^0iJ_-h{{e))7rdh8Ibafb=DTB&O{o*?K5!oeFI7_)}dW*7HtxtAw|Cm#C{ zHynHubs-epA?0vE=P{S4s8K(GIIL0Bw>h0z9_0MF zC)vF9Isl41nR5Tr>(c&SYN-vFf=bd7(e#8$;yU4rtAJpLmZ@3wLy~<>Lz*VDi-y#tmDq!+|0X7Jcg3l}mw7H`UDGO9o$4tty}{ zE-EN^P)8XcDz3PQiYVDnE8XnL>v|mUAnquO!DXp*wr>)->m@N;o@3bfg)%~9#tiEfr5yK02)d!097Q^CLKZ&iVzS< zRv?Xw2pv06Fu{Pu0W6e}>#kDDOTwSBrUO?hXD4@ zoTLlBOGvIpYyV#=9zsbFC{;w&lR!i$R8hrML_~z(;;LGA7HU=icf}nnNsYw<76KHa zbIrO-Ap|Ll!povyF_g8rYSWOWF`hdG5Z%P^e zN3j^3B-&aCF3BXO$Agt~PbTRj$pTg?SgU54Ogn6lgQ#lCD1zYXLI_YQUT8_7LJ^=8 zrw}{@r<8)A5CRe;pf0ZBf@;c6$dOoa#0|wv#N1g7$fCl@GvcGjC9Pl>g%hV2eW1!v z2eL3Ik{P17DBxnX5Nb~&ZmHiJF-ck-i57_-39)jMk>CYFg{wkUxr>NJK}DQUiMe>1 z$yMfgpmaGf?%i7*Gf-6iz8n2E3F~ z$^r)B9<)G%QvjkWBA^6vj%TPtfjN>ohPa`rJQayi`O7aX*~?yhQD-mLE~(TE<2R!OB1w~>c9lqe%cuHcH<$O$eM zLB;BcDtEzBW+X1+GBX}j!*hgkDOtc;oN3*SwKl_DDg*^}JcB+Pwt5-fs(>Up_FY)n`GfEQ)H$ni<3BzE)MN zRwK@=oc6V9MBz>>%%ho9HbP^hiX#zS16xC4V3-`K6U7Nbu^7Biq_Uhw7+DCp2R+Vf z^bydcq0^MA842uu-&GtzTvaF(%3?sNE{g$QEMTb!3{+SujD@10RAK)Xkvn%+x_=vb z#gei-Kq5+{B%+v?_2;5Fj6h_-TB!WgDEg_@ReyGM)&Bd3i|REmKOJ6j(*-R;YVaru z4?VhUANjX!RHvbn!w+rXZN_U-YAK*11aSr` zUW&^?5ML}|DFl{EVN>B;wJFG7+rO%J>~h(;R3s355hG9$YQRGcenApQL1a*apTH?K z$d#K^_AGDZMe5=FXy}Iy?e#zU%&BU5bA=l8(4hEYg40f}5`@wMazE^Yh!dX9g=23s@$e)|69`|9T%{YmHJ0?nWViZRy zP%7%2Hun#NnE~0mY3Ky}V@J;Mb1W!y!2=>?fiDDSF%&EX;lLJ^@4S9hUvcF~RZ}CY zrjdb|!T0pH(MSeKAQ>b}LJ*H&f-}HfD8rImy+!28%~$Es^XDxRc*(AviVUc#0T<&U zl`T+3WoQKzgBI)#@Kew0x~=AUVbfJ|)#85HzUQy%@e>c&Q(yYHU06Aqx@j0jbj$=X zuiOOGLokW4=QX;J_rR|$U{U~++TZkWT|`hVQ&v+4+<_J>DEy5ZqJH1aD*C4--*+9G7q;}=N2I`PJa!bETU+JxM?S~Xrw;SwvrlvE>`{huDjN(2Y~6Gn zJ9Zvq>yA6P^2*mx4)&xqJ=!Lfs)1{7{LXOf*!>o*LVSo8rJGfV1Q(6cW}UMVn{Gsq zb||9v#F+Ni1vW$Hn#OXZ1TO*%RN1j)l3h;L`ZW*y&J%0=&J*W(@R=bSSa!WWL8 zY9bnehH+1DQPkx%u9R3TA9)DtkbqzT_e1zkt z9|1lLyq(>#g@?CVdg zer3R)0O$N!KKQ`z^5F-67htX{d#+SfCjh}Z?m)CaC4hv~ehs)Qv9SQ7tHcTTS!rm$ zAJDj;Y3EuGL8lHGh~gSVGzb!`&Nv25ZvTAK^9g*HtUIixAgYKK`LB0CGL zYrahAeRe~=G}I>3&?#R_9EfDG1e$?NQ4FX&V4WWA3x*otb4Da|0&X6pNvi}@lF4*D zJ`-n&9MDc0I@kR)^8t0D<#~!jw4O^Ui7ZetXpR>O841%Y1R+l_=WMc>_pU?mbPT)a zc@DH_mrqa9__gun1gU=!+zCKvJ6?6C>xDu&03%>3)U!&63XR+!4t!Aa#hI{~-KTqpbbCIU#Md+Q@lN!pNqi^3%vuDQQ(ir>^m5 zlKaVSs&kztfGZVBMQAdi%3e4zpbNg!O){I=HKY~#3^;e*O13kz67T zomGc4=@;Y_ta$<3)vNBL%2%Ny_En<^;AbySpOwr^`$97s$`k?23&i!x0(Vn!%i9G= zJN8~ra8wkGcf>ya)Bt11bzA4PIMI`5H|~NqQvQ1D>h%G72sfDYdczaLH^8_ECeqNc z0_XASq_-YwLF9L0ORBmDZ1rr{EV>-Oz7hJ0moIH!07x!(`YM?@OB2V0(M?0WNkeCo z=P+VYk2eVfZG>QT2GpH%eW6g(2}`dUv^ToC+K2BFYed_OYGw?WMR_8Do&^XIrOH!` zK0gckTB8iUJ?H4#Qg%p%jr9X2X>#SVH%Hcf1p?9A`PUbc08nX{T5U)MGIybFN<%#` zJqXV%l)eSz?O?_xz+Y$`F*-vM<8&E@(V3FGlnctUYSXm8?FptVs7^!c3GG?*74Im0 z70U0v>tT{69F4X!Hl5}#Q-_($Qf3s_e3B+`dp!hU1S%W!7C$jX2+v1H>G>`{gZ~xj zgmuXKY3L;acN%X^Df#em62JDCAht*uor;EMcW|k^E+Z+i`0M* z#Iy`V>KC&c@=FHd&M}^b&I-EyYf`(OM0qwWFUzYKG^>|iNCL=jaJA`ZQbeQ83upC+ z=LO~Q(a`Mo$Kycjemw7L0S01eRfMEf0os`lm@k0AU=v%n-b}H$gCcAp43-JHiD(yy z!*f*eJgciGxN!C{hQp0+nKoJ1VCT&g3p*(WTPVsc6y;{B(F)b*Jk{_#)!IphC%-Va zu5Mo-O7;bou&K%<*s>o)rq611b96)?neao^ny}4tYZtw zC$z4pk6@3o3u@9(roYaYu5~!@x;l5voM;dYs_5hw?TnI&f}3yt5q9mki>0jxQ^utI zZoB97PTIQq%K(L?E&Ey6vY#E-yq4>~?rp4`ev}hWf0U;l`wfQYj{z{)as_*D{$6(O ze?5ykZ|aTP+XUEcQ@XjmA(UIMVR`E{EMNaxcE94MSv&nG=Z@T8KQE0d!eZKp&kXmN zuxs}y=wLVL`@6zv=7hbQhR$(ACN2t&87}Fye)seix#P`nbbiI>z=6L@r0Bh6l7O!p zK2__dfYw=)vuXQHEbh38y|?^*jz0Fk0oZf%8_{xs6r-j9K2~NrAIt6O2U-&-cihCz zoj3Kt7jUn@D)313Sid`E8Q@F0pWGIv2%rUNtQ(L`%xlxDa0)ZbB2^U`#pqc8`OTH_ zSA3wW^+p`6dbBl0u>xOn(*=>DM8kk`uq0M*iPi1*WKgwOVDF26pf^=GF>arq$Fb2x zui^-^AreRKp%lUZEe7bqlEg7eqrBAOPq z3ZfERWI+bYdt|V5r7mpUE6dxiQ;J3D3Srt*p$Sl}`i0X^*y{PCUacIniz{Dti))UV zxO(0~H<_RcS}aJhWsek__lj=as|&lY)1uha)B3J(yjm5rHFp~VRoWRUx_f6#$R{u# zb3fy0XoniN;|-U1Jsa=h$?v&Gk7j6$2qP|DJe%I5)w0ogi;**@AGT8`K5HxIzv%fp zVD6TJl^IURi@wzb@u&)1V07}Vt;iFRqd}f60*lMn>DH@m4NE&-6eOgcP!_PeasI@i zc>c=|#|x(qn|pC|ba(f9`?98EDQT(y=z|}kcDjrWU%j6-L&Uc z6%B1Yv10AwX{Q<@HX_DIl*-*A(zv2-!rEZk*J>uy(9Y#a%2r_F;3i_x%|=pLaFM`h zbe=~a|6siSrZ)yN;pDLg?AVb%j%&j+ZV2LriIKK^F)*t)e{`twJVSC(D#1~4B?zjh zsu2rW8GSacoIVuG#T~N!+HWkE_S_x-E`0gHc;<`uk1npBbliv*QPNH(o&;dg47Ic) z3e-C1^=zLCE-IqIQ56}gudF`oE5{zPQ;R!f`wib5miE0=!8!4l|0}X~mMX5|HpJpc zsG@sbJK^47HT~$IEHh$&liq9XT7j8)2qF;OT``Nq7~N%r5OCm&hyRZ~bL5Xrq;T-& z4U%@zYP}H#=5-q9G#g#y9k&9esdFb1Tta#ms*vD>0vDAyT(P4Me_)OBx06PVMlNnd zT)-Tw7{!Q-IWd7#-*$p!;_=Eq{of#t#D?do zsx^p1s%ivYxqAdRVoo8_GTol``dpmp_aa{ zT1C*Z2EScVU3Yt12#C81 z;v!MhU4*Jh$I}j4?vctW!A9UCys9v(B*nEPU{c%WYkx-^b5=!+cgzwx=I-JqNOTNb zomiE)J1P|;uS7*XruWGN)D5Io4KNqe%%O{0qy6>cdH-g`qkt%4nzqS}Du^W4t>Zkm zDY9WHy1R*BN$}RBiPENpo~mt@pJ$5@@)mQBN%AOZ3p1#2ix4Aj711J4fmz-Qv(v5Y zXFd}MOT9c@WaboJoe`yOSN7(fBf5ykh^XQgBc{RC>%CWM|F89ir(Op<0XJ)yYPLcp zD~f~UrIKlDMNknzsmSkUDbgJu!0UlYL&ULq`>H%tfMqbOt|wQT8kh3e#Dfd@6_>Qf zeGaH@_5Ts7)cLBKPFU)Y=67X0XIfJ-p9a{FFfR0^qSp(0kPWuOYRywo`vVN4-i#%NqY>XahuSFxQ?#ivGu>T z(<3WH4POg4+~q8034+# zHc^C0F_fBn8&0;`ZyBAsNlT0=IkHftdp6OPa0_+ITQi z2%Uzw?YxguExYs{7(F3r?ptT0CwXhFJ*3`abtp8v8 zee#4!N8@`SdH3f_x)wttv`O$=#b|tG^TQ6a!3WrO^WWD9aZ(9fcjcLvhLf;1_ z_hF4og7MhU5ri`lBW4L?-Z2&^8{iY@t@-upZ-CD(r{=q9bKgykgYTUzYaU&3%Xjx| zL>(RG-;X(X!*{T_u&<%kPaP`aHOemHz_#nJrO?ij8T6ROXzxYjTuYcWPy+UZ2 zoY3kF#Dq2U1kt*tdBPa$56nE~q@*2Qd>Zh5tsk?RnlBpV+6eed+pp)Uue-Yi9}Kg1 z>O0?c`{zvb{n-tP4*15G|6Dzsygnf`+TY3l`z~oRK=%YeBZ%fZU19j80iF=h*gxs= z()I-=fKU8S@ItHkt!1qne38HnuX_g?1|9z|zy1gRYWZOW2wMJ>6VBv?x>sHK3hun^ z&Fd6EBZw)!a*8$hq$Xz|#;l^TjE+Di1TzkFK01K+%IwuO3H%JLpB9py#i{@R14~In zK~&q;Py6orNw)60tNE6}*+TuFLOo;h@7{I&Gv@O1-Cu&f~z}8-$EOVlc z%zOX(G?1EWb>&`P>RxY@r8%#)eo7sertR0hii2->4^j-;Qk4(hckkvV73w&8ao5og z8pPK?MsJy&rd7l zhu(7TCF=Gu7q0HI{_L?&@W3bk8E4NvzG234byEaUS1~@yKQ55DEO%%EG#S6%cQf_p z=LkH&(#{*X;WcmX1>6jNz+V4*_bz{S26K~i_m7^sPF+9Z@FHl9FvcN|9C{yzKKHAv ztzPb4%~BWHRqsg)n9kgW1<(b&Q-C*@n*9DUfiE}h;_8>ZnXA6;htN>=!58_wR`PA* znm@+Obh`WQQ`==h-s2+Q)ikCCYgAq2@Z%rg$;Up#$rBG=7UNye`@-no+W^axkB?ix z1n5)0vo6|l<(*t{%im`AZQtGR;nHaS2VwXl_uaeW^sF-0;Z$Aje#_anBD}+etC)?drSOcHo;>+;*TBo;%>5aroIk{Fj^GeW`NS9o>K6%`1CDi0^dxex$SFiZQ9z zpNFFr;%J4+T0!)lYvRD{GUk;(ey>Z@?yb9w+QRcaDHhSeW{Pt282?ktH;`X Date: Sun, 25 Oct 2026 14:39:41 +0800 Subject: [PATCH 2/5] no-mistakes(review): fix novelito chrome, 429 retry, parsePage --- plugins/english/novelito.ts | 63 ++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/plugins/english/novelito.ts b/plugins/english/novelito.ts index 46bc2a9da..d735fce2b 100644 --- a/plugins/english/novelito.ts +++ b/plugins/english/novelito.ts @@ -49,8 +49,28 @@ function httpError(message: string, status: number): Error { return err; } +const MAX_RETRIES = 2; +const MAX_RETRY_WAIT_MS = 10000; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + async function fetchJson(url: string, what: string): Promise { - const res = await fetchApi(url, { headers: BROWSER_HEADERS }); + let res = await fetchApi(url, { headers: BROWSER_HEADERS }); + for ( + let attempt = 0; + res.status === 429 && attempt < MAX_RETRIES; + attempt++ + ) { + const retryAfter = parseInt(res.headers.get('Retry-After') || '', 10); + const waitMs = Math.min( + isNaN(retryAfter) ? 1000 : retryAfter * 1000, + MAX_RETRY_WAIT_MS, + ); + await sleep(waitMs); + res = await fetchApi(url, { headers: BROWSER_HEADERS }); + } if (!res.ok) { throw httpError(`${what}: HTTP ${res.status}`, res.status); } @@ -271,6 +291,16 @@ function parseChapters(data: unknown, novelId: number): Plugin.ChapterItem[] { return chapters; } +const READER_CHROME = [ + 'Settings', + 'Font Size', + 'A', + 'A', + '16px', + 'Font Family', + 'Background Color', +]; + function parseChapterBody(data: unknown): string { const res = data as { chapter?: unknown }; if (!isRecord(res.chapter)) { @@ -280,7 +310,7 @@ function parseChapterBody(data: unknown): string { if (!Array.isArray(paragraphs)) { throw new Error('Novelito chapter is missing its paragraphs array'); } - const parts: string[] = []; + const texts: string[] = []; for (const paragraph of paragraphs) { if (!isRecord(paragraph)) continue; const html = typeof paragraph['html'] === 'string' ? paragraph['html'] : ''; @@ -302,7 +332,22 @@ function parseChapterBody(data: unknown): string { : plainText(html); const trimmed = text.replace(/^\s+|\s+$/g, ''); if (trimmed === '') continue; - parts.push('

' + escapeHtml(trimmed) + '

'); + texts.push(trimmed); + } + let start = 0; + if (texts.length >= READER_CHROME.length) { + let matches = true; + for (let i = 0; i < READER_CHROME.length; i++) { + if (texts[i] !== READER_CHROME[i]) { + matches = false; + break; + } + } + if (matches) start = READER_CHROME.length; + } + const parts: string[] = []; + for (let i = start; i < texts.length; i++) { + parts.push('

' + escapeHtml(texts[i]) + '

'); } if (parts.length === 0) { throw new Error('Novelito chapter has no readable content'); @@ -430,18 +475,6 @@ class Novelito implements Plugin.PluginBase { return novel; } - // The app opens a novel already saved in its database but with no - // stored chapters through parsePage, not parseNovel. This plugin has - // no server-side chapter paging, so every page is the whole list. - async parsePage( - novelPathParam: string, - page: string, - ): Promise { - void page; - const novel = await this.parseNovel(novelPathParam); - return { chapters: novel.chapters || [] }; - } - async parseChapter(path: string): Promise { const parsed = parseChapterPath(path); const data = await fetchJson( From 9d1e2150376d55eebfeaeb3264cc06a570ab0cce Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sun, 25 Oct 2026 15:31:27 +0800 Subject: [PATCH 3/5] no-mistakes(review): decode entity-encoded chapter text before escaping --- plugins/english/novelito.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/english/novelito.ts b/plugins/english/novelito.ts index d735fce2b..a8a6b7ffa 100644 --- a/plugins/english/novelito.ts +++ b/plugins/english/novelito.ts @@ -328,7 +328,7 @@ function parseChapterBody(data: unknown): string { } const text = typeof paragraph['text'] === 'string' - ? paragraph['text'] + ? decodeEntities(paragraph['text']) : plainText(html); const trimmed = text.replace(/^\s+|\s+$/g, ''); if (trimmed === '') continue; From 8868ac5a4afd9496e9cbd7e7e275c3009399742d Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sun, 25 Oct 2026 16:04:27 +0800 Subject: [PATCH 4/5] no-mistakes(review): filter bare Noveli.to notices from chapter body --- plugins/english/novelito.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/english/novelito.ts b/plugins/english/novelito.ts index a8a6b7ffa..4f5e81c14 100644 --- a/plugins/english/novelito.ts +++ b/plugins/english/novelito.ts @@ -301,6 +301,11 @@ const READER_CHROME = [ 'Background Color', ]; +const SITE_NOTICE_TEXTS = [ + 'Provided by Noveli.to', + 'If you want to support more free releases please read on Noveli.to', +]; + function parseChapterBody(data: unknown): string { const res = data as { chapter?: unknown }; if (!isRecord(res.chapter)) { @@ -331,7 +336,9 @@ function parseChapterBody(data: unknown): string { ? decodeEntities(paragraph['text']) : plainText(html); const trimmed = text.replace(/^\s+|\s+$/g, ''); - if (trimmed === '') continue; + if (trimmed === '' || SITE_NOTICE_TEXTS.indexOf(trimmed) !== -1) { + continue; + } texts.push(trimmed); } let start = 0; From 83341fddac2bee7aa021e023dcb07da16b58f967 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sun, 25 Oct 2026 17:25:57 +0800 Subject: [PATCH 5/5] fix(english/novelito): decode numeric and named character references --- plugins/english/novelito.ts | 52 ++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/plugins/english/novelito.ts b/plugins/english/novelito.ts index 4f5e81c14..0d0cfc40f 100644 --- a/plugins/english/novelito.ts +++ b/plugins/english/novelito.ts @@ -108,14 +108,52 @@ function asNumber(value: unknown, what: string): number { return value; } +const NAMED_ENTITIES: Record = { + nbsp: ' ', + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + lsquo: '\u2018', + rsquo: '\u2019', + sbquo: '\u201A', + ldquo: '\u201C', + rdquo: '\u201D', + bdquo: '\u201E', + hellip: '\u2026', + ndash: '\u2013', + mdash: '\u2014', + middot: '\u00B7', + bull: '\u2022', + laquo: '\u00AB', + raquo: '\u00BB', + copy: '\u00A9', + deg: '\u00B0', + times: '\u00D7', +}; + +function fromCodePoint(code: number): string { + if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return ''; + if (code <= 0xffff) return String.fromCharCode(code); + const offset = code - 0x10000; + return String.fromCharCode( + 0xd800 + (offset >> 10), + 0xdc00 + (offset & 0x3ff), + ); +} + +// Single pass, so an escaped entity such as "&lt;" decodes only once. function decodeEntities(text: string): string { - return text - .replace(/ /gi, ' ') - .replace(/&/gi, '&') - .replace(/</gi, '<') - .replace(/>/gi, '>') - .replace(/"/gi, '"') - .replace(/�?39;/gi, "'"); + return text.replace( + /&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi, + (match: string, dec?: string, hex?: string, name?: string) => { + if (dec) return fromCodePoint(parseInt(dec, 10)) || match; + if (hex) return fromCodePoint(parseInt(hex, 16)) || match; + const named = NAMED_ENTITIES[(name || '').toLowerCase()]; + return named === undefined ? match : named; + }, + ); } /** Novel descriptions are sometimes plain text, sometimes HTML. */