feat: add Baka-Tsuki plugin (english) (#2380)
This commit is contained in:
@@ -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();
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Reference in New Issue
Block a user