From 85580b40e41ff39d9eb2ea66d63e92adbe0847be Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Mon, 12 Oct 2026 20:41:56 +0800 Subject: [PATCH 1/5] feat(english/toasteful): add Toasteful source plugin Implements popular/latest/search, novel details, chapter list and chapter content for https://www.toasteful.com/ (English, Blogger), using the site's own Web Novel label feed for listings and search. Closes #458. Test evidence: npm run check:plugin passes 4/4 (popular 25 novels, search, parseNovel 12 chapters, parseChapter 13698 chars); extended live assertions 23/23 incl. 395-chapter hub, volume prefixes, side-route exclusion and chapter cleanup. Co-Authored-By: Muse Spark --- plugins/english/toasteful.ts | 329 ++++++++++++++++++++++++ public/static/src/en/toasteful/icon.png | Bin 0 -> 9360 bytes 2 files changed, 329 insertions(+) create mode 100644 plugins/english/toasteful.ts create mode 100644 public/static/src/en/toasteful/icon.png diff --git a/plugins/english/toasteful.ts b/plugins/english/toasteful.ts new file mode 100644 index 000000000..9a2b53672 --- /dev/null +++ b/plugins/english/toasteful.ts @@ -0,0 +1,329 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { load as loadCheerio } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; + +type FeedLink = { rel: string; href: string }; +type FeedEntry = { + title: { $t: string }; + link: FeedLink[]; + published?: { $t: string }; + updated?: { $t: string }; + category?: { term: string }[]; + content?: { $t: string }; +}; +type FeedResponse = { + feed?: { + openSearch$totalResults?: { $t: string }; + entry?: FeedEntry[]; + }; +}; + +const NOVEL_LABEL = 'Web Novel'; +const PAGE_SIZE = 25; +const CHAPTER_PATH = /^\/\d{4}\/\d{2}\/[^/]+\.html$/; +const PLACEHOLDER_COVER = 'No_image_available'; + +class Toasteful implements Plugin.PluginBase { + id = 'toasteful'; + name = 'Toasteful'; + site = 'https://www.toasteful.com'; + icon = 'src/en/toasteful/icon.png'; + version = '1.0.0'; + + filters: Filters = { + genres: { + label: 'Genres', + value: [], + options: [ + 'Action', + 'Adventure', + 'Comedy', + 'Drama', + 'Ecchi', + 'Fantasy', + 'Harem', + 'Isekai', + 'Reincarnation', + 'Romance', + 'Slice of Life', + 'Slow Life', + 'Supernatural', + 'Overpowered', + 'Villain', + ].map(label => ({ label, value: label })), + type: FilterTypes.CheckboxGroup, + }, + }; + + private async request(url: string) { + const response = await fetchApi(url); + if (!response.ok) { + throw Object.assign(new Error(`Toasteful: HTTP ${response.status}.`), { + status: response.status, + }); + } + return response; + } + + private feedUrl(labels: string[], params: Record): string { + const path = labels.map(label => encodeURIComponent(label)).join('/'); + const query = new URLSearchParams({ + 'alt': 'json', + ...params, + }).toString(); + return `${this.site}/feeds/posts/default/-/${path}?${query}`; + } + + private entryToNovel(entry: FeedEntry): Plugin.NovelItem | undefined { + const href = entry.link?.find(link => link.rel === 'alternate')?.href; + if (!href) return undefined; + let cover: string | undefined; + const html = entry.content?.$t; + if (html) { + const src = loadCheerio(html)('img').first().attr('src'); + if (src && !src.includes(PLACEHOLDER_COVER)) cover = src; + } + return { + name: entry.title.$t.replace(/^\(NEW!!!\)\s*/, '').trim(), + path: new URL(this.resolveUrl(href)).pathname, + cover: cover || defaultCover, + }; + } + + async popularNovels( + pageNo: number, + { + showLatestNovels, + filters, + }: Plugin.PopularNovelsOptions, + ): Promise { + const labels = [NOVEL_LABEL, ...(filters?.genres?.value || [])]; + const params: Record = { + 'max-results': String(PAGE_SIZE), + 'start-index': String((pageNo - 1) * PAGE_SIZE + 1), + }; + if (showLatestNovels) params['orderby'] = 'updated'; + const data: FeedResponse = await ( + await this.request(this.feedUrl(labels, params)) + ).json(); + const entries = data.feed?.entry || []; + const novels: Plugin.NovelItem[] = []; + for (const entry of entries) { + const novel = this.entryToNovel(entry); + if (novel) novels.push(novel); + } + return novels; + } + + async parseNovel(novelPath: string): Promise { + const url = this.resolveUrl(novelPath); + const $ = loadCheerio(await (await this.request(url)).text()); + const name = + $('h3.post-title').text().trim() || + $('meta[property="og:title"]').attr('content')?.trim() || + 'Untitled'; + + const coverSrc = + $('link[rel="image_src"]').attr('href') || + $('.post-body img').first().attr('src'); + const cover = + coverSrc && !coverSrc.includes(PLACEHOLDER_COVER) + ? coverSrc + : defaultCover; + + const labels = $('span.post-labels a[rel="tag"]') + .toArray() + .map(el => $(el).text().trim()) + .filter(Boolean); + const statusLabel = labels + .find(label => /completed|on-?going|dropped|hiatus/i.test(label)) + ?.toLowerCase(); + let status: string | undefined; + if (statusLabel) { + if (/completed/.test(statusLabel)) status = NovelStatus.Completed; + else if (/on-?going/.test(statusLabel)) status = NovelStatus.Ongoing; + else if (/dropped/.test(statusLabel)) status = NovelStatus.Cancelled; + else if (/hiatus/.test(statusLabel)) status = NovelStatus.OnHiatus; + } + const normalize = (text: string) => + text.toLowerCase().replace(/[^a-z0-9]/g, ''); + const novelLabel = normalize(name); + const genres = labels + .filter( + label => + !/completed|on-?going|dropped|hiatus/i.test(label) && + label.toLowerCase() !== NOVEL_LABEL.toLowerCase() && + label.toLowerCase() !== 'light novel' && + normalize(label) !== novelLabel, + ) + .join(', '); + + const authorText = $('.post-body') + .find('b') + .toArray() + .map(el => $(el).parent().text()) + .find(text => /^author:/i.test(text.trim())); + const author = authorText + ? authorText + .trim() + .replace(/^author:\s*/i, '') + .trim() || undefined + : undefined; + + const body = $('.post-body').first(); + let summary = ''; + const article = body.find('article').first(); + if (article.length) { + const synopsis = article.clone(); + synopsis.find('b').each((_, el) => { + if (/^synopsis:/i.test($(el).text().trim())) $(el).remove(); + }); + summary = synopsis.text().trim(); + } else { + const paragraphs: string[] = []; + let inChapters = false; + body.find('p, a[href]').each((_, el) => { + const node = $(el); + if (node.is('a')) { + if (this.isChapterLink(node.attr('href'), url, node.text())) + inChapters = true; + return; + } + if (inChapters) return; + const text = node.text().trim(); + if (!text || /^genre:/i.test(text) || /raw source:/i.test(text)) return; + paragraphs.push(text); + }); + summary = paragraphs.join('\n\n'); + } + + const chapters: Plugin.ChapterItem[] = []; + const seen = new Set(); + let volume: string | undefined; + body.find('b, a[href]').each((_, el) => { + const node = $(el); + if (node.is('a')) { + const href = node.attr('href'); + if (!this.isChapterLink(href, url, node.text())) return; + const chapterPath = new URL(href!, this.site).pathname; + if (seen.has(chapterPath)) return; + seen.add(chapterPath); + const chapterName = node.text().trim().replace(/\s+/g, ' '); + if (!chapterName) return; + const number = /chapter\s+(\d+(?:\.\d+)?)/i.exec(chapterName)?.[1]; + chapters.push({ + name: volume ? `${volume} - ${chapterName}` : chapterName, + path: chapterPath, + chapterNumber: number ? Number(number) : undefined, + }); + return; + } + if (node.find('a[href]').length) return; + const text = node.text().trim().replace(/\s+/g, ' '); + if (/^(volume|afterstory)\b/i.test(text)) volume = text; + }); + + if (!chapters.length) + throw new Error('Toasteful: no chapters were found on this page.'); + + return { + name, + path: new URL(url).pathname, + cover, + summary: summary || undefined, + author, + genres: genres || undefined, + status, + chapters, + }; + } + + private isChapterLink( + href: string | undefined, + pageUrl: string, + linkText: string, + ): boolean { + if (!href) return false; + let path: string; + try { + const parsed = new URL(href, this.site); + if (parsed.origin !== this.site) return false; + path = parsed.pathname; + } catch { + return false; + } + if (!CHAPTER_PATH.test(path)) return false; + if (path === new URL(pageUrl).pathname) return false; + // Side-story hubs are linked as bare "here" pointers, not chapters. + if (linkText.trim().toLowerCase() === 'here') return false; + return true; + } + + async parseChapter(chapterPath: string): Promise { + const url = this.resolveUrl(chapterPath); + const $ = loadCheerio(await (await this.request(url)).text()); + const content = $('.content-post').first().length + ? $('.content-post').first() + : $('.post-body').first(); + content.find('script,style,iframe,form,noscript,.ChapterNav').remove(); + content + .find('.reader-toolbar,.js-notice,.adsbygoogle,[class*="ad-"]') + .remove(); + content.find('a[href*="docs.google.com/forms"]').remove(); + const junk = + /hello,?\s+we have a (new|other)|we also have a similar genre|please bookmark this series|report error/i; + content.find('div,p').each((_, el) => { + if (junk.test($(el).text().trim())) $(el).remove(); + }); + const chapter = content.html() || ''; + if (loadCheerio(chapter).text().trim().length < 200) + throw new Error('Toasteful: no readable chapter text was found.'); + return chapter; + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const wanted = pageNo * PAGE_SIZE; + const novels: Plugin.NovelItem[] = []; + let start = 1; + for (let round = 0; round < 4 && novels.length < wanted; round++) { + const params = new URLSearchParams({ + 'alt': 'json', + 'q': searchTerm, + 'max-results': '150', + 'start-index': String(start), + }).toString(); + const data: FeedResponse = await ( + await this.request(`${this.site}/feeds/posts/default?${params}`) + ).json(); + const entries = data.feed?.entry || []; + if (!entries.length) break; + for (const entry of entries) { + const isNovel = (entry.category || []).some( + category => category.term === NOVEL_LABEL, + ); + if (!isNovel) continue; + const novel = this.entryToNovel(entry); + if (novel && !novels.some(item => item.path === novel.path)) + novels.push(novel); + if (novels.length >= wanted) break; + } + start += entries.length; + } + return novels.slice((pageNo - 1) * PAGE_SIZE, wanted); + } + + resolveUrl(path: string): string { + const url = new URL(path, this.site); + if (url.origin !== this.site) + throw new Error('Toasteful: invalid page address.'); + return url.href; + } +} + +export default new Toasteful(); diff --git a/public/static/src/en/toasteful/icon.png b/public/static/src/en/toasteful/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2ae9ed3451f0385b9ad8f9d6f1037587d1361781 GIT binary patch literal 9360 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 ZCw(uvEVWwv_k9QepbF~p)v~5x{|jNN(ZT=# literal 0 HcmV?d00001 From 9d49bb515433e28f8f5e89c4ab9f968a013cd53f Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Mon, 12 Oct 2026 21:26:16 +0800 Subject: [PATCH 2/5] no-mistakes(review): Harden toasteful feed, chapter, author, cleanup paths --- plugins/english/toasteful.ts | 48 +++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/plugins/english/toasteful.ts b/plugins/english/toasteful.ts index 9a2b53672..5260c7cee 100644 --- a/plugins/english/toasteful.ts +++ b/plugins/english/toasteful.ts @@ -78,19 +78,26 @@ class Toasteful implements Plugin.PluginBase { } private entryToNovel(entry: FeedEntry): Plugin.NovelItem | undefined { - const href = entry.link?.find(link => link.rel === 'alternate')?.href; - if (!href) return undefined; - let cover: string | undefined; - const html = entry.content?.$t; - if (html) { - const src = loadCheerio(html)('img').first().attr('src'); - if (src && !src.includes(PLACEHOLDER_COVER)) cover = src; + try { + const href = entry.link?.find(link => link.rel === 'alternate')?.href; + if (!href) return undefined; + const name = entry.title?.$t?.replace(/^\(NEW!!!\)\s*/, '').trim(); + if (!name) return undefined; + const resolved = this.resolveUrl(href); + let cover: string | undefined; + const html = entry.content?.$t; + if (html) { + const src = loadCheerio(html)('img').first().attr('src'); + if (src && !src.includes(PLACEHOLDER_COVER)) cover = src; + } + return { + name, + path: new URL(resolved).pathname, + cover: cover || defaultCover, + }; + } catch { + return undefined; } - return { - name: entry.title.$t.replace(/^\(NEW!!!\)\s*/, '').trim(), - path: new URL(this.resolveUrl(href)).pathname, - cover: cover || defaultCover, - }; } async popularNovels( @@ -170,7 +177,8 @@ class Toasteful implements Plugin.PluginBase { ? authorText .trim() .replace(/^author:\s*/i, '') - .trim() || undefined + .split(/\n|genre:|raw\s*source:|raw:|status:|synopsis:/i)[0] + ?.trim() || undefined : undefined; const body = $('.post-body').first(); @@ -259,6 +267,12 @@ class Toasteful implements Plugin.PluginBase { if (path === new URL(pageUrl).pathname) return false; // Side-story hubs are linked as bare "here" pointers, not chapters. if (linkText.trim().toLowerCase() === 'here') return false; + if ( + !/(chapter|episode|part|prologue|epilogue|interlude|afterstory|volume|side story|\d)/i.test( + linkText, + ) + ) + return false; return true; } @@ -270,13 +284,17 @@ class Toasteful implements Plugin.PluginBase { : $('.post-body').first(); content.find('script,style,iframe,form,noscript,.ChapterNav').remove(); content - .find('.reader-toolbar,.js-notice,.adsbygoogle,[class*="ad-"]') + .find( + '.reader-toolbar,.js-notice,.adsbygoogle,.ad,.ads,[class^="ad-"],[class*=" ad-"]', + ) .remove(); content.find('a[href*="docs.google.com/forms"]').remove(); const junk = /hello,?\s+we have a (new|other)|we also have a similar genre|please bookmark this series|report error/i; content.find('div,p').each((_, el) => { - if (junk.test($(el).text().trim())) $(el).remove(); + const node = $(el); + if (node.is('div') && node.find('p,div').length) return; + if (junk.test(node.text().trim())) node.remove(); }); const chapter = content.html() || ''; if (loadCheerio(chapter).text().trim().length < 200) From c1f5653f483dd61d667eb0b8370440b68e5604b3 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Mon, 12 Oct 2026 21:59:05 +0800 Subject: [PATCH 3/5] no-mistakes(review): tighten chapter link text matching --- plugins/english/toasteful.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/english/toasteful.ts b/plugins/english/toasteful.ts index 5260c7cee..06fe0bb10 100644 --- a/plugins/english/toasteful.ts +++ b/plugins/english/toasteful.ts @@ -268,7 +268,7 @@ class Toasteful implements Plugin.PluginBase { // Side-story hubs are linked as bare "here" pointers, not chapters. if (linkText.trim().toLowerCase() === 'here') return false; if ( - !/(chapter|episode|part|prologue|epilogue|interlude|afterstory|volume|side story|\d)/i.test( + !/(chapter|episode|part|prologue|epilogue|interlude|afterstory|volume|side story)/i.test( linkText, ) ) From f1080535911190ddffedf4da916d8aae763e2a57 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Mon, 12 Oct 2026 22:13:05 +0800 Subject: [PATCH 4/5] no-mistakes(review): Anchor chapter keyword match with word boundaries --- plugins/english/toasteful.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/english/toasteful.ts b/plugins/english/toasteful.ts index 06fe0bb10..6903c8ff7 100644 --- a/plugins/english/toasteful.ts +++ b/plugins/english/toasteful.ts @@ -268,7 +268,7 @@ class Toasteful implements Plugin.PluginBase { // Side-story hubs are linked as bare "here" pointers, not chapters. if (linkText.trim().toLowerCase() === 'here') return false; if ( - !/(chapter|episode|part|prologue|epilogue|interlude|afterstory|volume|side story)/i.test( + !/\b(chapter|episode|part|prologue|epilogue|interlude|afterstory|volume)\b|\bside\s*story\b/i.test( linkText, ) ) From 7fa038751e5d1d3960915c23c46afb713341a698 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 13 Oct 2026 05:22:44 +0800 Subject: [PATCH 5/5] fix(english/toasteful): search labeled novels so chapters cannot crowd out matches Blogger ignores the /-/label path once q is present, so a q search returns chapter posts that crowd matching novels out of the window. Search the Web Novel label feed instead and match titles client-side. Bump version to 1.0.1. --- plugins/english/toasteful.ts | 41 ++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/plugins/english/toasteful.ts b/plugins/english/toasteful.ts index 6903c8ff7..387ee6e22 100644 --- a/plugins/english/toasteful.ts +++ b/plugins/english/toasteful.ts @@ -31,7 +31,7 @@ class Toasteful implements Plugin.PluginBase { name = 'Toasteful'; site = 'https://www.toasteful.com'; icon = 'src/en/toasteful/icon.png'; - version = '1.0.0'; + version = '1.0.1'; filters: Filters = { genres: { @@ -306,34 +306,39 @@ class Toasteful implements Plugin.PluginBase { searchTerm: string, pageNo: number, ): Promise { - const wanted = pageNo * PAGE_SIZE; + const tokens = searchTerm + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + if (!tokens.length) return []; + // Blogger ignores the /-/label path once q is present, so a q search + // returns chapter posts that crowd novels out of the window. Search the + // labeled novel list instead: one feed holds the whole catalogue, so + // matching novels can never be crowded out and paging is exact. const novels: Plugin.NovelItem[] = []; let start = 1; - for (let round = 0; round < 4 && novels.length < wanted; round++) { - const params = new URLSearchParams({ - 'alt': 'json', - 'q': searchTerm, - 'max-results': '150', - 'start-index': String(start), - }).toString(); + for (let round = 0; round < 4; round++) { const data: FeedResponse = await ( - await this.request(`${this.site}/feeds/posts/default?${params}`) + await this.request( + this.feedUrl([NOVEL_LABEL], { + 'max-results': '150', + 'start-index': String(start), + }), + ) ).json(); const entries = data.feed?.entry || []; if (!entries.length) break; for (const entry of entries) { - const isNovel = (entry.category || []).some( - category => category.term === NOVEL_LABEL, - ); - if (!isNovel) continue; const novel = this.entryToNovel(entry); - if (novel && !novels.some(item => item.path === novel.path)) - novels.push(novel); - if (novels.length >= wanted) break; + if (!novel || novels.some(item => item.path === novel.path)) continue; + const name = novel.name.toLowerCase(); + if (tokens.every(token => name.includes(token))) novels.push(novel); } + const total = Number(data.feed?.openSearch$totalResults?.$t || 0); start += entries.length; + if (!total || start > total) break; } - return novels.slice((pageNo - 1) * PAGE_SIZE, wanted); + return novels.slice((pageNo - 1) * PAGE_SIZE, pageNo * PAGE_SIZE); } resolveUrl(path: string): string {