From 2c7c18c7ebabefe7ef6209b83278a98f89b18c46 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 19:41:42 +0800 Subject: [PATCH 1/2] feat(english): add Mizupan's Archives source plugin Adds a standalone English source for mizupanshoko.wordpress.com, a WordPress.com fan translation blog. Novels are the menu pages, chapters are the dated posts they link to, and search is the native WordPress search mapped back to novels through each post's category. Authored with AI assistance (Claude); verified with the live-check script and the plugin playground. --- plugins/english/mizupanshoko.ts | 296 +++++++++++++++++++++ public/static/src/en/mizupanshoko/icon.png | Bin 0 -> 5965 bytes 2 files changed, 296 insertions(+) create mode 100644 plugins/english/mizupanshoko.ts create mode 100644 public/static/src/en/mizupanshoko/icon.png diff --git a/plugins/english/mizupanshoko.ts b/plugins/english/mizupanshoko.ts new file mode 100644 index 000000000..2361ffaf4 --- /dev/null +++ b/plugins/english/mizupanshoko.ts @@ -0,0 +1,296 @@ +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 Page = ReturnType; + +type NovelPage = { + path: string; + name: string; + cover: string; + $: Page; +}; + +/** Dated permalink every chapter post uses, e.g. `/2022/01/26//`. */ +const CHAPTER_PATH = /^\/(\d{4})\/(\d{2})\/(\d{2})\/[^/]+\/$/; + +/** Headings that open a new section inside a novel page's chapter list. */ +const SECTION_HEADING = /^(chapters|part\s*\d+)$/i; + +/** Previous / TOC / Next navigation words at the top or bottom of a chapter. */ +const NAV_WORDS = /\[?\s*(previous|next|toc)\s*\]?/gi; + +/** + * Mizupan's Archives (mizupanshoko.wordpress.com) is a WordPress.com fan + * translation blog with a plain, theme-driven structure: + * + * - Each novel is a WordPress *page* (`/shisha-sosei/`) linked from the + * main menu. Its entry content holds the synopsis and a hand-maintained + * chapter list that links to each released chapter post. Chapters that are + * not out yet appear as plain bracketed text and are not listed. + * - Chapters are dated posts (`/2020/07/24/shisha-sosei-chapter-1-.../`) that + * sit in a per-novel category sharing the novel's slug. + * - The REST API is not exposed on this site, so everything is scraped from + * the HTML pages. + * - Search is native WordPress `/?s=term`; chapter hits are mapped back to + * their novel through the category slug on each result. + */ +class MizupanPlugin implements Plugin.PluginBase { + id = 'mizupanshoko'; + name = "Mizupan's Archives"; + icon = 'src/en/mizupanshoko/icon.png'; + site = 'https://mizupanshoko.wordpress.com'; + version = '1.0.0'; + + filters: Filters | undefined = undefined; + + async popularNovels(pageNo: number): Promise { + // Every novel is linked from the main menu; there is no paginated browse. + if (pageNo > 1) { + return []; + } + const catalogue = await this.fetchCatalogue(); + return catalogue.map(this.toNovelItem); + } + + async parseNovel(novelPath: string): Promise { + const path = this.toPath(novelPath); + const page = await this.loadNovelPage(path); + const chapters = this.novelChapters(page.$); + if (chapters.length === 0) { + throw new Error(`Mizupan's Archives: no chapters listed at ${path}`); + } + + const novel: Plugin.SourceNovel = { + path, + name: page.name, + cover: page.cover, + chapters, + }; + + const summary = this.novelSummary(page.$); + if (summary) { + novel.summary = summary; + } + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const path = this.toPath(chapterPath); + const $ = loadCheerio(await this.fetchHtml(this.site + path)); + const content = this.entryContent($); + if (content.length === 0) { + throw new Error(`Mizupan's Archives: no chapter text at ${path}`); + } + + content.find('script, style, .sharedaddy, .jp-relatedposts').remove(); + + // Drop the Previous / TOC / Next navigation paragraphs, which hold nothing + // but those words and their links. + content.find('p').each((_, el) => { + const text = $(el).text(); + if ( + /\b(previous|next|toc)\b/i.test(text) && + text.replace(NAV_WORDS, '').trim() === '' + ) { + $(el).remove(); + } + }); + + return content.html() || ''; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const base = pageNo > 1 ? `${this.site}/page/${pageNo}/` : `${this.site}/`; + const url = `${base}?s=${encodeURIComponent(searchTerm)}`; + const res = await fetchApi(url); + if (res.status === 404) { + return []; + } + if (!res.ok) { + throw this.httpError(res.status, url); + } + const $ = loadCheerio(await res.text()); + + const catalogue = await this.fetchCatalogue(); + const byPath: Record = {}; + for (const novel of catalogue) { + byPath[novel.path] = novel; + } + + const found: NovelPage[] = []; + const seen: Record = {}; + $('article').each((_, el) => { + const article = $(el); + const classes = article.attr('class') || ''; + let paths: string[]; + if (classes.indexOf('type-page') !== -1) { + // A novel page itself matched. + paths = [ + this.toPath( + article.find('.entry-title a').first().attr('href') || '', + ), + ]; + } else { + // A chapter post: its category slug is the novel's slug. + const categories = classes.match(/\bcategory-[\w-]+/g) || []; + paths = categories.map(category => `/${category.slice(9)}/`); + } + for (const path of paths) { + if (byPath[path] && !seen[path]) { + seen[path] = true; + found.push(byPath[path]); + } + } + }); + + return found.map(this.toNovelItem); + } + + resolveUrl = (path: string) => this.site + this.toPath(path); + + private toNovelItem = (novel: NovelPage): Plugin.NovelItem => ({ + name: novel.name, + path: novel.path, + cover: novel.cover, + }); + + /** Novels are the menu pages whose content links to chapter posts. */ + private async fetchCatalogue(): Promise { + const $ = loadCheerio(await this.fetchHtml(this.site + '/')); + const paths: string[] = []; + $('li.menu-item > a').each((_, el) => { + const path = this.toPath($(el).attr('href') || ''); + if ( + path.charAt(0) === '/' && + path !== '/' && + paths.indexOf(path) === -1 + ) { + paths.push(path); + } + }); + + const pages: NovelPage[] = []; + for (const path of paths) { + const page = await this.loadNovelPage(path); + if (this.novelChapters(page.$).length > 0) { + pages.push(page); + } + } + return pages; + } + + private async loadNovelPage(path: string): Promise { + const $ = loadCheerio(await this.fetchHtml(this.site + path)); + $('.jp-relatedposts, .sharedaddy, script, style').remove(); + + const name = this.normalizeText($('h1.entry-title').first().text()); + // Novels without artwork fall back to WordPress's blank placeholder image. + const ogImage = $('meta[property="og:image"]').attr('content') || ''; + const cover = + ogImage && ogImage.indexOf('/i/blank.jpg') === -1 + ? ogImage + : defaultCover; + return { path, name, cover, $ }; + } + + private novelChapters($: Page): Plugin.ChapterItem[] { + const chapters: Plugin.ChapterItem[] = []; + const seen: Record = {}; + + this.entryContent($) + .find('a[href]') + .each((_, el) => { + const path = this.toPath($(el).attr('href') || ''); + const match = CHAPTER_PATH.exec(path); + if (!match || seen[path]) { + return; + } + seen[path] = true; + + const name = this.normalizeText($(el).text()); + const numbered = name.match(/\b(?:Ch\.?|Chapter)\s*(\d+)/i); + chapters.push({ + name, + path, + releaseTime: `${match[1]}-${match[2]}-${match[3]}`, + chapterNumber: numbered ? Number(numbered[1]) : chapters.length + 1, + }); + }); + + return chapters; + } + + /** Synopsis paragraphs: everything before the chapter list begins. */ + private novelSummary($: Page): string { + const lines: string[] = []; + this.entryContent($) + .find('p, h4') + .each((_, el) => { + const element = $(el); + const text = this.normalizeText(element.text()); + const hasChapterLink = element + .find('a[href]') + .toArray() + .some(link => + CHAPTER_PATH.test(this.toPath($(link).attr('href') || '')), + ); + if (SECTION_HEADING.test(text) || hasChapterLink) { + return false; + } + if (text) { + lines.push(text); + } + }); + return lines.join('\n'); + } + + private entryContent($: Page) { + let content = $('.entry-content').first(); + if (content.length === 0) { + content = $('article .entry-content').first(); + } + return content; + } + + /** Normalize a URL or href to a site-relative path with a trailing slash. */ + private toPath(href: string): string { + const raw = href.trim(); + const sameSite = raw.match( + /^https?:\/\/mizupanshoko\.wordpress\.com(\/.*)?$/i, + ); + if (/^https?:\/\//i.test(raw) && !sameSite) { + return raw; + } + const clean = (sameSite ? sameSite[1] || '/' : raw).split(/[?#]/)[0]; + const path = clean.charAt(0) === '/' ? clean : '/' + clean; + return path.charAt(path.length - 1) === '/' ? path : path + '/'; + } + + private async fetchHtml(url: string): Promise { + const res = await fetchApi(url); + if (!res.ok) { + throw this.httpError(res.status, url); + } + return res.text(); + } + + private httpError(status: number, url: string): Error { + // Carry the status so tooling can tell a refused/blocked request + // (403/503) apart from a genuine parsing failure. + return Object.assign(new Error(`HTTP ${status} while fetching ${url}`), { + status, + }); + } + + private normalizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); + } +} + +export default new MizupanPlugin(); diff --git a/public/static/src/en/mizupanshoko/icon.png b/public/static/src/en/mizupanshoko/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..670506743280577f68ae07d08064b144aa6fd79f GIT binary patch literal 5965 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 T$gKTGlLORLw3HFgEyMo{8}xa7 literal 0 HcmV?d00001 From 8623faa31d04e935a2df73db7360c07ad14671aa Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Thu, 15 Oct 2026 19:46:33 +0800 Subject: [PATCH 2/2] no-mistakes(review): Drop cheerio document from catalogue novel records --- plugins/english/mizupanshoko.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/plugins/english/mizupanshoko.ts b/plugins/english/mizupanshoko.ts index 2361ffaf4..7c26cc6e9 100644 --- a/plugins/english/mizupanshoko.ts +++ b/plugins/english/mizupanshoko.ts @@ -10,7 +10,6 @@ type NovelPage = { path: string; name: string; cover: string; - $: Page; }; /** Dated permalink every chapter post uses, e.g. `/2022/01/26//`. */ @@ -57,12 +56,13 @@ class MizupanPlugin implements Plugin.PluginBase { async parseNovel(novelPath: string): Promise { const path = this.toPath(novelPath); - const page = await this.loadNovelPage(path); - const chapters = this.novelChapters(page.$); + const $ = await this.loadNovelPage(path); + const chapters = this.novelChapters($); if (chapters.length === 0) { throw new Error(`Mizupan's Archives: no chapters listed at ${path}`); } + const page = this.toNovelPage(path, $); const novel: Plugin.SourceNovel = { path, name: page.name, @@ -70,7 +70,7 @@ class MizupanPlugin implements Plugin.PluginBase { chapters, }; - const summary = this.novelSummary(page.$); + const summary = this.novelSummary($); if (summary) { novel.summary = summary; } @@ -177,18 +177,21 @@ class MizupanPlugin implements Plugin.PluginBase { const pages: NovelPage[] = []; for (const path of paths) { - const page = await this.loadNovelPage(path); - if (this.novelChapters(page.$).length > 0) { - pages.push(page); + const $ = await this.loadNovelPage(path); + if (this.novelChapters($).length > 0) { + pages.push(this.toNovelPage(path, $)); } } return pages; } - private async loadNovelPage(path: string): Promise { + private async loadNovelPage(path: string): Promise { const $ = loadCheerio(await this.fetchHtml(this.site + path)); $('.jp-relatedposts, .sharedaddy, script, style').remove(); + return $; + } + private toNovelPage(path: string, $: Page): NovelPage { const name = this.normalizeText($('h1.entry-title').first().text()); // Novels without artwork fall back to WordPress's blank placeholder image. const ogImage = $('meta[property="og:image"]').attr('content') || ''; @@ -196,7 +199,7 @@ class MizupanPlugin implements Plugin.PluginBase { ogImage && ogImage.indexOf('/i/blank.jpg') === -1 ? ogImage : defaultCover; - return { path, name, cover, $ }; + return { path, name, cover }; } private novelChapters($: Page): Plugin.ChapterItem[] {