From e4a23fe42dc60814473ceffc9fdd3b95ac0ec398 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sat, 17 Oct 2026 21:24:48 +0800 Subject: [PATCH 1/2] feat(english): add Tensurafan source --- plugins/english/tensurafan.ts | 329 +++++++++++++++++++++++ public/static/src/en/tensurafan/icon.png | Bin 0 -> 9683 bytes 2 files changed, 329 insertions(+) create mode 100644 plugins/english/tensurafan.ts create mode 100644 public/static/src/en/tensurafan/icon.png diff --git a/plugins/english/tensurafan.ts b/plugins/english/tensurafan.ts new file mode 100644 index 000000000..7b34a0621 --- /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, 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 0000000000000000000000000000000000000000..27d44d31754cef72ce5cf7e8c115bfbd9e0d5e97 GIT binary patch literal 9683 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 Xusz#HyoMw6tO1}Pqbyw`X&m@J(R?Gz literal 0 HcmV?d00001 From e56ddef4336d480a0c9a8fe7d41affc2fbc8c0fd Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Sun, 18 Oct 2026 08:30:51 +0800 Subject: [PATCH 2/2] fix(english/tensurafan): strip iframe, object and embed from chapter HTML Co-Authored-By: Claude Sonnet 5.5 --- plugins/english/tensurafan.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/english/tensurafan.ts b/plugins/english/tensurafan.ts index 7b34a0621..bb7f84107 100644 --- a/plugins/english/tensurafan.ts +++ b/plugins/english/tensurafan.ts @@ -280,7 +280,7 @@ class TensurafanPlugin implements Plugin.PluginBase { const $ = loadCheerio(body, null, false); // The full-page title card repeats the heading that follows it as text. - $('script, style, noscript, p.pagebreak').remove(); + $('script, style, noscript, iframe, object, embed, p.pagebreak').remove(); $('div:has(> img[src*="/title_cards/"])').remove(); $('img').each((_, el) => { const img = $(el);