895 lines
31 KiB
TypeScript
895 lines
31 KiB
TypeScript
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();
|