From af2c3bfec51a8630aac87f42137d7cecc8943d42 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 19:46:07 +0800 Subject: [PATCH 1/3] feat(english/seireitranslations): add Seirei Translations source plugin Adds a standalone English source for seireitranslations.blogspot.com, a Blogger site. Each novel is one of the blog's labels, and its chapters are the posts under that label. Browse, search, novel detail with the full chapter list, and chapter text are all served from the Blogger feeds and post pages. Labels that are not English novel text are left out: the "[Bahasa Indonesia]" translations, the untagged Indonesian labels, and the image-only manhwa labels. Closes #160 --- plugins/english/seireitranslations.ts | 234 ++++++++++++++++++ .../static/src/en/seireitranslations/icon.png | Bin 0 -> 5088 bytes 2 files changed, 234 insertions(+) create mode 100644 plugins/english/seireitranslations.ts create mode 100644 public/static/src/en/seireitranslations/icon.png diff --git a/plugins/english/seireitranslations.ts b/plugins/english/seireitranslations.ts new file mode 100644 index 000000000..e66d19022 --- /dev/null +++ b/plugins/english/seireitranslations.ts @@ -0,0 +1,234 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters } from '@libs/filterInputs'; +import { load as loadCheerio } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; + +type FeedLink = { rel: string; href: string }; +type FeedEntry = { + title: { $t: string }; + link: FeedLink[]; + published?: { $t: string }; + media$thumbnail?: { url: string }; +}; +type FeedResponse = { + feed?: { + openSearch$totalResults?: { $t: string }; + category?: { term: string }[]; + entry?: FeedEntry[]; + }; +}; + +const PAGE_SIZE = 20; +const FEED_PAGE_SIZE = 150; +const COVER_SIZE = 's320'; +const PLACEHOLDER_COVER = 'No_image_available'; +const CHAPTER_PATH = /^\/\d{4}\/\d{2}\/[^/]+\.html$/; +const NOVEL_PATH = /^\/search\/label\/(.+)$/; +const NAV_TEXT = /^(previous|next|table of contents?|[|\s])+$/i; +// Labels that are not English novel text. Indonesian translations are dropped +// by their "[Bahasa Indonesia]" tag, or by name when the tag is missing. The +// manhwa labels hold only images. +const NON_ENGLISH_TAG = /\[bahasa indonesia\]/i; +const EXCLUDED_LABELS = new Set([ + 'Back Then I Adored You', + 'ChroNoiR', + 'Lagu', + 'Rainy Night', + 'The Great Wish', +]); + +const postHref = (entry: FeedEntry): string | undefined => + entry.link?.find(link => link.rel === 'alternate')?.href; + +class SeireiTranslations implements Plugin.PluginBase { + id = 'seireitranslations'; + name = 'Seirei Translations'; + icon = 'src/en/seireitranslations/icon.png'; + site = 'https://seireitranslations.blogspot.com'; + version = '1.0.0'; + filters: Filters | undefined = undefined; + + private async request(url: string) { + const response = await fetchApi(url); + if (!response.ok) { + throw Object.assign( + new Error(`Seirei Translations: HTTP ${response.status}.`), + { status: response.status }, + ); + } + return response; + } + + private async getFeed( + params: Record, + label?: string, + ): Promise { + const feed = label ? `summary/-/${encodeURIComponent(label)}` : 'summary'; + const query = new URLSearchParams({ alt: 'json', ...params }).toString(); + const data: FeedResponse = await ( + await this.request(`${this.site}/feeds/posts/${feed}?${query}`) + ).json(); + return data.feed; + } + + /** Blogger's label list is the blog's own grouping of novels. */ + private async novelLabels(): Promise { + const feed = await this.getFeed({ 'max-results': '1' }); + return (feed?.category || []) + .map(category => category.term) + .filter( + label => !NON_ENGLISH_TAG.test(label) && !EXCLUDED_LABELS.has(label), + ); + } + + private novelPath(label: string): string { + return `/search/label/${encodeURIComponent(label)}`; + } + + private labelFromPath(novelPath: string): string { + const match = NOVEL_PATH.exec(new URL(novelPath, this.site).pathname); + if (!match) throw new Error('Seirei Translations: invalid novel address.'); + return decodeURIComponent(match[1]); + } + + private coverFrom(entry?: FeedEntry): string { + const thumbnail = entry?.media$thumbnail?.url; + if (!thumbnail || thumbnail.includes(PLACEHOLDER_COVER)) + return defaultCover; + return thumbnail.replace(/\/s\d+(-c)?\//, `/${COVER_SIZE}/`); + } + + /** Each novel's cover is the thumbnail of its newest post. */ + private async toNovelItems(labels: string[]): Promise { + return Promise.all( + labels.map(async label => { + const feed = await this.getFeed({ 'max-results': '1' }, label).catch( + () => undefined, + ); + return { + name: label, + path: this.novelPath(label), + cover: this.coverFrom(feed?.entry?.[0]), + }; + }), + ); + } + + async popularNovels(pageNo: number): Promise { + const labels = (await this.novelLabels()).slice( + (pageNo - 1) * PAGE_SIZE, + pageNo * PAGE_SIZE, + ); + return this.toNovelItems(labels); + } + + /** Every post of a label, newest first, with duplicates across pages removed. */ + private async labelPosts(label: string): Promise { + const posts: FeedEntry[] = []; + const seen = new Set(); + let start = 1; + let total = 1; + while (start <= total) { + const feed = await this.getFeed( + { + 'max-results': String(FEED_PAGE_SIZE), + 'start-index': String(start), + }, + label, + ); + const entries = feed?.entry || []; + if (!entries.length) break; + for (const entry of entries) { + const href = postHref(entry); + if (!href || seen.has(href)) continue; + seen.add(href); + posts.push(entry); + } + // Blogger returns fewer posts than asked per page, so step by what came back. + start += entries.length; + total = Number(feed?.openSearch$totalResults?.$t || 0); + } + return posts; + } + + async parseNovel(novelPath: string): Promise { + const label = this.labelFromPath(novelPath); + const posts = await this.labelPosts(label); + + const chapters: Plugin.ChapterItem[] = []; + for (const entry of posts) { + const href = postHref(entry); + const name = entry.title?.$t?.trim(); + if (!href || !name) continue; + const path = new URL(href).pathname; + if (!CHAPTER_PATH.test(path)) continue; + chapters.push({ + name, + path, + releaseTime: entry.published?.$t, + }); + } + if (!chapters.length) + throw new Error('Seirei Translations: no chapters were found.'); + + // Blogger lists newest first; readers expect the series in publish order. + chapters.reverse(); + + return { + name: label, + path: this.novelPath(label), + cover: this.coverFrom(posts[0]), + chapters, + }; + } + + async parseChapter(chapterPath: string): Promise { + const url = this.resolveUrl(chapterPath); + const $ = loadCheerio(await (await this.request(url)).text()); + const content = $('div.post-body').first(); + content + .find( + 'script,style,iframe,noscript,form,.sharethis-inline-reaction-buttons', + ) + .remove(); + // Drop the "Previous | Table of Contents | Next" footer. + content.find('div,p').each((_, el) => { + const node = $(el); + const text = node.text().replace(/\s+/g, ' ').trim(); + if (node.find('a').length && NAV_TEXT.test(text)) node.remove(); + }); + const chapter = content.html() || ''; + const hasImages = content.find('img').length > 0; + if (!hasImages && content.text().trim().length < 200) + throw new Error( + 'Seirei Translations: no readable chapter text was found.', + ); + return chapter; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const terms = searchTerm.toLowerCase().split(/\s+/).filter(Boolean); + if (!terms.length) return []; + // Searching the label catalogue finds novels by name. Blogger's post + // search would return chapter posts instead, which crowd novels out. + const labels = (await this.novelLabels()).filter(label => + terms.every(term => label.toLowerCase().includes(term)), + ); + return this.toNovelItems( + labels.slice((pageNo - 1) * PAGE_SIZE, pageNo * PAGE_SIZE), + ); + } + + resolveUrl(path: string): string { + const url = new URL(path, this.site); + if (url.origin !== this.site) + throw new Error('Seirei Translations: invalid page address.'); + return url.href; + } +} + +export default new SeireiTranslations(); diff --git a/public/static/src/en/seireitranslations/icon.png b/public/static/src/en/seireitranslations/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fc40b9511f32bfef19be9e50f4071f08327a5f4b GIT binary patch literal 5088 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 Date: Thu, 15 Oct 2026 19:51:49 +0800 Subject: [PATCH 2/3] no-mistakes(review): clarify seirei label exclusion comment --- plugins/english/seireitranslations.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/english/seireitranslations.ts b/plugins/english/seireitranslations.ts index e66d19022..abf39ed99 100644 --- a/plugins/english/seireitranslations.ts +++ b/plugins/english/seireitranslations.ts @@ -26,9 +26,9 @@ const PLACEHOLDER_COVER = 'No_image_available'; const CHAPTER_PATH = /^\/\d{4}\/\d{2}\/[^/]+\.html$/; const NOVEL_PATH = /^\/search\/label\/(.+)$/; const NAV_TEXT = /^(previous|next|table of contents?|[|\s])+$/i; -// Labels that are not English novel text. Indonesian translations are dropped -// by their "[Bahasa Indonesia]" tag, or by name when the tag is missing. The -// manhwa labels hold only images. +// Labels that are not novels. Indonesian translations are dropped by their +// "[Bahasa Indonesia]" tag, and the named labels are dropped because they are +// not novels. const NON_ENGLISH_TAG = /\[bahasa indonesia\]/i; const EXCLUDED_LABELS = new Set([ 'Back Then I Adored You', From 315b115f40d4cd518d5bcf2210354dd8b3c6dc3c Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 20:06:57 +0800 Subject: [PATCH 3/3] no-mistakes(document): Clarify seirei label exclusion comment --- plugins/english/seireitranslations.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/english/seireitranslations.ts b/plugins/english/seireitranslations.ts index abf39ed99..d26d58f8c 100644 --- a/plugins/english/seireitranslations.ts +++ b/plugins/english/seireitranslations.ts @@ -26,9 +26,9 @@ const PLACEHOLDER_COVER = 'No_image_available'; const CHAPTER_PATH = /^\/\d{4}\/\d{2}\/[^/]+\.html$/; const NOVEL_PATH = /^\/search\/label\/(.+)$/; const NAV_TEXT = /^(previous|next|table of contents?|[|\s])+$/i; -// Labels that are not novels. Indonesian translations are dropped by their -// "[Bahasa Indonesia]" tag, and the named labels are dropped because they are -// not novels. +// Labels that are not novels: Indonesian translations are matched by their +// "[Bahasa Indonesia]" tag, and the named labels in EXCLUDED_LABELS are not +// novels. const NON_ENGLISH_TAG = /\[bahasa indonesia\]/i; const EXCLUDED_LABELS = new Set([ 'Back Then I Adored You',