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 000000000..274a4a292 Binary files /dev/null and b/public/static/src/pt-br/blnovels/icon.png differ