feat: add Baka-Tsuki plugin (english) (#2380)

This commit is contained in:
pdmacinnes
2026-08-08 03:56:37 -06:00
committed by GitHub
parent e90f066c59
commit 84b61764dd
2 changed files with 894 additions and 0 deletions
+894
View File
@@ -0,0 +1,894 @@
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 }[];
allpages?: { title: string }[];
};
parse?: { title: string; text: string };
continue?: Record<string, string>;
error?: { code: string; info: string };
};
class BakaTsuki implements Plugin.PluginBase {
id = 'bakatsuki';
name = 'Baka-Tsuki';
version = '1.1.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;
/**
* The catalogue is exactly the wiki's own English light novel listing —
* titles verbatim, romanised as Baka-Tsuki writes them.
*/
private readonly catalogueCategory = 'Light novel (English)';
/**
* 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<string, string> = {
'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;
/**
* 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<string, boolean>();
private cataloguePromise: Promise<string[]> | null = null;
private catalogue: string[] = [];
private catalogueSet = new Set<string>();
private statusByTitle = new Map<string, string>();
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<string, string>): Promise<MWResponse> {
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<string[]> {
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 */
/* ------------------------------------------------------------------ */
/**
* 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<string[]> {
if (this.cataloguePromise) return this.cataloguePromise;
this.cataloguePromise = (async () => {
const titles = new Set<string>();
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)) {
if (!this.statusByTitle.has(title)) {
this.statusByTitle.set(title, status);
}
}
}
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<Plugin.NovelItem[]> {
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<string, string>();
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<string[]> {
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<string>();
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<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
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<Plugin.NovelItem[]> {
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(await this.dropEmptyProjects(titles));
}
const scored = new Map<string, number>();
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(await this.dropEmptyProjects(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.
*/
/**
* 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<boolean> {
// 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<string[]> {
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.
// 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;
// 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)
);
}
/* ------------------------------------------------------------------ */
/* Novel */
/* ------------------------------------------------------------------ */
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
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<Plugin.ChapterItem[]> {
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<string>();
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<string, number>();
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),
);
// 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;
}
/**
* 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<string> {
// 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 '<p>This chapter is not available on Baka-Tsuki.</p>';
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 || '<p>This chapter appears to be empty.</p>';
}
resolveUrl = (path: string) =>
`${this.site}index.php?title=${encodeURIComponent(this.toTitle(path))}`;
}
export default new BakaTsuki();