From fea227b70d041e9ed691ab672a8d3fe639b133c7 Mon Sep 17 00:00:00 2001 From: Raiyn Aydin Date: Tue, 22 Sep 2026 13:13:42 +0800 Subject: [PATCH] feat(portuguese): add blnovels plugin New LNReader source for blnovels.com (Madara theme, Portuguese/Brazil), closes the source request in #2541. Authored by an autonomous AI agent (pi coding agent); not human-reviewed. Co-Authored-By: pi coding agent --- plugins/portuguese/blnovels.ts | 481 ++++++++++++++++++++++ public/static/src/pt-br/blnovels/icon.png | Bin 0 -> 9555 bytes 2 files changed, 481 insertions(+) create mode 100644 plugins/portuguese/blnovels.ts create mode 100644 public/static/src/pt-br/blnovels/icon.png diff --git a/plugins/portuguese/blnovels.ts b/plugins/portuguese/blnovels.ts new file mode 100644 index 000000000..39fa079e4 --- /dev/null +++ b/plugins/portuguese/blnovels.ts @@ -0,0 +1,481 @@ +import { fetchApi } from '@libs/fetch'; +import { Filters, FilterTypes } from '@libs/filterInputs'; +import { Plugin } from '@/types/plugin'; +import { CheerioAPI, load as parseHTML } from 'cheerio'; +import { defaultCover } from '@libs/defaultCover'; +import { NovelStatus } from '@libs/novelStatus'; +import dayjs from 'dayjs'; + +const SITE = 'https://blnovels.com'; + +// Titles used by Cloudflare/bot interstitials; seeing one of these in a +// response means we got a challenge instead of the requested page. +const CHALLENGE_TITLES = [ + 'Just a moment...', + 'Attention Required! | Cloudflare', + 'Checking your browser...', + 'One moment please...', + 'Bot Verification', + 'You are being redirected...', + 'Redirecting...', +]; + +type FetchHtmlOptions = { + method?: string; + referrer?: string; + // Treat HTTP 404 as an empty result instead of throwing: used when paging + // past the last listing/search page (WordPress answers those with 404). + allowNotFound?: boolean; +}; + +class BlNovels implements Plugin.PluginBase { + id = 'blnovels'; + name = 'BL Novels'; + version = '1.0.0'; + icon = 'src/pt-br/blnovels/icon.png'; + site = SITE; + + // Browser-like headers — blnovels.com is fronted by Cloudflare and + // UA-less requests from runner networks can be answered with a bot check. + private headers = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8', + Referer: `${SITE}/`, + }; + + private async fetchHtml( + url: string, + options: FetchHtmlOptions = {}, + ): Promise { + const res = await fetchApi(url, { + headers: this.headers, + method: options.method, + referrer: options.referrer, + }); + if (!res.ok) { + if (options.allowNotFound && res.status === 404) return ''; + // Attach the status so a runner-side block (403/503) surfaces as an + // INCONCLUSIVE site block instead of a false "no novels" result. + throw Object.assign( + new Error(`Could not reach site (HTTP ${res.status}) at ${url}`), + { status: res.status }, + ); + } + const html = await res.text(); + const title = html.match(/]*>([^<]*)<\/title>/)?.[1]?.trim() || ''; + if (CHALLENGE_TITLES.includes(title)) { + throw new Error(`Bot challenge page returned by site ("${title}")`); + } + return html; + } + + // WordPress stores covers as `name-WxH.ext` thumbnails; strip the size + // suffix to use the full-size original (all suffix-stripped URLs checked + // live returned HTTP 200). + private coverUrl(src?: string): string { + if (!src) return defaultCover; + if (!/^https?:\/\//.test(src)) return src; + if (src === SITE) return defaultCover; + const stripped = src.replace(/-\d+x\d+(\.\w+)$/, '$1'); + return stripped || defaultCover; + } + + private toPath(url: string): string { + return url.startsWith(SITE) ? url.slice(SITE.length) : url; + } + + parseNovels($: CheerioAPI): Plugin.NovelItem[] { + const novels: Plugin.NovelItem[] = []; + const seen = new Set(); + + $('.manga-title-badges').remove(); + $('.page-item-detail, .c-tabs-item__content').each((_, element) => { + const link = $(element).find('.post-title a').first(); + const name = + link.text().trim() || $(element).find('.post-title').text().trim(); + const href = link.attr('href') || ''; + if (!name || !href) return; + + const path = this.toPath(href); + if (seen.has(path)) return; + seen.add(path); + + const img = $(element).find('img').first(); + const cover = + this.coverUrl( + img.attr('data-src') || + img.attr('data-srcset')?.split(' ')[0] || + img.attr('src'), + ) || defaultCover; + + novels.push({ name, cover, path }); + }); + + return novels; + } + + private buildListingUrl( + pageNo: number, + searchTerm: string, + { filters, showLatestNovels }: Plugin.PopularNovelsOptions, + ): string { + let url = `/page/${pageNo}/?s=`; + if (searchTerm) url += encodeURIComponent(searchTerm); + url += '&post_type=wp-manga'; + + if (showLatestNovels) url += '&m_orderby=latest'; + + const active: Plugin.PopularNovelsOptions['filters'] = + filters || this.filters; + for (const key in active) { + const value = active[key].value; + if (Array.isArray(value)) { + for (const item of value) { + if (item) url += `&${key}=${encodeURIComponent(item)}`; + } + } else if (typeof value === 'boolean') { + // The site's own advanced-search form submits "1" for the + // "match ALL selected genres" switch. + if (value) url += `&${key}=1`; + } else if (value) { + url += `&${key}=${encodeURIComponent(String(value))}`; + } + } + return SITE + url; + } + + async popularNovels( + pageNo: number, + options: Plugin.PopularNovelsOptions, + ): Promise { + const url = this.buildListingUrl(pageNo, '', options); + // Page 1 failing (any status) must throw; only a genuine past-the-end + // 404 on later pages is allowed to come back empty. + const html = await this.fetchHtml(url, { + allowNotFound: pageNo > 1, + }); + if (!html) return []; + return this.parseNovels(parseHTML(html)); + } + + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const url = this.buildListingUrl(pageNo, searchTerm, { + filters: this.filters, + }); + const html = await this.fetchHtml(url, { allowNotFound: true }); + if (!html) return []; + return this.parseNovels(parseHTML(html)); + } + + private parseStatus(text: string): string | undefined { + if (!text) return undefined; + if (/on\s*-?\s*going/i.test(text)) return NovelStatus.Ongoing; + if (/complet/i.test(text)) return NovelStatus.Completed; + if (/cancel/i.test(text)) return NovelStatus.Cancelled; + if (/hold|hiatus|hiato/i.test(text)) return NovelStatus.OnHiatus; + if (/upcoming|em breve/i.test(text)) return NovelStatus.Unknown; + return undefined; + } + + // Chapter dates render as e.g. `title="15 horas ago"` on the "new" tag + // anchor (mixed Portuguese unit + English "ago"); older chapters ship an + // empty placeholder, for which we return null rather than inventing a date. + private parseReleaseDate(raw?: string): string | null { + if (!raw) return null; + const match = raw.match(/(\d+)\s*([a-záéêíóúãõç]+)/i); + if (!match) return null; + const amount = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + + let calendarUnit: string; + if (unit.startsWith('seg') || unit.startsWith('second')) { + calendarUnit = 'second'; + } else if (unit.startsWith('min')) { + calendarUnit = 'minute'; + } else if (unit.startsWith('hor') || unit.startsWith('hour')) { + calendarUnit = 'hour'; + } else if (unit.startsWith('dia') || unit.startsWith('day')) { + calendarUnit = 'day'; + } else if (unit.startsWith('sem') || unit.startsWith('week')) { + calendarUnit = 'week'; + } else if ( + unit.startsWith('month') || + unit.startsWith('me') || + unit.startsWith('m') + ) { + calendarUnit = 'month'; + } else if (unit.startsWith('an') || unit.startsWith('year')) { + calendarUnit = 'year'; + } else { + return null; + } + return dayjs() + .subtract(amount, calendarUnit as dayjs.ManipulateType) + .format('YYYY-MM-DD'); + } + + private parseChapters($: CheerioAPI): Plugin.ChapterItem[] { + const chapters: Plugin.ChapterItem[] = []; + const items = $('li.wp-manga-chapter'); + const total = items.length; + + items.each((index, element) => { + const link = $(element).find('a').first(); + const href = link.attr('href') || ''; + const name = link.text().replace(/\s+/g, ' ').trim(); + if (!href || !name || href === '#') return; + + const releaseRaw = + $(element).find('span.chapter-release-date a').attr('title') || + $(element).find('span.chapter-release-date').text().trim(); + + chapters.push({ + name, + path: this.toPath(href), + releaseTime: this.parseReleaseDate(releaseRaw), + // The site lists chapters newest first; number them from the oldest + // chapter and reverse below so the app reads oldest first. + chapterNumber: total - index, + }); + }); + + return chapters.reverse(); + } + + async parseNovel(novelPath: string): Promise { + const novelUrl = SITE + novelPath; + const html = await this.fetchHtml(novelUrl); + const $ = parseHTML(html); + + $('.manga-title-badges').remove(); + const novel: Plugin.SourceNovel = { + path: novelPath, + name: $('.post-title h1').first().text().trim(), + cover: this.coverUrl( + $('.summary_image img').first().attr('data-src') || + $('.summary_image img').first().attr('src'), + ), + }; + + $('.post-content_item').each((_, element) => { + const heading = $(element).find('h5').first().text().trim(); + if (/^autor/i.test(heading)) { + const authors = $(element) + .find('.author-content a') + .map((i, el) => $(el).text().trim()) + .get() + .filter(Boolean); + novel.author = authors.length + ? authors.join(', ') + : $(element).find('.summary-content').first().text().trim(); + } else if (/^g[eê]nero/i.test(heading)) { + const genres = $(element) + .find('.genres-content a') + .map((i, el) => $(el).text().trim()) + .get() + .filter(Boolean); + if (genres.length) novel.genres = genres.join(','); + } else if (/^status/i.test(heading)) { + novel.status = this.parseStatus( + $(element).find('.summary-content').first().text(), + ); + } else if (/^artist/i.test(heading)) { + novel.artist = $(element) + .find('.summary-content') + .first() + .text() + .trim(); + } + }); + + if (!novel.genres) { + const genres = $('.genres-content a') + .map((i, el) => $(el).text().trim()) + .get() + .filter(Boolean); + if (genres.length) novel.genres = genres.join(','); + } + + const rating = parseFloat( + $('.post-rating .post-total-rating span.score').first().text().trim(), + ); + if (!Number.isNaN(rating)) novel.rating = rating; + + const summaryContainer = $( + 'div.description-summary div.summary__content', + ).first(); + const paragraphs = summaryContainer + .find('p') + .map((i, el) => $(el).text().trim()) + .get() + .filter(Boolean); + novel.summary = paragraphs.length + ? paragraphs.join('\n\n') + : summaryContainer.text().trim() || undefined; + + // Chapter list is rendered inline server-side. If that ever stops being + // true, fall back to Madara's ajax chapter-list endpoint (verified live: + // POST /ajax/chapters/ returns the same
  • + // markup, unpaginated). + let chapters = this.parseChapters($); + if (chapters.length === 0) { + const ajaxHtml = await this.fetchHtml(`${novelUrl}ajax/chapters/`, { + method: 'POST', + referrer: novelUrl, + }); + if (ajaxHtml) chapters = this.parseChapters(parseHTML(ajaxHtml)); + } + novel.chapters = chapters; + + return novel; + } + + async parseChapter(chapterPath: string): Promise { + const html = await this.fetchHtml(SITE + chapterPath); + const $ = parseHTML(html); + const content = $('.text-left').first(); + const chapterHtml = content.html()?.trim() || ''; + if (chapterHtml.length < 200) { + throw new Error( + `Chapter content not found or too short at ${chapterPath}`, + ); + } + return chapterHtml; + } + + resolveUrl = (path: string): string => SITE + path; + + filters = { + 'genre[]': { + type: FilterTypes.CheckboxGroup, + label: 'Gêneros', + value: [] as string[], + options: [ + { label: 'Abuso', value: 'abuso' }, + { label: 'Ação', value: 'acao' }, + { label: 'Anti-herói', value: 'anti-heroi' }, + { label: 'Aventura', value: 'aventura' }, + { label: 'BG', value: 'bg' }, + { label: 'BL', value: 'bl' }, + { label: 'Boyslove', value: 'boyslove' }, + { label: 'Comédia', value: 'comedia' }, + { label: 'Crime', value: 'crime' }, + { label: 'Cultivo', value: 'cultivo' }, + { label: 'Danmei', value: 'danmei' }, + { label: 'Delulu', value: 'delulu' }, + { label: 'Detetive', value: 'detetive' }, + { label: 'Drama', value: 'drama' }, + { label: 'Escolar', value: 'escolar' }, + { label: 'Español', value: 'espanol' }, + { label: 'Fantasia', value: 'fantasia' }, + { label: 'Ficção Científica', value: 'ficcao-cientifica' }, + { label: 'Girls Love', value: 'girls-love' }, + { label: 'GL', value: 'gl' }, + { label: 'Guideverse', value: 'guideverse' }, + { label: 'Hardcore', value: 'hardcore' }, + { label: 'Histórico', value: 'historico' }, + { label: 'Horror', value: 'horror' }, + { label: 'Incesto', value: 'incesto' }, + { label: 'Interestelar', value: 'interestelar' }, + { label: 'Máfia', value: 'mafia' }, + { label: 'Mature', value: 'mature' }, + { label: 'Mistério', value: 'misterio' }, + { label: 'Moderno', value: 'moderno' }, + { label: 'Mpreg', value: 'mpreg' }, + { label: 'Obsessivo', value: 'obsessivo' }, + { label: 'Omegaverse', value: 'omegaverse' }, + { label: 'Policial', value: 'policial' }, + { label: 'Português', value: 'portugues' }, + { label: 'Prostituição', value: 'prostituicao' }, + { label: 'Psicológico', value: 'psicologico' }, + { label: 'Renascimento', value: 'renascimento' }, + { label: 'Romance', value: 'romance' }, + { label: 'School Life', value: 'school-life' }, + { label: 'Shounen', value: 'shounen' }, + { label: 'Shounen-ai', value: 'shounen-ai' }, + { label: 'Slice of Life', value: 'slice-of-life' }, + { label: 'Smut', value: 'smut' }, + { label: 'Sobrenatural', value: 'sobrenatural' }, + { label: 'Submissão', value: 'submissao' }, + { label: 'Supernatural', value: 'supernatural' }, + { label: 'Suspense', value: 'suspense' }, + { label: 'Terror', value: 'terror' }, + { label: 'Thriller', value: 'thriller' }, + { label: 'Tragédia', value: 'tragedia' }, + { label: 'Tragedy', value: 'tragedy' }, + { label: 'Transmigração', value: 'transmigracao' }, + { label: 'Vingança', value: 'vinganca' }, + { label: 'Wuxia', value: 'wuxia' }, + { label: 'Xianxia', value: 'xianxia' }, + { label: 'Xuanhuan', value: 'xuanhuan' }, + { label: 'Yakuza', value: 'yakuza' }, + { label: 'Yaoi', value: 'yaoi' }, + { label: 'Yuri', value: 'yuri' }, + ], + }, + op: { + type: FilterTypes.Switch, + label: 'E (tendo todos os gêneros selecionados)', + value: false, + }, + author: { + type: FilterTypes.TextInput, + label: 'Autor', + value: '', + }, + artist: { + type: FilterTypes.TextInput, + label: 'Artista', + value: '', + }, + release: { + type: FilterTypes.TextInput, + label: 'Ano de Lançado', + value: '', + }, + adult: { + type: FilterTypes.Picker, + label: 'Conteúdo adulto', + value: '', + options: [ + { label: 'Tudo', value: '' }, + { label: 'Nenhum conteúdo adulto', value: '0' }, + { label: 'APENAS CONTEÚDO ADULTO', value: '1' }, + ], + }, + 'status[]': { + type: FilterTypes.CheckboxGroup, + label: 'Status', + value: [] as string[], + options: [ + { label: 'OnGoing', value: 'on-going' }, + { label: 'Completed', value: 'end' }, + { label: 'Canceled', value: 'canceled' }, + { label: 'On Hold', value: 'on-hold' }, + { label: 'Upcoming', value: 'upcoming' }, + ], + }, + m_orderby: { + type: FilterTypes.Picker, + label: 'Ordem', + value: '', + options: [ + { label: 'Relevância', value: '' }, + { label: 'Mais recentes', value: 'latest' }, + { label: 'A-Z', value: 'alphabet' }, + { label: 'Votos', value: 'rating' }, + { label: 'Tendências', value: 'trending' }, + { label: 'Mais Vistas', value: 'views' }, + { label: 'Novo', value: 'new-manga' }, + ], + }, + } satisfies Filters; +} + +export default new BlNovels(); diff --git a/public/static/src/pt-br/blnovels/icon.png b/public/static/src/pt-br/blnovels/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..274a4a292c7414cc6eb3b8b90d394e3697f23e44 GIT binary patch literal 9555 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%0AYoSkK7*$icmWc~>x-gLx}FE1}kz}gUUcJ}t1 zD{5*w?M{~JL{^$yT$I4z1Px8i;qh@g7$qa5`D0{s6s)RB7#9~uEhMz^x@?oFGY&`i#*>9vw zyw0nPzt9|^#dea~F9|-FA~)YrFfi6h@bEk+KA~g%xe9C{%4K=2ac#T|tTm_toEGHg zbHNBx7V_X{S2$2{Q4tTBfZI+cyiSz?9eL1$+4R&DWeS@yGYm;|z$0DJTq zY+U#V_YDp%ramI?sl(_;o}K;un3EGnIjGm^Sb=O@B1qX{IFWAaJopXT-q8UOb6nym zD=TBVgxSI(D%H_vDY1M1XB-X$?Fs|lXprd2L42Zvhw>*H}hI^XK% zx2UM$cYB+YeQ!D2!A_{_$gnOKw@g~_ZF#vhz~t{+