feat: Add NOVA (#2064)

* Add files via upload

* Rename public/static/src/es/icon.png to public/static/src/es/NOVA/icon.png

* Rename icon.png to icon.png

* Create NOVA.ts
This commit is contained in:
Hiirbaf
2026-03-30 16:54:15 -03:00
committed by GitHub
parent 5be7a598d4
commit f1ce6a4b3b
2 changed files with 261 additions and 0 deletions
+261
View File
@@ -0,0 +1,261 @@
import { fetchApi } from "@libs/fetch";
import { Plugin } from "@typings/plugin";
import { NovelStatus } from "@libs/novelStatus";
import * as cheerio from "cheerio";
class NovaPlugin implements Plugin.PluginBase {
id = 'nova';
name = 'NOVA';
icon = 'src/es/nova/icon.png';
site = 'https://novelasligeras.net';
version = '1.1.0';
// Regex para parsear títulos de capítulos
private readonly CHAPTER_REGEX = /(Parte \d+) . (.+?): (.+)/;
// Helper para bypass de imágenes de Cloudflare
private async bypassCloudflareImages(
$: cheerio.CheerioAPI,
$content: cheerio.Cheerio<cheerio.Element>
): Promise<string> {
$content.find('img').each((i, img) => {
const $img = $(img);
let src = $img.attr('src') || $img.attr('data-src') || $img.attr('data-cfsrc');
if (src) {
// Si la imagen tiene atributos de Cloudflare, usar la URL directa
$img.attr('src', src);
$img.removeAttr('data-src');
$img.removeAttr('data-cfsrc');
}
});
return $content.html() || '';
}
// Helper para convertir HTML a texto limpio (si es necesario)
private htmlToText(html: string | null | undefined): string {
if (!html) return '';
const $ = cheerio.load(html);
$('script, style').remove();
return $.text().trim();
}
// Método para obtener novelas populares
async popularNovels(
pageNo: number,
options: Plugin.PopularNovelsOptions
): Promise<Plugin.NovelItem[]> {
// Para la primera página, usar la búsqueda AJAX
if (pageNo === 1) {
return this.searchNovels('', 1);
}
// Para páginas siguientes, usar la paginación normal
const url = `${this.site}/index.php/page/${pageNo}/?post_type=product&orderby=popularity`;
const body = await fetchApi(url).then(res => res.text());
const $ = cheerio.load(body);
const novels: Plugin.NovelItem[] = [];
$('.dt-css-grid div.wf-cell').each((i, element) => {
const $el = $(element);
const $img = $el.find('img');
const $link = $el.find('h4.entry-title a');
const path = $link.attr('href')?.replace(this.site, '') || '';
const name = $link.text().trim();
const cover = $img.attr('data-src') || $img.attr('data-cfsrc') || $img.attr('src') || '';
if (name && path) {
novels.push({ name, path, cover });
}
});
return novels;
}
// Método para buscar novelas
async searchNovels(
searchTerm: string,
pageNo: number
): Promise<Plugin.NovelItem[]> {
const novels: Plugin.NovelItem[] = [];
if (pageNo > 1) {
// Búsqueda paginada normal
const encodedTerm = encodeURIComponent(searchTerm);
const url = `${this.site}/index.php/page/${pageNo}/?s=${encodedTerm}&post_type=product&title=1&excerpt=1&content=0&categories=1&attributes=1&tags=1&sku=0&orderby=popularity&ixwps=1`;
const body = await fetchApi(url).then(res => res.text());
const $ = cheerio.load(body);
$('.dt-css-grid div.wf-cell').each((i, element) => {
const $el = $(element);
const $img = $el.find('img');
const $link = $el.find('h4.entry-title a');
const path = $link.attr('href')?.replace(this.site, '') || '';
const name = $link.text().trim();
const cover = $img.attr('data-src') || $img.attr('data-cfsrc') || $img.attr('src') || '';
if (name && path) {
novels.push({ name, path, cover });
}
});
} else {
// Primera página: usar búsqueda AJAX
const url = `${this.site}/wp-admin/admin-ajax.php?tags=1&sku=&limit=30&category_results=&order=DESC&category_limit=5&order_by=title&product_thumbnails=1&title=1&excerpt=1&content=&categories=1&attributes=1`;
const formData = new FormData();
formData.append('action', 'product_search');
formData.append('product-search', '1');
formData.append('product-query', searchTerm);
const response = await fetchApi(url, {
method: 'POST',
body: formData
});
const data = await response.json();
if (Array.isArray(data)) {
data.forEach(novel => {
const path = novel.url?.replace(this.site, '') || '';
const name = novel.title || '';
const cover = novel.thumbnail || '';
if (name && path) {
novels.push({ name, path, cover });
}
});
}
}
return novels;
}
// Método para obtener detalles de una novela
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const url = `${this.site}${novelPath}`;
const body = await fetchApi(url).then(res => res.text());
const $ = cheerio.load(body);
// Extraer información básica
const name = $('h1').first().text().trim();
const $coverImg = $('.woocommerce-product-gallery').find('img').first();
const cover = $coverImg.attr('src') || $coverImg.attr('data-cfsrc') || $coverImg.attr('data-src') || '';
// Extraer autor, artista
const author = $('.woocommerce-product-attributes-item--attribute_pa_escritor td')
.text().trim() || 'Desconocido';
const artist = $('.woocommerce-product-attributes-item--attribute_pa_ilustrador td')
.text().trim() || '';
// Extraer resumen
const summaryHtml = $('.woocommerce-product-details__short-description').html();
const summary = this.htmlToText(summaryHtml);
// Determinar estado
const statusText = $('.woocommerce-product-attributes-item--attribute_pa_estado td')
.text().trim().toLowerCase();
let status = NovelStatus.Unknown;
if (statusText.includes('en curso') || statusText.includes('ongoing')) {
status = NovelStatus.Ongoing;
} else if (statusText.includes('completado') || statusText.includes('completed')) {
status = NovelStatus.Completed;
}
// Extraer capítulos
const chapters: Plugin.ChapterItem[] = [];
let chapterIndex = 0;
$('.vc_row div.vc_column-inner > div.wpb_wrapper').each((i, element) => {
const $el = $(element);
const volume = $el.find('.dt-fancy-title').first().text().trim();
if (!volume.startsWith('Volumen')) {
return;
}
$el.find('.wpb_tab a').each((j, chapterEl) => {
const $chapter = $(chapterEl);
const chapterPartName = $chapter.text().trim();
const chapterPath = $chapter.attr('href')?.replace(this.site, '') || '';
if (!chapterPath) return;
const match = this.CHAPTER_REGEX.exec(chapterPartName);
let chapterName: string;
if (match) {
const [, part, chapter, name] = match;
chapterName = `${volume} - ${chapter} - ${part}: ${name}`;
} else {
chapterName = `${volume} - ${chapterPartName}`;
}
chapters.push({
name: chapterName,
path: chapterPath,
releaseTime: '',
chapterNumber: chapterIndex + 1
});
chapterIndex++;
});
});
const novel: Plugin.SourceNovel = {
path: novelPath,
name,
cover,
summary,
author,
artist,
status,
chapters
};
return novel;
}
// Método para obtener contenido del capítulo
async parseChapter(chapterPath: string): Promise<string> {
const url = `${this.site}${chapterPath}`;
const body = await fetchApi(url).then(res => res.text());
const $ = cheerio.load(body);
// Determinar el selector correcto basado en el contenido
let $chapterText: cheerio.Cheerio<cheerio.Element>;
if (body.includes('Nadie entra sin permiso en la Gran Tumba de Nazarick')) {
$chapterText = $('#content');
} else {
$chapterText = $('.wpb_text_column.wpb_content_element > .wpb_wrapper');
}
// Remover anuncios y elementos no deseados
$chapterText.find('center').remove();
// Convertir elementos con text-align center a tags <center>
$chapterText.find('*').each((i, el) => {
const $el = $(el);
const style = $el.attr('style') || '';
if (/text-align:.?center/.test(style)) {
$el.replaceWith(`<center>${$el.html()}</center>`);
}
});
// Aplicar bypass de imágenes de Cloudflare
let chapterContent = await this.bypassCloudflareImages($, $chapterText);
// Limpiar scripts, estilos y otros elementos innecesarios
const $clean = cheerio.load(chapterContent);
$clean('script, style, iframe, .ads, .advertisement').remove();
return $clean.html() || chapterContent;
}
}
export default new NovaPlugin();