diff --git a/plugins/english/tensurafan.ts b/plugins/english/tensurafan.ts new file mode 100644 index 000000000..bb7f84107 --- /dev/null +++ b/plugins/english/tensurafan.ts @@ -0,0 +1,329 @@ +import { load as loadCheerio } from 'cheerio'; +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; +import { storage } from '@libs/storage'; + +type Volume = { + id: string; + name: string; + path: string; + progress?: number; +}; + +type Term = { + options: string[]; + officialTermIndex: number; + textToSpeechIndex: number; +}; + +type TermSet = 'default' | 'official' | 'tts'; + +type Section = { + anchor: string; + title: string; +}; + +// The title of the only series the site hosts. +const NOVEL_NAME = 'That Time I Got Reincarnated as a Slime'; +// A volume's table of contents sits in its first few KB; the rest of the file +// is the whole volume's text, which only a chapter fetch needs. +const TOC_RANGE = 'bytes=0-23999'; + +const unescapeAttr = (value: string) => + value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); + +const escapeText = (value: string) => + value.replace(/&/g, '&').replace(//g, '>'); + +// Mirrors the site's own `capitalCase` helper from its reader controller. +const capitalCase = (phrase: string, everyWord: boolean) => + phrase + .toLowerCase() + .split(' ') + .map((word, index) => + word && (everyWord || index === 0) + ? word[0].toUpperCase() + word.slice(1) + : word, + ) + .join(' '); + +class TensurafanPlugin implements Plugin.PluginBase { + id = 'tensurafan'; + name = 'Tensurafan'; + icon = 'src/en/tensurafan/icon.png'; + site = 'https://tensurafan.github.io'; + version = '1.0.0'; + + pluginSettings = { + termSet: { + value: 'default', + label: + 'Names and terms (the same choices as "Set All Terms to ..." under Extras > Manage Selectable terms on the site; applies to chapters opened after changing it)', + type: 'Select', + options: [ + { label: 'Default terms', value: 'default' }, + { label: 'Official terms', value: 'official' }, + { label: 'Text-to-speech friendly terms', value: 'tts' }, + ], + }, + }; + + private volumeCache?: { path: string; html: string }; + + private async fetchOk(url: string, headers?: Record) { + const res = await fetchApi(url, headers ? { headers } : undefined); + if (!res.ok) { + throw new Error(`Tensurafan returned HTTP ${res.status} for ${url}`); + } + return res; + } + + private getVolumeList(): Promise { + return this.fetchOk(this.site + '/ln/volumes.json').then(r => r.json()); + } + + private async getVolumes(): Promise { + // The site lists its in-progress volume with the previous volume's file; + // keep only the first entry for each file. + const seen: Record = {}; + return (await this.getVolumeList()).filter(volume => { + if (seen[volume.path]) return false; + seen[volume.path] = true; + return true; + }); + } + + private getTermSet(): TermSet { + const value = storage.get('termSet'); + return value === 'official' || value === 'tts' ? value : 'default'; + } + + /** + * The site renders every name as a `` holding a template + * that picks the reader's chosen term for it. Replace each one with the + * term from the selected set (the key itself is the site's default). + */ + private applyTerms( + html: string, + terms?: Record, + set?: TermSet, + ) { + const pick = (key: string) => { + const term = terms && terms[key]; + if (!term || !set || set === 'default') return key; + const index = + set === 'official' ? term.officialTermIndex : term.textToSpeechIndex; + return term.options[index] || key; + }; + return html.replace( + /\{:\s*([^]*?)\s*:\}\|\{[^}]*\}\|<\/span>/g, + (_, rawKey: string, expression: string) => { + const chosen = pick(unescapeAttr(rawKey)); + let text = chosen; + if (/toLowerCase\(\)/.test(expression)) { + text = chosen.toLowerCase(); + } else if (/capitalCase\(.*,\s*true\s*\)/.test(expression)) { + text = capitalCase(chosen, true); + } else if (/capitalCase\(/.test(expression)) { + text = capitalCase(chosen, false); + } + return escapeText(text); + }, + ); + } + + private parseSections(html: string): Section[] { + const $ = loadCheerio(this.applyTerms(html)); + const sections: Section[] = []; + $('p.toc-chapter, p.toc-chapter-space').each((_, el) => { + const link = $(el).find('a.hlink').first(); + const anchor = (link.attr('href') || '').replace(/^#/, ''); + const title = link.text().replace(/\s+/g, ' ').trim(); + if (anchor && title) sections.push({ anchor, title }); + }); + return sections; + } + + private async getVolumeHtml(volume: Volume): Promise { + if (this.volumeCache && this.volumeCache.path === volume.path) { + return this.volumeCache.html; + } + const html = await this.fetchOk(this.site + volume.path).then(r => + r.text(), + ); + this.volumeCache = { path: volume.path, html }; + return html; + } + + /** Start of the element carrying `id`, including a lone wrapping `
`. */ + private sectionStart(html: string, anchor: string): number { + const at = html.indexOf(`id="${anchor}"`); + if (at < 0) return -1; + let start = html.lastIndexOf('<', at); + if (html.slice(start - 5, start) === '
') start -= 5; + return start; + } + + async popularNovels(pageNo: number): Promise { + if (pageNo > 1) return []; + return [await this.getNovelItem()]; + } + + private async getNovelItem(): Promise { + const [first] = await this.getVolumes(); + return { + name: NOVEL_NAME, + path: '/', + cover: first ? await this.getCover(first) : defaultCover, + }; + } + + private async getCover(volume: Volume): Promise { + const head = await this.fetchOk(this.site + volume.path, { + Range: TOC_RANGE, + }).then(r => r.text()); + const src = /]*\ssrc="([^"{]+)"/.exec(head); + return src ? this.site + encodeURI(unescapeAttr(src[1])) : defaultCover; + } + + async parseNovel(novelPath: string): Promise { + const listed = await this.getVolumeList(); + const volumes = await this.getVolumes(); + const heads = await Promise.all( + volumes.map(volume => + this.fetchOk(this.site + volume.path, { Range: TOC_RANGE }).then(r => + r.text(), + ), + ), + ); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: NOVEL_NAME, + cover: defaultCover, + status: listed.some(volume => (volume.progress ?? 100) < 100) + ? NovelStatus.Ongoing + : NovelStatus.Completed, + }; + + const credits = loadCheerio(heads[0] || ''); + credits('p.credits-entry-space, p.credits-entry').each((_, el) => { + const text = credits(el).text(); + const author = /^Author:\s*(.+)$/.exec(text); + const artist = /^Illustration:\s*(.+)$/.exec(text); + if (author) novel.author = author[1].trim(); + if (artist) novel.artist = artist[1].trim(); + }); + const cover = /]*\ssrc="([^"{]+)"/.exec(heads[0] || ''); + if (cover) novel.cover = this.site + encodeURI(unescapeAttr(cover[1])); + + const chapters: Plugin.ChapterItem[] = []; + volumes.forEach((volume, index) => { + this.parseSections(heads[index]).forEach(section => { + chapters.push({ + name: `${volume.name} - ${section.title}`, + path: `/read/${volume.id}#${section.anchor}`, + chapterNumber: chapters.length + 1, + }); + }); + }); + novel.chapters = chapters; + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const [, volumeId, anchor] = + /^\/read\/([^#]+)#(.+)$/.exec(chapterPath) || []; + if (!volumeId) throw new Error('Unrecognised chapter path ' + chapterPath); + + const volume = (await this.getVolumes()).find(v => v.id === volumeId); + if (!volume) throw new Error('Unknown volume ' + volumeId); + + const html = await this.getVolumeHtml(volume); + const sections = this.parseSections(html.slice(0, 24000)); + const position = sections.findIndex(s => s.anchor === anchor); + const start = this.sectionStart(html, anchor); + if (position < 0 || start < 0) { + throw new Error(`Section ${anchor} not found in ${volume.name}`); + } + const next = sections[position + 1]; + const end = next ? this.sectionStart(html, next.anchor) : -1; + let body = html.slice(start, end > start ? end : undefined); + + const set = this.getTermSet(); + let terms: Record | undefined; + if (set !== 'default') { + terms = await this.fetchOk(this.site + '/ln/terms.json') + .then(r => r.json()) + .then(data => data.terms); + } + body = this.applyTerms(body, terms, set) + // Illustrations are templated on the site's "coloured illustrations" + // option, which is on by default: use the coloured file it names. + .replace( + /(src="[^"{]*)\{:[^?]*\?\s*'([^']*)'\s*:\s*'[^']*'\s*:\}\|\{[^}]*\}\|/g, + '$1$2', + ) + // The footnote icon keeps its text in an attribute and shows it on tap. + .replace( + /]*>[^]*?<\/span><\/span>/g, + '[$1]', + ) + .replace(/
[^]*?<\/div>/g, '
'); + + const $ = loadCheerio(body, null, false); + // The full-page title card repeats the heading that follows it as text. + $('script, style, noscript, iframe, object, embed, p.pagebreak').remove(); + $('div:has(> img[src*="/title_cards/"])').remove(); + $('img').each((_, el) => { + const img = $(el); + const src = img.attr('src'); + if (src && !/^[a-z][a-z\d+.-]*:/i.test(src)) { + img.attr('src', this.site + encodeURI(src)); + } + }); + // The app renders this HTML as is, so drop event handlers and URLs that + // would run script. + $('*').each((_, el) => { + const node = $(el); + for (const name of Object.keys(el.attribs)) { + if (/^on/i.test(name)) { + node.removeAttr(name); + } else if (name === 'href' || name === 'src') { + const value = el.attribs[name] + .split('') + .filter(c => c.charCodeAt(0) > 32) + .join('') + .toLowerCase(); + if ( + /^(javascript|vbscript):/.test(value) || + (value.startsWith('data:') && !value.startsWith('data:image/')) + ) { + node.removeAttr(name); + } + } + } + }); + return $.html(); + } + + async searchNovels(searchTerm: string): Promise { + const query = searchTerm.trim().toLowerCase(); + if (!query) return []; + const haystack = `${NOVEL_NAME} Tensura ${this.name}`.toLowerCase(); + const matches = query.split(/\s+/).every(word => haystack.includes(word)); + return matches ? [await this.getNovelItem()] : []; + } + + resolveUrl = (path: string, isNovel?: boolean) => + this.site + (isNovel ? '/' : path); +} + +export default new TensurafanPlugin(); diff --git a/public/static/src/en/tensurafan/icon.png b/public/static/src/en/tensurafan/icon.png new file mode 100644 index 000000000..27d44d317 Binary files /dev/null and b/public/static/src/en/tensurafan/icon.png differ