From 998e3d7b6b2ffb426f67c80cc9b6ddb9eebe17ca Mon Sep 17 00:00:00 2001 From: Patrick Date: Tue, 4 Aug 2026 21:53:37 -0600 Subject: [PATCH 1/4] feat: add Baka-Tsuki plugin Adds an English source plugin for Baka-Tsuki, built on the wiki's MediaWiki API rather than HTML scraping. - Catalogue unions the English-tagged categories (trusted verbatim) with the language-agnostic status categories filtered by a language-suffix pattern, since neither source alone is both complete and English-only. Cached per session behind a shared promise. - Search ranks title-first (exact > prefix > substring > token) over the cached catalogue, augmented by prefixsearch and full-text. Chapter sub-pages are excluded by catalogue membership plus a strict colon rule, which holds across languages where enumerating chapter words ("Tome", "Rozdzial", "Tap") does not. - Chapter lists are read from the project page in document order, the one place scraping beats the API: allpages is alphabetical and would place Chapter 10 before Chapter 2. Accepts both the full page title and its first colon-segment as a prefix, since some projects file chapters under a short name. - Volume "Full Text" pages are dropped where individual chapters exist and kept where they are the only readable entry. - Genres, status and author are derived from page categories; author is omitted rather than guessed when it does not look like a personal name. - Summary prefers a synopsis section, as the lead section is usually wiki housekeeping. - showLatestNovels maps to recentchanges, rolled up to parent projects. Verified against the live wiki across four differently shaped projects (standard, slash-titled, colon-titled, irregular): 51/51 checks pass. parseNovel uses 4 requests, parseChapter exactly 1. --- plugins/english/bakaTsuki.ts | 795 ++++++++++++++++++++++++ public/static/src/en/bakatsuki/icon.png | Bin 0 -> 937 bytes 2 files changed, 795 insertions(+) create mode 100644 plugins/english/bakaTsuki.ts create mode 100644 public/static/src/en/bakatsuki/icon.png diff --git a/plugins/english/bakaTsuki.ts b/plugins/english/bakaTsuki.ts new file mode 100644 index 000000000..596cc97c2 --- /dev/null +++ b/plugins/english/bakaTsuki.ts @@ -0,0 +1,795 @@ +import { load as parseHTML } from 'cheerio'; +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +type MWPage = { + title: string; + missing?: boolean; + extract?: string; + thumbnail?: { source: string }; + categories?: { title: string }[]; + revisions?: { timestamp: string }[]; +}; + +type MWResponse = { + query?: { + pages?: MWPage[]; + categorymembers?: { title: string }[]; + search?: { title: string }[]; + prefixsearch?: { title: string }[]; + recentchanges?: { title: string }[]; + }; + parse?: { title: string; text: string }; + continue?: Record; + error?: { code: string; info: string }; +}; + +class BakaTsuki implements Plugin.PluginBase { + id = 'bakatsuki'; + name = 'Baka-Tsuki'; + version = '1.0.0'; + icon = 'src/en/bakatsuki/icon.png'; + site = 'https://www.baka-tsuki.org/project/'; + + private readonly apiUrl = this.site + 'api.php'; + private readonly pageSize = 40; + + /** + * Categories that guarantee an English project. Members are trusted verbatim. + */ + private readonly englishCategories = [ + 'Light novel (English)', + 'Web novel (English)', + ]; + + /** + * Status categories are language-agnostic, so they pull in translation + * siblings ("Absolute Duo - Français") alongside English projects that were + * never given a language tag. Members are kept only if they survive + * `nonEnglishPattern`. + */ + private readonly statusCategories: Record = { + 'Active Projects': NovelStatus.Ongoing, + 'Completed Project': NovelStatus.Completed, + 'Stalled Projects': NovelStatus.OnHiatus, + 'Inactive Projects': NovelStatus.Inactive, + 'Hosted Projects': NovelStatus.Ongoing, + }; + + private readonly nonEnglishPattern = + /(?:^|[\s([~\-_])(?:fran[cç]ais|espa[nñ]ol|spanish|french|german|deutsch|italian|italiano|polski|polish|portugu[eê]s|portuguese|brazilian|russian|swedish|svenska|indonesia|indonesian|vietnamese|chinese|thai|turkish|t[uü]rk[cç]e|arabic|korean|japanese|dutch|nederlands|hungarian|magyar|romanian|greek|hebrew|persian|farsi|czech|bulgarian|serbian|croatian|finnish|danish|norwegian|ukrainian|latvian|lithuanian|estonian|slovak|slovenian|catalan|filipino|tagalog|malay|hindi|bengali|tamil|urdu)(?:$|[\s)\]~\-_:])|[\s\-~_](?:FR|PL|ES|DE|IT|PT|RU|VN|CN|TH|TR|AR|KR|JP|NL|HU|RO|GR|CZ|BG|RS|HR|FI|DK|NO|UA|SE|ID)$/i; + + /** + * Sub-pages of a project (chapters, illustrations, staff pages). Used to keep + * them out of novel lists — matching on a bare ":" would be wrong, since real + * projects contain colons ("Anohana: The Flower We Saw That Day"). + */ + private readonly subPagePattern = + /:(?:\s*)(?:Volume|Vol\.?|Chapter|Part|Book|Full[_ ]Text|Illustrations?|Prologue|Epilogue|Afterword|Side[_ ]Stor(?:y|ies)|Short[_ ]Stor(?:y|ies)|Names[_ ]and[_ ]Terminology|Registration|Registry|Updates?|Staff|Guidelines|Format|Historique|Enregistrement)/i; + + /** + * Project scaffolding that shares the chapter prefix but isn't readable + * content ("Absolute Duo:Registration", "…:Names and Terminology"). + */ + private readonly nonChapterPattern = + /^(?:Registration|Registry|Archives?|Staff|Guidelines?|Updates?|Format|Names[\s_]and[\s_]Terminology|Terminology|Translation|Translators?|Editors?|Discussion|Sandbox|Talk|Status|References?|Credits?|To-?do|Feedback|Preview|Announcements?)/i; + + private readonly genrePattern = /^Category:Genre\s*-\s*(.+)$/i; + + /** + * Publisher imprints and structural categories, excluded when guessing the + * author from a page's category list. + */ + private readonly nonAuthorCategoryPattern = + /^(?:Light novel|Web novel|Original light novel|Visual novel|Audio novel|Genre|Hosted|Active|Completed|Inactive|Stalled|Teaser|Licensed|Pages? |Candidates|Articles|Project|Series|Novel|Manga)|(?:Bunko|Books|Publishing|Shoten|Shuppan|Kadokawa|Shueisha|Kodansha|Shogakukan|ASCII|Media Factory|Enterbrain|Hobby Japan|Overlap|SoftBank|Fujimi|Dengeki|Gagaga|Sneaker|Ichijinsha|Earth Star|Micro Magazine|TO Books|Alphapolis|Famitsu|Fantasia|Dash|Kobunsha|Takeshobo|Houbunsha|Media Works)/i; + + private cataloguePromise: Promise | null = null; + private catalogue: string[] = []; + private catalogueSet = new Set(); + private statusByTitle = new Map(); + + filters = { + status: { + label: 'Status', + value: '', + options: [ + { label: 'All', value: '' }, + { label: 'Active', value: NovelStatus.Ongoing }, + { label: 'Completed', value: NovelStatus.Completed }, + { label: 'Stalled', value: NovelStatus.OnHiatus }, + { label: 'Inactive', value: NovelStatus.Inactive }, + ], + type: FilterTypes.Picker, + }, + order: { + label: 'Order', + value: 'asc', + options: [ + { label: 'A → Z', value: 'asc' }, + { label: 'Z → A', value: 'desc' }, + ], + type: FilterTypes.Picker, + }, + } satisfies Filters; + + /* ------------------------------------------------------------------ */ + /* API plumbing */ + /* ------------------------------------------------------------------ */ + + private async query(params: Record): Promise { + const search = new URLSearchParams({ + format: 'json', + formatversion: '2', + ...params, + }); + + const response = await fetchApi(`${this.apiUrl}?${search.toString()}`); + if (!response.ok) { + throw new Error( + `Baka-Tsuki returned ${response.status} ${response.statusText}`, + ); + } + + const json = (await response.json()) as MWResponse; + if (json.error) { + throw new Error(`Baka-Tsuki API error: ${json.error.info}`); + } + return json; + } + + /** Page titles use underscores in URLs but spaces in the API. */ + private toTitle = (path: string) => path.replace(/_/g, ' ').trim(); + private toPath = (title: string) => title.replace(/ /g, '_'); + + private async categoryMembers(category: string): Promise { + const titles: string[] = []; + let cmcontinue: string | undefined; + + do { + const json: MWResponse = await this.query({ + action: 'query', + list: 'categorymembers', + cmtitle: `Category:${category}`, + cmnamespace: '0', + cmtype: 'page', + cmlimit: '500', + ...(cmcontinue ? { cmcontinue } : {}), + }); + + for (const member of json.query?.categorymembers ?? []) { + titles.push(member.title); + } + cmcontinue = json.continue?.cmcontinue; + } while (cmcontinue); + + return titles; + } + + /* ------------------------------------------------------------------ */ + /* Catalogue */ + /* ------------------------------------------------------------------ */ + + private isEnglishProject(title: string) { + return ( + !this.nonEnglishPattern.test(title) && !this.subPagePattern.test(title) + ); + } + + /** + * The wiki has no single "all English novels" category, so the catalogue is + * assembled from the language-tagged categories (trusted as-is) plus the + * status categories with non-English siblings filtered out. Cached for the + * session — it costs seven requests to build. + */ + private async getCatalogue(): Promise { + if (this.cataloguePromise) return this.cataloguePromise; + + this.cataloguePromise = (async () => { + const titles = new Set(); + + for (const category of this.englishCategories) { + for (const title of await this.categoryMembers(category)) { + if (!this.subPagePattern.test(title)) titles.add(title); + } + } + + for (const [category, status] of Object.entries(this.statusCategories)) { + for (const title of await this.categoryMembers(category)) { + // Status is recorded even for titles the language filter rejects, so + // a direct visit to a translation sibling still shows its status. + if (!this.statusByTitle.has(title)) { + this.statusByTitle.set(title, status); + } + if (this.isEnglishProject(title)) titles.add(title); + } + } + + this.catalogue = Array.from(titles).sort((a, b) => a.localeCompare(b)); + this.catalogueSet = new Set(this.catalogue); + return this.catalogue; + })(); + + try { + return await this.cataloguePromise; + } catch (error) { + this.cataloguePromise = null; // allow a retry after a transient failure + throw error; + } + } + + /** Attaches cover thumbnails to a slice of titles. `titles` accepts 50/request. */ + private async withCovers(titles: string[]): Promise { + const novels: Plugin.NovelItem[] = titles.map(title => ({ + name: title, + path: this.toPath(title), + cover: defaultCover, + })); + + for (let i = 0; i < titles.length; i += 50) { + const batch = titles.slice(i, i + 50); + try { + const json = await this.query({ + action: 'query', + titles: batch.join('|'), + prop: 'pageimages', + piprop: 'thumbnail', + pithumbsize: '300', + pilimit: '50', + }); + + const covers = new Map(); + for (const page of json.query?.pages ?? []) { + if (page.thumbnail?.source) + covers.set(page.title, page.thumbnail.source); + } + for (const novel of novels) { + const cover = covers.get(novel.name); + if (cover) novel.cover = cover; + } + } catch { + // Covers are cosmetic — a failed batch keeps the placeholder. + } + } + + return novels; + } + + /* ------------------------------------------------------------------ */ + /* Browse */ + /* ------------------------------------------------------------------ */ + + /** + * "Latest" maps to recently edited pages. Chapter edits are rolled up to + * their parent project, which is what actually signals a new release. + */ + private async latestTitles(): Promise { + const catalogue = await this.getCatalogue(); + const json = await this.query({ + action: 'query', + list: 'recentchanges', + rcnamespace: '0', + rclimit: '500', + rctype: 'edit|new', + rcprop: 'title', + }); + + const seen = new Set(); + const ordered: string[] = []; + + for (const change of json.query?.recentchanges ?? []) { + const parent = change.title.split(':')[0].trim(); + const project = this.catalogueSet.has(change.title) + ? change.title + : this.catalogueSet.has(parent) + ? parent + : null; + + if (project && !seen.has(project)) { + seen.add(project); + ordered.push(project); + } + } + + // Pad with the catalogue so the list never dead-ends on a quiet wiki. + for (const title of catalogue) { + if (!seen.has(title)) { + seen.add(title); + ordered.push(title); + } + } + + return ordered; + } + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + let titles = showLatestNovels + ? await this.latestTitles() + : [...(await this.getCatalogue())]; + + const status = filters?.status?.value; + if (status) { + titles = titles.filter(title => this.statusByTitle.get(title) === status); + } + + // Recency order is the point of the "latest" feed, so don't re-sort it. + if (!showLatestNovels && filters?.order?.value === 'desc') { + titles.reverse(); + } + + const start = (pageNo - 1) * this.pageSize; + if (start >= titles.length) return []; + + return this.withCovers(titles.slice(start, start + this.pageSize)); + } + + /* ------------------------------------------------------------------ */ + /* Search */ + /* ------------------------------------------------------------------ */ + + private normalize(value: string) { + return value + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + } + + /** + * Title-first relevance, so partial and out-of-order queries behave the way a + * reader expects. The wiki's own full-text search ranks any page mentioning + * the words, which buries the project page under its own chapters. + */ + private score(title: string, term: string): number { + const a = this.normalize(title); + const b = this.normalize(term); + if (!a || !b) return 0; + + if (a === b) return 1000; + if (a.startsWith(b)) return 900 - Math.min(a.length - b.length, 99); + if (a.includes(b)) return 800 - Math.min(a.length - b.length, 99); + + const queryTokens = b.split(' ').filter(Boolean); + const titleTokens = a.split(' ').filter(Boolean); + const matched = queryTokens.filter(token => + titleTokens.some(candidate => candidate.startsWith(token)), + ).length; + + if (matched === queryTokens.length) return 700 - Math.min(a.length, 99); + if (matched > 0) return Math.round((matched / queryTokens.length) * 500); + return 0; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const term = searchTerm.trim(); + if (!term) return []; + + const catalogue = await this.getCatalogue().catch(() => [] as string[]); + + // Deeper pages fall through to the wiki's full-text search, since the local + // ranked matches are exhausted by page one. + if (pageNo > 1) { + const json = await this.query({ + action: 'query', + list: 'search', + srsearch: term, + srnamespace: '0', + srlimit: String(this.pageSize), + sroffset: String((pageNo - 1) * this.pageSize), + }); + + const titles = (json.query?.search ?? []) + .map(result => result.title) + .filter(title => this.isNovelTitle(title)); + + return this.withCovers(titles); + } + + const scored = new Map(); + + for (const title of catalogue) { + const score = this.score(title, term); + if (score > 0) scored.set(title, score); + } + + // Network passes add anything the catalogue missed, at a lower base score. + const [prefix, fullText] = await Promise.all([ + this.query({ + action: 'query', + list: 'prefixsearch', + pssearch: term, + psnamespace: '0', + pslimit: '50', + }).catch(() => ({}) as MWResponse), + this.query({ + action: 'query', + list: 'search', + srsearch: term, + srnamespace: '0', + srlimit: '50', + }).catch(() => ({}) as MWResponse), + ]); + + const remote = [ + ...(prefix.query?.prefixsearch ?? []).map(r => r.title), + ...(fullText.query?.search ?? []).map(r => r.title), + ]; + + for (const title of remote) { + if (!this.isNovelTitle(title) || scored.has(title)) continue; + const score = this.score(title, term); + if (score > 0) scored.set(title, score - 50); + } + + const ranked = Array.from(scored.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, this.pageSize) + .map(([title]) => title); + + return this.withCovers(ranked); + } + + /** + * Chapter pages share namespace 0 with project pages. Catalogue membership is + * the reliable test; the structural pattern is the fallback so uncategorised + * projects stay findable. + */ + private isNovelTitle(title: string) { + if (this.catalogueSet.has(title)) return true; + // Outside the catalogue, any colon is treated as a sub-page marker. + // Enumerating chapter words per language does not scale — the wiki hosts + // "…:Tome 1 Chapitre 1", ":Tom 1 Rozdział 2", ":Tập 2", ":Act 1". Real + // colon-bearing projects are already covered by the catalogue check above. + if (title.includes(':')) return false; + return ( + !this.subPagePattern.test(title) && !this.nonEnglishPattern.test(title) + ); + } + + /* ------------------------------------------------------------------ */ + /* Novel */ + /* ------------------------------------------------------------------ */ + + async parseNovel(novelPath: string): Promise { + const requestedTitle = this.toTitle(novelPath); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: requestedTitle, + cover: defaultCover, + chapters: [], + }; + + const metaJson = await this.query({ + action: 'query', + titles: requestedTitle, + prop: 'extracts|pageimages|categories', + explaintext: '1', + exsectionformat: 'raw', + piprop: 'thumbnail', + pithumbsize: '400', + cllimit: 'max', + clshow: '!hidden', + redirects: '1', + }); + + const page = metaJson.query?.pages?.[0]; + if (!page || page.missing) { + novel.summary = 'This project is not available on Baka-Tsuki.'; + return novel; + } + + novel.name = page.title; + if (page.thumbnail?.source) novel.cover = page.thumbnail.source; + + const summary = this.extractSummary(page.extract); + if (summary) novel.summary = summary; + + const categories = (page.categories ?? []).map(category => category.title); + + const genres = categories + .map(category => category.match(this.genrePattern)?.[1]?.trim()) + .filter((genre): genre is string => Boolean(genre)); + if (genres.length) novel.genres = genres.join(','); + + novel.status = this.resolveStatus(categories, page.title); + + const author = this.resolveAuthor(categories); + if (author) novel.author = author; + + novel.chapters = await this.parseChapterList(page.title); + return novel; + } + + /** + * Project pages open with wiki housekeeping — a registration prompt, project + * status, translation notices — so the lead section is rarely the synopsis. + * Prefer an explicitly titled synopsis section and fall back to the first + * paragraph that isn't boilerplate. + */ + private extractSummary(extract?: string): string | undefined { + if (!extract) return undefined; + + const boilerplate = + /^(?:only available to registered users|register here|please read|this project|the project|as of |note:|warning:|attention|status:|translation|editing|recruit)|available in the following languages|this project has been|has been (?:restored|licensed|removed|discontinued|taken down)|do you (?:follow|want)/i; + + const sections = extract.split(/^\s*=+\s*(.+?)\s*=+\s*$/m); + // split() yields [lead, heading, body, heading, body, ...] + for (let i = 1; i < sections.length; i += 2) { + if (!/synopsis|summary|story|plot|description|about/i.test(sections[i])) { + continue; + } + const body = this.cleanParagraphs(sections[i + 1], boilerplate); + if (body) return body; + } + + return this.cleanParagraphs(sections[0], boilerplate); + } + + private cleanParagraphs( + text: string | undefined, + boilerplate: RegExp, + ): string | undefined { + if (!text) return undefined; + + const kept = text + .split(/\n+/) + .map(line => line.replace(/\s+/g, ' ').trim()) + .filter(line => line.length > 40 && !boilerplate.test(line)); + + if (!kept.length) return undefined; + + const summary = kept.join('\n\n'); + return summary.length > 1500 ? `${summary.slice(0, 1497)}...` : summary; + } + + private resolveStatus(categories: string[], title: string): string { + for (const category of categories) { + const name = category.replace(/^Category:/, ''); + const status = this.statusCategories[name]; + if (status) return status; + } + return this.statusByTitle.get(title) ?? NovelStatus.Unknown; + } + + /** + * Baka-Tsuki files projects under a category named after the author, mixed in + * with publisher imprints and structural tags. An omitted author is correct; + * a guessed one is not, so anything that doesn't look like a personal name is + * discarded. + */ + private resolveAuthor(categories: string[]): string | undefined { + for (const category of categories) { + const name = category.replace(/^Category:/, '').trim(); + if (this.genrePattern.test(category)) continue; + if (this.nonAuthorCategoryPattern.test(name)) continue; + if (/\d/.test(name)) continue; + if (name.split(/\s+/).length < 2) continue; + return name; + } + return undefined; + } + + /* ------------------------------------------------------------------ */ + /* Chapter list */ + /* ------------------------------------------------------------------ */ + + private async parseChapterList( + novelTitle: string, + ): Promise { + const json = await this.query({ + action: 'parse', + page: novelTitle, + prop: 'text', + disableeditsection: '1', + disabletoc: '1', + redirects: '1', + }).catch(() => ({}) as MWResponse); + + const html = json.parse?.text; + if (!html) return []; + + const $ = parseHTML(html); + + // A project's chapters are not always filed under its full page title: + // "Anohana: The Flower We Saw That Day" keeps its chapters at + // "Anohana:Part 1 Chapter 1". Accept the short form as well. + const fullTitle = novelTitle.replace(/_/g, ' '); + const prefixes = [`${fullTitle}:`]; + const shortTitle = fullTitle.split(':')[0].trim(); + if (shortTitle && shortTitle !== fullTitle) prefixes.push(`${shortTitle}:`); + + const seen = new Set(); + + type Candidate = { + title: string; + name: string; + volume: string; + isFullText: boolean; + }; + const candidates: Candidate[] = []; + + // Document order is what preserves reading order — the API's allpages list + // is alphabetical, which puts Chapter 10 before Chapter 2. + $('a[href]').each((_, element) => { + const anchor = $(element); + + const classes = (anchor.attr('class') ?? '').split(/\s+/); + if (classes.includes('new') || classes.includes('external')) return; + + const href = anchor.attr('href') ?? ''; + if (/^(?:https?:)?\/\//.test(href)) return; + if (/\.(?:pdf|epub|mobi|zip|rar|7z|docx?)$/i.test(href)) return; + if (/[?&]action=edit/.test(href)) return; + + const rawTitle = anchor.attr('title')?.trim(); + if (!rawTitle || /\(page does not exist\)$/i.test(rawTitle)) return; + + const title = rawTitle.replace(/_/g, ' '); + const prefix = prefixes.find(candidate => title.startsWith(candidate)); + if (!prefix) return; + if ( + /^(?:File|Image|Category|Template|Help|User|Talk|Special):/i.test(title) + ) { + return; + } + + const label = anchor.text().replace(/\s+/g, ' ').trim(); + const suffix = title.slice(prefix.length).trim(); + + // Project scaffolding lives under the same prefix as the chapters. + if (this.nonChapterPattern.test(suffix)) return; + + const path = this.toPath(title); + if (seen.has(path)) return; + seen.add(path); + + candidates.push({ + title, + name: label || suffix, + volume: suffix.match(/(?:Volume|Vol\.?)[\s_]*(\d+)/i)?.[1] ?? '', + // The whole-volume page is often titled "…:Volume 1" and only the link + // text says "Full Text", so both have to be checked. + isFullText: + /Full[\s_]*Text/i.test(suffix) || /^Full[\s_]*Text$/i.test(label), + }); + }); + + // A volume's "Full Text" page duplicates its chapters. Keep it only where + // there is nothing else to read for that volume. + const chaptersPerVolume = new Map(); + for (const candidate of candidates) { + if (!candidate.isFullText) { + chaptersPerVolume.set( + candidate.volume, + (chaptersPerVolume.get(candidate.volume) ?? 0) + 1, + ); + } + } + + const kept = candidates.filter( + candidate => + !candidate.isFullText || !chaptersPerVolume.get(candidate.volume), + ); + + const chapters: Plugin.ChapterItem[] = kept.map((candidate, index) => ({ + name: candidate.name, + path: this.toPath(candidate.title), + chapterNumber: index + 1, + page: candidate.volume ? `Volume ${candidate.volume}` : 'Other', + })); + + await this.attachReleaseTimes(chapters); + return chapters; + } + + /** + * Last-edit timestamps, batched 50 per request and capped — Baka-Tsuki is a + * small community server and a long project would otherwise fan out into + * dozens of calls. Best-effort: failures leave the field unset. + */ + private async attachReleaseTimes(chapters: Plugin.ChapterItem[]) { + const maxBatches = 4; + const byTitle = new Map( + chapters.map(chapter => [this.toTitle(chapter.path), chapter]), + ); + const titles = Array.from(byTitle.keys()).slice(0, maxBatches * 50); + + for (let i = 0; i < titles.length; i += 50) { + const batch = titles.slice(i, i + 50); + try { + const json = await this.query({ + action: 'query', + titles: batch.join('|'), + prop: 'revisions', + rvprop: 'timestamp', + }); + + for (const page of json.query?.pages ?? []) { + const timestamp = page.revisions?.[0]?.timestamp; + const chapter = byTitle.get(page.title); + if (timestamp && chapter) { + chapter.releaseTime = timestamp.slice(0, 10); + } + } + } catch { + return; + } + } + } + + /* ------------------------------------------------------------------ */ + /* Chapter */ + /* ------------------------------------------------------------------ */ + + async parseChapter(chapterPath: string): Promise { + // A deleted or renamed page is an API error, not an empty body. + const json = await this.query({ + action: 'parse', + page: this.toTitle(chapterPath), + prop: 'text', + disableeditsection: '1', + disabletoc: '1', + redirects: '1', + }).catch(() => ({}) as MWResponse); + + const html = json.parse?.text; + if (!html) return '

This chapter is not available on Baka-Tsuki.

'; + + const $ = parseHTML(html); + + $( + '.mw-editsection, #toc, .toc, .navbox, .printfooter, .catlinks, ' + + '.mw-jump-link, .noprint, #siteSub, .mw-empty-elt, #contentSub, ' + + '.mw-references-wrap ~ .navbox, script, style', + ).remove(); + + // Chapter navigation sits in a table at the top and bottom of most pages, + // but not reliably in the same position — match on content instead. + $('table').each((_, element) => { + const table = $(element); + if (/Back to|Return to|Forward to|Main Page/i.test(table.text())) { + table.remove(); + } + }); + + const origin = new URL(this.site).origin; + const absolute = (value: string) => + value.startsWith('//') + ? `https:${value}` + : value.startsWith('/') + ? origin + value + : value; + + $('img[src]').each((_, element) => { + const img = $(element); + img.attr('src', absolute(img.attr('src') ?? '')); + img.removeAttr('srcset'); + }); + + $('a[href]').each((_, element) => { + const anchor = $(element); + anchor.attr('href', absolute(anchor.attr('href') ?? '')); + }); + + const content = $.html().trim(); + return content || '

This chapter appears to be empty.

'; + } + + resolveUrl = (path: string) => + `${this.site}index.php?title=${encodeURIComponent(this.toTitle(path))}`; +} + +export default new BakaTsuki(); diff --git a/public/static/src/en/bakatsuki/icon.png b/public/static/src/en/bakatsuki/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5fe3859bc1de9db7d748795c622b45a9f5b6bbdf GIT binary patch literal 937 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBugD~Uq{1qt-49s?(E{-7;ac^hs?GkkqX#H;6aLidxFGL|wH~~E zonBp?_w@Pp(@$S74?lf%_w&;?Cm&y38~o0;X3MR`=4Sr4wzKS?zTRK+fxx!#UH|zw z8NP@l)ch~n7_+~${Hi+>!(LViHO05ej19>g273DQ-hG|O;Gpv00%zoei?d{3?si~e z2%K?|^R8v>1}i(&*vX5_ro}C|xg2OlP5r*8%SQ31b!;6r`)eyiMH|O&iO-uRx8UY~ zFUFl)+h6RB*W5RMy}$N>fAiMe{P^fkPH9cww8*LlzkmLk!ld)y%MabxN?GYITi-u_ z9n865_52s7#qVG}J0AzlZwc$n{@l?K68P}*@ad<|-u_;_*Sj-qVgt{& zid**>b(I!Hsw`-jrM&>CB*3G$C1cwEAO@?<>J4e~<}W{Lmc{HcTa-3!9>}n{4YE7g zi*|n1)ZP5$!1vEzGg&2emOGtH&3vu179_v5;ZE_tN&O42MMi!6TNBGODQLSE?vDzxH%L-`X=tdNV{xZ4~20;lRW1`YxLaLd|^1a^twyyQ%p%?!CO#F)Om_ z5zwCojJh{g?|&j>V&2Unl4PElxnAZX(6J7_KXyi{BryMST-NkrNpXb6x73_)XWfJO zGoQ{ERruGiX)1$|Ml`2FBi$Gp4l?|l5nEH@ Date: Tue, 4 Aug 2026 21:58:38 -0600 Subject: [PATCH 2/4] refactor: use the site's own light novel category as the catalogue Drops the union with the language-agnostic status categories. Those are now read only to populate the status map, so browse lists exactly the 160 titles in Category:Light novel (English), romanised as the wiki has them. Also rejects parenthesised translation siblings ("Fate/Zero (Bielarus)") from search results, which the colon rule did not catch. --- plugins/english/bakaTsuki.ts | 41 +++++++++++++----------------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/plugins/english/bakaTsuki.ts b/plugins/english/bakaTsuki.ts index 596cc97c2..2c3254605 100644 --- a/plugins/english/bakaTsuki.ts +++ b/plugins/english/bakaTsuki.ts @@ -38,18 +38,15 @@ class BakaTsuki implements Plugin.PluginBase { private readonly pageSize = 40; /** - * Categories that guarantee an English project. Members are trusted verbatim. + * The catalogue is exactly the wiki's own English light novel listing — + * titles verbatim, romanised as Baka-Tsuki writes them. */ - private readonly englishCategories = [ - 'Light novel (English)', - 'Web novel (English)', - ]; + private readonly catalogueCategory = 'Light novel (English)'; /** - * Status categories are language-agnostic, so they pull in translation - * siblings ("Absolute Duo - Français") alongside English projects that were - * never given a language tag. Members are kept only if they survive - * `nonEnglishPattern`. + * Read for status only, not for catalogue membership. These are + * language-agnostic and would otherwise pull in translation siblings + * ("Absolute Duo - Français"). */ private readonly statusCategories: Record = { 'Active Projects': NovelStatus.Ongoing, @@ -172,17 +169,10 @@ class BakaTsuki implements Plugin.PluginBase { /* Catalogue */ /* ------------------------------------------------------------------ */ - private isEnglishProject(title: string) { - return ( - !this.nonEnglishPattern.test(title) && !this.subPagePattern.test(title) - ); - } - /** - * The wiki has no single "all English novels" category, so the catalogue is - * assembled from the language-tagged categories (trusted as-is) plus the - * status categories with non-English siblings filtered out. Cached for the - * session — it costs seven requests to build. + * The catalogue is the wiki's English light novel category, taken as-is. + * Status categories are read alongside it purely to populate the status map. + * Cached for the session — six requests to build. */ private async getCatalogue(): Promise { if (this.cataloguePromise) return this.cataloguePromise; @@ -190,20 +180,15 @@ class BakaTsuki implements Plugin.PluginBase { this.cataloguePromise = (async () => { const titles = new Set(); - for (const category of this.englishCategories) { - for (const title of await this.categoryMembers(category)) { - if (!this.subPagePattern.test(title)) titles.add(title); - } + for (const title of await this.categoryMembers(this.catalogueCategory)) { + if (!this.subPagePattern.test(title)) titles.add(title); } for (const [category, status] of Object.entries(this.statusCategories)) { for (const title of await this.categoryMembers(category)) { - // Status is recorded even for titles the language filter rejects, so - // a direct visit to a translation sibling still shows its status. if (!this.statusByTitle.has(title)) { this.statusByTitle.set(title, status); } - if (this.isEnglishProject(title)) titles.add(title); } } @@ -453,6 +438,10 @@ class BakaTsuki implements Plugin.PluginBase { // "…:Tome 1 Chapitre 1", ":Tom 1 Rozdział 2", ":Tập 2", ":Act 1". Real // colon-bearing projects are already covered by the catalogue check above. if (title.includes(':')) return false; + // Translation siblings are parenthesised ("Fate/Zero (Biełaruś)", + // "Absolute Duo (Swedish)"). Rejecting the bracket outright beats naming + // every language; catalogue members are already exempt. + if (/\([^)]+\)\s*$/.test(title)) return false; return ( !this.subPagePattern.test(title) && !this.nonEnglishPattern.test(title) ); From be7b09497238f6aa7748067b073fc16641d3ce0f Mon Sep 17 00:00:00 2001 From: Patrick Date: Tue, 4 Aug 2026 22:08:17 -0600 Subject: [PATCH 3/4] chore: use the Baka-Tsuki mascot as the plugin icon --- public/static/src/en/bakatsuki/icon.png | Bin 937 -> 14289 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/public/static/src/en/bakatsuki/icon.png b/public/static/src/en/bakatsuki/icon.png index 5fe3859bc1de9db7d748795c622b45a9f5b6bbdf..1486c0022112e2e4b847a8e133a70eb9bec09705 100644 GIT binary patch literal 14289 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 Date: Tue, 4 Aug 2026 22:23:55 -0600 Subject: [PATCH 4/4] feat: sort Other last and hide empty projects from search Chapters sort by volume ascending with ungrouped entries last. Projects with no sub-pages are hidden from search, verified per-hit so a revived project un-hides itself. --- plugins/english/bakaTsuki.ts | 128 ++++++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 9 deletions(-) diff --git a/plugins/english/bakaTsuki.ts b/plugins/english/bakaTsuki.ts index 2c3254605..7991d15e5 100644 --- a/plugins/english/bakaTsuki.ts +++ b/plugins/english/bakaTsuki.ts @@ -21,6 +21,7 @@ type MWResponse = { search?: { title: string }[]; prefixsearch?: { title: string }[]; recentchanges?: { title: string }[]; + allpages?: { title: string }[]; }; parse?: { title: string; text: string }; continue?: Record; @@ -30,7 +31,7 @@ type MWResponse = { class BakaTsuki implements Plugin.PluginBase { id = 'bakatsuki'; name = 'Baka-Tsuki'; - version = '1.0.0'; + version = '1.1.0'; icon = 'src/en/bakatsuki/icon.png'; site = 'https://www.baka-tsuki.org/project/'; @@ -83,6 +84,50 @@ class BakaTsuki implements Plugin.PluginBase { private readonly nonAuthorCategoryPattern = /^(?:Light novel|Web novel|Original light novel|Visual novel|Audio novel|Genre|Hosted|Active|Completed|Inactive|Stalled|Teaser|Licensed|Pages? |Candidates|Articles|Project|Series|Novel|Manga)|(?:Bunko|Books|Publishing|Shoten|Shuppan|Kadokawa|Shueisha|Kodansha|Shogakukan|ASCII|Media Factory|Enterbrain|Hobby Japan|Overlap|SoftBank|Fujimi|Dengeki|Gagaga|Sneaker|Ichijinsha|Earth Star|Micro Magazine|TO Books|Alphapolis|Famitsu|Fantasia|Dash|Kobunsha|Takeshobo|Houbunsha|Media Works)/i; + /** + * Catalogue entries that had no sub-pages at all when last surveyed — nothing + * to read, so they are hidden from search. Treated as *suspicion* only: each + * is re-checked once per session before being hidden, so a project that gains + * chapters reappears on its own rather than staying hidden until this list is + * regenerated. + */ + private readonly possiblyEmpty = new Set([ + 'Allison', + 'Ark', + 'Clotaku Club!', + 'CtG—Zero Kara Sodateru Dennou Shoujo', + 'Etsusa Bridge', + 'Famima!', + 'Hikaru ga Chikyuu ni Itakoro......', + 'Hitotsu no Tairiki no Monogatari', + "I'm a High School Boy and a Bestselling Light Novel author, strangled by my female classmate who is my junior and a voice actress", + 'Kamisu Reina Series', + 'Kill No More', + 'Lillia to Treize', + 'Maru-MA', + 'Meg to Seron', + 'Mushi to Medama', + 'Ojamajo Doremi 16', + 'Onii-chan Dakedo Ai Sae Areba Kankei Nai yo ne—', + 'Ore ga Ojou-Sama Gakkou ni ‘Shomin Sample’ Toshite Usarareta Ken', + 'Ore no Kanojo to Osananajimi ga Shuraba Sugiru ~Brazilian Portuguese~', + 'Ore no Nounai Sentakushi ga, Gakuen Love Come o Zenryoku de Jama Shiteiru', + 'Puppetmaster', + 'Remembrances for a certain pilot', + 'Sayonara Piano Sonata', + 'Sekai Ichi no Imouto-sama', + 'Sword of the Emperor', + 'Tabi ni Deyou, Horobiyuku Sekai no Hate Made', + 'Tsuki Tsuki!', + 'Tsukumodo Antique Shop', + 'Una Simple Revisión en Español', + 'Vamp!', + "We Don't Open Anywhere -There are no facts, only interpretations.-", + 'White Album 2 Omake', + ]); + + private contentCache = new Map(); + private cataloguePromise: Promise | null = null; private catalogue: string[] = []; private catalogueSet = new Set(); @@ -379,7 +424,7 @@ class BakaTsuki implements Plugin.PluginBase { .map(result => result.title) .filter(title => this.isNovelTitle(title)); - return this.withCovers(titles); + return this.withCovers(await this.dropEmptyProjects(titles)); } const scored = new Map(); @@ -423,7 +468,7 @@ class BakaTsuki implements Plugin.PluginBase { .slice(0, this.pageSize) .map(([title]) => title); - return this.withCovers(ranked); + return this.withCovers(await this.dropEmptyProjects(ranked)); } /** @@ -431,6 +476,60 @@ class BakaTsuki implements Plugin.PluginBase { * the reliable test; the structural pattern is the fallback so uncategorised * projects stay findable. */ + /** + * A handful of catalogue entries are project pages with no chapters behind + * them, which are noise in search results. Verified with one cheap existence + * probe, and only for titles already on the suspicion list — so a typical + * search costs no extra requests. + */ + private async hasReadableContent(title: string): Promise { + // The short form is a superset: sub-pages of the full title begin with it + // too, and it also covers projects filed under a shortened prefix. + const prefix = `${title.split(':')[0].trim()}:`; + + try { + const json = await this.query({ + action: 'query', + list: 'allpages', + apprefix: prefix, + apnamespace: '0', + aplimit: '1', + }); + const hasContent = (json.query?.allpages?.length ?? 0) > 0; + this.contentCache.set(title, hasContent); + return hasContent; + } catch { + return true; // Never hide a novel because a probe failed. + } + } + + /** + * A catalogue entry not on the suspicion list is known-good and costs nothing. + * Everything else gets probed: suspicion-list entries so a revived project + * un-hides itself, and non-catalogue hits because their provenance is unknown + * — that is how the redirect "Vamp" → "Vamp!" and stray help pages get caught. + */ + private async dropEmptyProjects(titles: string[]): Promise { + const probeBudget = 12; + let probes = 0; + + const verdicts = await Promise.all( + titles.map(title => { + if (this.catalogueSet.has(title) && !this.possiblyEmpty.has(title)) { + return true; + } + const cached = this.contentCache.get(title); + if (cached !== undefined) return cached; + // Bounded so an odd query can't fan out into dozens of requests. + if (probes >= probeBudget) return true; + probes++; + return this.hasReadableContent(title); + }), + ); + + return titles.filter((_, index) => verdicts[index]); + } + private isNovelTitle(title: string) { if (this.catalogueSet.has(title)) return true; // Outside the catalogue, any colon is treated as a sub-page marker. @@ -673,12 +772,23 @@ class BakaTsuki implements Plugin.PluginBase { !candidate.isFullText || !chaptersPerVolume.get(candidate.volume), ); - const chapters: Plugin.ChapterItem[] = kept.map((candidate, index) => ({ - name: candidate.name, - path: this.toPath(candidate.title), - chapterNumber: index + 1, - page: candidate.volume ? `Volume ${candidate.volume}` : 'Other', - })); + // Volumes ascending, then everything ungrouped last, so the list reads + // Volume 1 → 2 → 3 → Other. Document order is preserved within each group. + const chapters: Plugin.ChapterItem[] = kept + .map((candidate, index) => ({ candidate, index })) + .sort((a, b) => { + const left = a.candidate.volume ? Number(a.candidate.volume) : Infinity; + const right = b.candidate.volume + ? Number(b.candidate.volume) + : Infinity; + return left === right ? a.index - b.index : left - right; + }) + .map(({ candidate }, index) => ({ + name: candidate.name, + path: this.toPath(candidate.title), + chapterNumber: index + 1, + page: candidate.volume ? `Volume ${candidate.volume}` : 'Other', + })); await this.attachReleaseTimes(chapters); return chapters;