diff --git a/plugins/indonesian/sakuranovel.ts b/plugins/indonesian/sakuranovel.ts index 6200861cb..abac3238e 100644 --- a/plugins/indonesian/sakuranovel.ts +++ b/plugins/indonesian/sakuranovel.ts @@ -3,12 +3,21 @@ import { fetchApi } from '@libs/fetch'; import { Plugin } from '@/types/plugin'; import { Filters, FilterTypes } from '@libs/filterInputs'; +// `.tldariinggrissendiribrojangancopy` is the site's own obfuscated content wrapper class, not a typo — keep it in sync with the live markup. +const CHAPTER_CONTENT_SELECTORS = [ + '.tldariinggrissendiribrojangancopy .entry-content', + '.entry-content', +]; + +// Promo paragraph the site injects into chapter bodies; rewordings only need updating here. +const PROMO_TEXT = 'Baca novel lain di sakuranovel'; + class SakuraNovel implements Plugin.PluginBase { id = 'sakura.id'; name = 'SakuraNovel'; icon = 'src/id/sakuranovel/icon.png'; site = 'https://sakuranovel.id/'; - version = '1.0.1'; + version = '1.0.2'; parseNovels(loadedCheerio: CheerioAPI) { const novels: Plugin.NovelItem[] = []; @@ -136,15 +145,49 @@ class SakuraNovel implements Plugin.PluginBase { const body = await result.text(); const loadedCheerio = parseHTML(body); + // Inline ad blocks (adsbygoogle scripts, slots, sticky-ad styles) sit inside the chapter body on some layouts. + loadedCheerio('script, style, ins, noscript').remove(); + + // Tier 1: themed content container — the obfuscated parent class marks the real chapter body when the site serves its full theme markup. + for (const selector of CHAPTER_CONTENT_SELECTORS) { + const content = loadedCheerio(selector).first(); + if (!content.length) continue; + content.find(`p:contains('${PROMO_TEXT}')`).remove(); + const chapterText = (content.html() || '').trim(); + if (chapterText) return chapterText; + } - const divi = loadedCheerio("div:contains('Daftar Isi') +") - .find('div:first') - .attr('class'); - loadedCheerio(`.${divi}`).remove(); - const chapterText = - loadedCheerio("div:contains('Daftar Isi') +").html() || ''; + // Tier 2: markdown-rendered chapters expose one paragraph per element. + const paragraphs = loadedCheerio('p.ds-markdown-paragraph') + .map((i, el) => { + const para = loadedCheerio(el); + if (para.text().includes(PROMO_TEXT)) return ''; + return para.toString(); + }) + .get() + .join(''); + if (paragraphs.trim()) return paragraphs; - return chapterText; + // Tier 3 (last resort): the body is the first non-empty sibling between the top and bottom chapter navigation. Its wrapper class rotates, and an empty anti-scrape decoy div can sit in between. + const legacy = loadedCheerio('.entry-pagination') + .first() + .nextUntil('.entry-pagination') + .filter((i, el) => { + const sibling = loadedCheerio(el); + return !!sibling.text().trim() || sibling.find('img').length > 0; + }) + .first(); + legacy.find(`p:contains('${PROMO_TEXT}')`).remove(); + // Older layouts open the body with an ad wrapper div whose class repeats around every ad slot; drop it only when it is empty, as on some layouts the first div is the text itself. + const legacyInner = legacy.find('div').first(); + const legacyInnerClass = legacyInner.attr('class'); + if ( + legacyInnerClass && + !legacyInner.text().trim() && + !legacyInner.find('img').length + ) + legacy.find(`.${legacyInnerClass.split(' ')[0]}`).remove(); + return (legacy.html() || '').trim(); } async searchNovels(