From 08fc0c63705479cac38a1c69f16a711c233abe79 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 20:00:12 +0800 Subject: [PATCH 1/5] feat(indonesian/grensia): add Grensia source plugin Add a standalone Indonesian source for grensia.blogspot.com (Blogger). - Catalog: the site's own "List Novel" index (label novel). - Chapters: the reader's HTML search for the series name, followed through the "Older Posts" pages, filtered to the matching series. - Search: site search grouped by series name, so series missing from the List Novel (e.g. Isekai wa Heiwa deshita) are still found. - Chapter text is the post body. Known gap: 15 of the 38 listed series have titles that do not match their chapter posts on the site, so they show no chapters. Co-Authored-By: Claude --- plugins/indonesian/grensia.ts | 281 ++++++++++++++++++++++++++ public/static/src/id/grensia/icon.png | Bin 0 -> 1307 bytes 2 files changed, 281 insertions(+) create mode 100644 plugins/indonesian/grensia.ts create mode 100644 public/static/src/id/grensia/icon.png diff --git a/plugins/indonesian/grensia.ts b/plugins/indonesian/grensia.ts new file mode 100644 index 000000000..dae609169 --- /dev/null +++ b/plugins/indonesian/grensia.ts @@ -0,0 +1,281 @@ +import { load as parseHTML } from 'cheerio'; +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { defaultCover } from '@libs/defaultCover'; + +const BASE = 'https://grensia.blogspot.com'; +const SEARCH_PAGE_SIZE = 150; +const LIST_LABEL = `${BASE}/search/label/novel?max-results=${SEARCH_PAGE_SIZE}`; +const MAX_PAGES = 30; +const LIST_PAGES = 3; + +type Post = { + title: string; + url: string; + published: string; + cover?: string; +}; + +type Landing = { + name: string; + url: string; + cover?: string; +}; + +const normalize = (text: string) => + text.toLowerCase().replace(/[^a-z0-9]+/g, ''); + +// Blogger chapter titles look like "Series V4 Chapter 12 Part 3", so the series +// name is everything before the volume, chapter, part, or epilogue marker. +const seriesName = (title: string) => + title + .replace(/^Novel\s+/i, '') + .replace(/\s*\((LN|WN)\)/gi, '') + .replace(/\s+(Bahasa\s+)?Indonesia\b.*$/i, '') + .replace( + /\s+(V\d+|Vol\.?\s*\d+|Volume\s*\d+|Chapter\b|Ch\.?\s*\d|Epilog|Epilogue|Prolog|Prologue|Kata Penutup|Part\s*\d|Side Story|Extra|Bonus|\d+(\.\d+)?$|-\s*\d).*$/i, + '', + ) + .replace(/[\s\-–:]+$/, '') + .trim(); + +// The "List Novel" index posts are titled "Novel (LN) Indonesia" etc. +const landingName = (title: string) => + title + .replace(/^Novel\s+/i, '') + .replace(/\s*\((LN|WN)\)/gi, '') + .replace(/\s+(Bahasa\s+)?Indonesia\b.*$/i, '') + .replace(/\s+Bahasa.*$/i, '') + .trim(); + +// Some posts point at Blogger's generic "Shoebox" logo (base64 "U2hvZWJveA") +// instead of artwork, so those are treated as having no cover. +const usableCover = (url?: string) => + url && url.indexOf('U2hvZWJveA') < 0 ? url : undefined; + +// Headlines in the structured data keep HTML entities such as ★ for ★. +const decodeEntities = (text: string) => parseHTML(`

${text}

`).text(); + +const chapterNumberOf = (title: string) => { + const match = /Chapter\s*(\d+(?:\.\d+)?)/i.exec(title); + return match ? Number(match[1]) : undefined; +}; + +/** + * Reads the post cards on a Blogger listing or search page. Each card carries a + * JSON-LD block with the headline, publish date, canonical URL and image. + * The "Older Posts" link is followed by the caller to reach every page. + */ +async function readPage(url: string) { + const body = await (await fetchApi(url)).text(); + const $ = parseHTML(body); + const posts: Post[] = []; + + $("script[type='application/ld+json']").each((i, el) => { + try { + const data = JSON.parse($(el).html() || '{}'); + const link = data.mainEntityOfPage && data.mainEntityOfPage['@id']; + if (!data.headline || !link) return; + posts.push({ + title: decodeEntities(String(data.headline)).trim(), + url: link, + published: data.datePublished, + cover: usableCover(data.image && data.image.url), + }); + } catch (error) { + // Skip malformed structured data rather than failing the whole page. + } + }); + + const older = $('a.blog-pager-older-link').attr('href'); + return { posts, next: older }; +} + +async function readAll(startUrl: string, maxPages: number) { + const posts: Post[] = []; + let url: string | undefined = startUrl; + for (let i = 0; url && i < maxPages; i++) { + const page = await readPage(url); + posts.push(...page.posts); + url = page.next; + } + return posts; +} + +class GrensiaPlugin implements Plugin.PluginBase { + id = 'grensia'; + name = 'Grensia'; + icon = 'src/id/grensia/icon.png'; + site = `${BASE}/`; + version = '1.0.0'; + filters = undefined; + + private landingCache?: Promise; + + // The site's own "List Novel" index: one post per series. + private getLandings() { + if (!this.landingCache) { + this.landingCache = readAll(LIST_LABEL, LIST_PAGES) + .then(posts => { + const landings: Landing[] = []; + posts.forEach(post => { + if (post.url.indexOf('novel-translate-indonesia') >= 0) return; + const name = landingName(post.title); + if (name) landings.push({ name, url: post.url, cover: post.cover }); + }); + return landings; + }) + .catch(error => { + // Let the next call retry instead of caching a failed request. + this.landingCache = undefined; + throw error; + }); + } + return this.landingCache; + } + + async popularNovels(pageNo: number): Promise { + if (pageNo > 1) return []; + + const landings = await this.getLandings(); + const seen: Record = {}; + const novels: Plugin.NovelItem[] = []; + landings.forEach(landing => { + const key = normalize(landing.name); + if (seen[key]) return; + seen[key] = true; + novels.push({ + name: landing.name, + path: encodeURIComponent(landing.name), + cover: landing.cover || defaultCover, + }); + }); + return novels; + } + + async parseNovel(novelPath: string): Promise { + const name = decodeURIComponent(novelPath); + const landings = await this.getLandings(); + const landing = landings.find( + item => normalize(item.name) === normalize(name), + ); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name, + }; + + if (landing) { + const body = await (await fetchApi(landing.url)).text(); + const $ = parseHTML(body); + $('.post-body style, .post-body script').remove(); + const text = $('.post-body').first().text().replace(/\s+/g, ' '); + + // Each field runs until the next label, since the landing text is flat. + const field = (label: string, stops: string) => { + const match = new RegExp( + `${label}\\s*:?\\s*(.*?)(?=\\s+(?:${stops})\\b|$)`, + 'i', + ).exec(text); + return match && match[1].trim() ? match[1].trim() : undefined; + }; + + novel.genres = field('Genre', 'Author|Artist|Sinopsis|Volume|Chapter'); + novel.author = field( + 'Author(?:\\(s\\))?', + 'Artist|Sinopsis|Genre|Volume|Chapter', + ); + novel.artist = field( + 'Artist(?:\\(s\\))?', + 'Sinopsis|Author|Genre|Volume|Chapter', + ); + novel.summary = field('Sinopsis', 'Author|Artist|Genre|Volume|Chapter'); + } + + // Chapters are every post whose series name matches this novel's name. + // The reader's search is used because it returns the full history, unlike + // the Atom feed, which truncates long label runs. + const posts = await readAll( + `${BASE}/search?q=${encodeURIComponent(name)}&max-results=${SEARCH_PAGE_SIZE}`, + MAX_PAGES, + ); + const key = normalize(name); + // The List Novel posts share the series name but are index pages, not chapters. + const indexUrls = landings.map(item => item.url); + const matching = posts.filter( + post => + indexUrls.indexOf(post.url) < 0 && + normalize(seriesName(post.title)) === key, + ); + // Oldest first; posts published in the same minute keep chapter order. + matching.sort( + (a, b) => + Date.parse(a.published) - Date.parse(b.published) || + (chapterNumberOf(a.title) || 0) - (chapterNumberOf(b.title) || 0), + ); + + const withCover = matching.find(post => !!post.cover); + novel.cover = + (landing && landing.cover) || + (withCover && withCover.cover) || + defaultCover; + + novel.chapters = matching.map(post => ({ + name: post.title, + path: post.url.replace(BASE, ''), + releaseTime: new Date(post.published).toISOString(), + chapterNumber: chapterNumberOf(post.title), + })); + + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const body = await (await fetchApi(BASE + chapterPath)).text(); + const $ = parseHTML(body); + $( + '.post-body script, .post-body style, .post-body ins, .post-body iframe, .post-body a[name="more"]', + ).remove(); + return $('.post-body').first().html() || ''; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + if (pageNo > 1) return []; + + const posts = await readAll( + `${BASE}/search?q=${encodeURIComponent(searchTerm)}&max-results=${SEARCH_PAGE_SIZE}`, + 1, + ); + const landings = await this.getLandings(); + const term = normalize(searchTerm); + const seen: Record = {}; + const novels: Plugin.NovelItem[] = []; + + const indexUrls = landings.map(item => item.url); + posts.forEach(post => { + if (indexUrls.indexOf(post.url) >= 0) return; + const name = seriesName(post.title); + const key = normalize(name); + if (!key || seen[key] || key.indexOf(term) < 0) return; + seen[key] = true; + + // Prefer the series' own index cover, then the first chapter's image. + const landing = landings.find(item => normalize(item.name) === key); + novels.push({ + name, + path: encodeURIComponent(name), + cover: (landing && landing.cover) || post.cover || defaultCover, + }); + }); + + return novels; + } + + resolveUrl = (path: string, isNovel?: boolean) => + isNovel ? `${BASE}/search?q=${path}` : BASE + path; +} + +export default new GrensiaPlugin(); diff --git a/public/static/src/id/grensia/icon.png b/public/static/src/id/grensia/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..14633262fb4ae286e7ac5b871c8d898f93758598 GIT binary patch literal 1307 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGo3dtTpz6=aiY77hwEes65fI&pI$i=SIX@&liZ z2~bG2#5JNMC9x#cD!C{XNHG{07@6rB8t59Dh8P-J8Jk%d8E6|ASQ!`;Y_(X4q9Hdw zB{QuOw+6-+4Wd8|8gLs*GILXlOA>Pnu;?*`Si*9&FPDLV`M0NwV@SoVw{y>WizLb% zf1X=@{Gw=a?a2wJI$D-A>2yuvlU)^DvA%xkFXmSl7V7PtW5rgwLSaKPdwyj=(*@DX zXRbwUDY`3LUjB=HpM6roH2o9bi~m^JeD0ZF`{4LzqsMK5iiK95LWg(k>&~~XduDg$ zH=EvpJD<6B{JkZ0MqK>eA)7p%19wUbe@8wn-BS87UZtUjWx_-TBOwR16YEy0`K--K ze|1de%;HCeGt_hM?0fv++kQRU=E}uwt7Y|1q<>%U(dfn?$*G{sm;%;$at&8kd9;Cj z?MK!#i{)b8Zi%jVcjEiUIX+*X>zvs?b^alno`S8HS3Lav%<0dbYce5GCQK(h;0E}l zg$CS?2!AcVhvl=%P2;FTyt=h@9G_49(w-sS^J-~ci1p;~dxvVCo>rdm|6UdU=Pg$! zqS{{I%JZ20?hpQT{EyGRH#Uf$v!A(m^{vb+g>h2*xIQm*zMi;m<1Z=m5K}per~XmO zaKE}SohaGEm32kipnn(tW4@`E0FNUPkg$%XjH+vzM8oyLZCj zpSO(eiJR2Dk~`z=9Bg23-TFKw>~w|rmlrp5?<`!x`#64Tzd`=P({p3o(vPEfx99fD z$)CPPF+WpRmp@!n^2Y4ligxa8JN9vYuCd_w{PvaljPrE|u7*oQ?hjo2#=8^6t1l}= zQv{{22HF)R);&4GJcHel|BUwQitV4D{gR$hzU$V;3mouB0Y>M{P)AShaMNQB6C^KFaPsqM*5xvRGV1Bwg$`WWGl88U%Jcj(xG@q-=6jBx6R%$ z=Ty0DL*?d=?Aw!TYOnJ5{F6?d{8cnu|A7A5vR=!jSwB_tjthn$<&&>56JAH1TN5d^ zcuzs^(H%!t%vy6e;aKh3I<;dd)3U!FkNLeRZT6beAKzv7!b1s|Wj^!I{`1_XFohLZ Q3@|Wwy85}Sb4q9e05zI0KmY&$ literal 0 HcmV?d00001 From 27e73f7515bc4cc04a2016adaeee59b0552a8c9a Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 20:08:00 +0800 Subject: [PATCH 2/5] no-mistakes(review): remove Grensia metadata field parsing from parseNovel --- plugins/indonesian/grensia.ts | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/plugins/indonesian/grensia.ts b/plugins/indonesian/grensia.ts index dae609169..18080396b 100644 --- a/plugins/indonesian/grensia.ts +++ b/plugins/indonesian/grensia.ts @@ -165,33 +165,6 @@ class GrensiaPlugin implements Plugin.PluginBase { name, }; - if (landing) { - const body = await (await fetchApi(landing.url)).text(); - const $ = parseHTML(body); - $('.post-body style, .post-body script').remove(); - const text = $('.post-body').first().text().replace(/\s+/g, ' '); - - // Each field runs until the next label, since the landing text is flat. - const field = (label: string, stops: string) => { - const match = new RegExp( - `${label}\\s*:?\\s*(.*?)(?=\\s+(?:${stops})\\b|$)`, - 'i', - ).exec(text); - return match && match[1].trim() ? match[1].trim() : undefined; - }; - - novel.genres = field('Genre', 'Author|Artist|Sinopsis|Volume|Chapter'); - novel.author = field( - 'Author(?:\\(s\\))?', - 'Artist|Sinopsis|Genre|Volume|Chapter', - ); - novel.artist = field( - 'Artist(?:\\(s\\))?', - 'Sinopsis|Author|Genre|Volume|Chapter', - ); - novel.summary = field('Sinopsis', 'Author|Artist|Genre|Volume|Chapter'); - } - // Chapters are every post whose series name matches this novel's name. // The reader's search is used because it returns the full history, unlike // the Atom feed, which truncates long label runs. From a57be2acd927d27c04ed414de23091077478670b Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Fri, 16 Oct 2026 06:10:53 +0800 Subject: [PATCH 3/5] no-mistakes(test): Match Grensia series by structured description title --- plugins/indonesian/grensia.ts | 42 +++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/plugins/indonesian/grensia.ts b/plugins/indonesian/grensia.ts index 18080396b..4a45913d5 100644 --- a/plugins/indonesian/grensia.ts +++ b/plugins/indonesian/grensia.ts @@ -11,6 +11,7 @@ const LIST_PAGES = 3; type Post = { title: string; + description?: string; url: string; published: string; cover?: string; @@ -48,6 +49,21 @@ const landingName = (title: string) => .replace(/\s+Bahasa.*$/i, '') .trim(); +// Some series headlines use a romanized alias ("Isekai wa Heiwa deshita Chapter 1"), +// but the structured description repeats the catalog title ("Novel Indonesia"). +const descriptionSeries = (description?: string) => { + const match = /^Novel\s+(.+?)\s+Indonesia\b/i.exec(description || ''); + return match ? match[1].trim() : undefined; +}; + +// A post belongs to every series its headline or description names. +const seriesNames = (post: Post) => { + const names = [seriesName(post.title)]; + const fromDescription = descriptionSeries(post.description); + if (fromDescription) names.push(fromDescription); + return names; +}; + // Some posts point at Blogger's generic "Shoebox" logo (base64 "U2hvZWJveA") // instead of artwork, so those are treated as having no cover. const usableCover = (url?: string) => @@ -78,6 +94,7 @@ async function readPage(url: string) { if (!data.headline || !link) return; posts.push({ title: decodeEntities(String(data.headline)).trim(), + description: data.description && String(data.description), url: link, published: data.datePublished, cover: usableCover(data.image && data.image.url), @@ -178,7 +195,7 @@ class GrensiaPlugin implements Plugin.PluginBase { const matching = posts.filter( post => indexUrls.indexOf(post.url) < 0 && - normalize(seriesName(post.title)) === key, + seriesNames(post).some(name => normalize(name) === key), ); // Oldest first; posts published in the same minute keep chapter order. matching.sort( @@ -230,17 +247,18 @@ class GrensiaPlugin implements Plugin.PluginBase { const indexUrls = landings.map(item => item.url); posts.forEach(post => { if (indexUrls.indexOf(post.url) >= 0) return; - const name = seriesName(post.title); - const key = normalize(name); - if (!key || seen[key] || key.indexOf(term) < 0) return; - seen[key] = true; - - // Prefer the series' own index cover, then the first chapter's image. - const landing = landings.find(item => normalize(item.name) === key); - novels.push({ - name, - path: encodeURIComponent(name), - cover: (landing && landing.cover) || post.cover || defaultCover, + seriesNames(post).forEach(name => { + const key = normalize(name); + if (!key || seen[key] || key.indexOf(term) < 0) return; + seen[key] = true; + + // Prefer the series' own index cover, then the first chapter's image. + const landing = landings.find(item => normalize(item.name) === key); + novels.push({ + name, + path: encodeURIComponent(name), + cover: (landing && landing.cover) || post.cover || defaultCover, + }); }); }); From 74ed94671693dc052900cc57ff7872d6338f690f Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Fri, 16 Oct 2026 06:31:15 +0800 Subject: [PATCH 4/5] no-mistakes(test): Match Grensia chapters by description prefix, cutting zero-chapter series --- plugins/indonesian/grensia.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/indonesian/grensia.ts b/plugins/indonesian/grensia.ts index 4a45913d5..5d3f0d47a 100644 --- a/plugins/indonesian/grensia.ts +++ b/plugins/indonesian/grensia.ts @@ -64,6 +64,13 @@ const seriesNames = (post: Post) => { return names; }; +// Some headlines drop words from the catalog title, but the description begins with it. +const belongsTo = (post: Post, key: string) => { + if (seriesNames(post).some(name => normalize(name) === key)) return true; + const description = normalize(decodeEntities(post.description || '')); + return description.replace(/^novel/, '').indexOf(key) === 0; +}; + // Some posts point at Blogger's generic "Shoebox" logo (base64 "U2hvZWJveA") // instead of artwork, so those are treated as having no cover. const usableCover = (url?: string) => @@ -195,7 +202,7 @@ class GrensiaPlugin implements Plugin.PluginBase { const matching = posts.filter( post => indexUrls.indexOf(post.url) < 0 && - seriesNames(post).some(name => normalize(name) === key), + belongsTo(post, key), ); // Oldest first; posts published in the same minute keep chapter order. matching.sort( From e9f069f3faa4a5c45c94f6b28177e20e5e65c488 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Fri, 16 Oct 2026 06:49:36 +0800 Subject: [PATCH 5/5] no-mistakes(document): no docs stale; prettier fix applied to grensia.ts --- plugins/indonesian/grensia.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugins/indonesian/grensia.ts b/plugins/indonesian/grensia.ts index 5d3f0d47a..da2ac2a12 100644 --- a/plugins/indonesian/grensia.ts +++ b/plugins/indonesian/grensia.ts @@ -200,9 +200,7 @@ class GrensiaPlugin implements Plugin.PluginBase { // The List Novel posts share the series name but are index pages, not chapters. const indexUrls = landings.map(item => item.url); const matching = posts.filter( - post => - indexUrls.indexOf(post.url) < 0 && - belongsTo(post, key), + post => indexUrls.indexOf(post.url) < 0 && belongsTo(post, key), ); // Oldest first; posts published in the same minute keep chapter order. matching.sort(