Files
2026-05-18 10:39:07 +05:00

324 lines
10 KiB
TypeScript

import { fetchApi } from '@libs/fetch';
import { Plugin } from '@/types/plugin';
import { FilterTypes, Filters } from '@libs/filterInputs';
import { CheerioAPI, load as loadCheerio } from 'cheerio';
import { defaultCover } from '@libs/defaultCover';
import { NovelStatus } from '@libs/novelStatus';
function parseSpanishTextToISO(text: string) {
if (!text) return null;
const now = new Date();
const textLower = text.trim().toLowerCase();
// --- 1. MAPEOS Y EXPRESIONES REGULARES ---
const months = {
enero: 0,
febrero: 1,
marzo: 2,
abril: 3,
mayo: 4,
junio: 5,
julio: 6,
agosto: 7,
septiembre: 8,
octubre: 9,
noviembre: 10,
diciembre: 11,
};
// Expresión para: "21 de febrero de 2026"
const absoluteRegex = /^(\d{1,2})\s+de\s+([a-z]+)\s+de\s+(\d{4})$/i;
// --- 2. EVALUAR FECHAS ABSOLUTAS ---
const absoluteMatch = textLower.match(absoluteRegex);
if (absoluteMatch) {
const day = parseInt(absoluteMatch[1], 10);
const monthStr = absoluteMatch[2];
const year = parseInt(absoluteMatch[3], 10);
const monthIndex = months[monthStr as keyof typeof months];
if (monthIndex !== undefined) {
// Se crea la fecha en hora local (00:00:00)
const date = new Date(year, monthIndex, day);
return date.toISOString();
}
}
// --- 3. EVALUAR FECHAS RELATIVAS ("hace...") ---
if (textLower.startsWith('hace')) {
// Normalizar "un" / "una" a "1" para facilitar el cálculo
const normalized = textLower
.replace(/\b(un|una)\b/g, '1')
.replace('un momento', '0 segundos');
if (normalized.includes('momento')) {
return now.toISOString();
}
const relativeRegex = /(\d+)\s+([a-zñáéíóú]+)/i;
const relativeMatch = normalized.match(relativeRegex);
if (relativeMatch) {
const value = parseInt(relativeMatch[1], 10);
const unit = relativeMatch[2];
const date = new Date(now); // Clonamos la fecha actual
if (unit.startsWith('segundo')) {
date.setSeconds(date.getSeconds() - value);
} else if (unit.startsWith('minuto')) {
date.setMinutes(date.getMinutes() - value);
} else if (unit.startsWith('hora')) {
date.setHours(date.getHours() - value);
} else if (unit.startsWith('dia') || unit.startsWith('día')) {
date.setDate(date.getDate() - value);
} else if (unit.startsWith('mes')) {
date.setMonth(date.getMonth() - value);
} else if (unit.startsWith('año') || unit.startsWith('ano')) {
date.setFullYear(date.getFullYear() - value);
}
return date.toISOString();
}
}
// Si no coincide con ningún formato conocido, intentar el parse nativo o lanzar error
try {
const fallbackDate = new Date(text);
if (!isNaN(fallbackDate.getTime())) return fallbackDate.toISOString();
} catch (e) {
// No se pudo parsear
}
throw new Error(`Formato de fecha no soportado: "${text}"`);
}
class Novelyra implements Plugin.PluginBase {
id = 'novelyra';
name = 'Novelyra';
icon = 'src/es/novelyra/icon.png';
site = 'https://novelyra.com/';
version = '1.0.1';
filters: Filters = {
genres: {
type: FilterTypes.Picker,
label: 'Generos',
value: '',
options: [
{ label: 'Todos', value: '' },
{ label: 'Acción', value: 'accion' },
{ label: 'Aventura', value: 'aventura' },
{ label: 'Fantasía', value: 'fantasia' },
{ label: 'Artes Marciales', value: 'artes-marciales' },
{ label: 'Harén', value: 'haren' },
{ label: 'Romance', value: 'romance' },
{ label: 'Sobrenatural', value: 'sobrenatural' },
{ label: 'Xuanhuan', value: 'xuanhuan' },
{ label: 'Xianxia', value: 'xianxia' },
{ label: 'Comedia', value: 'comedia' },
{ label: 'Ciencia Ficción', value: 'ciencia-ficcion' },
{ label: 'Misterio', value: 'misterio' },
{ label: 'Maduro', value: 'maduro' },
{ label: 'Psicológico', value: 'psicologico' },
{ label: 'Shounen', value: 'shounen' },
{ label: 'Reencarnación', value: 'reencarnacion' },
{ label: 'Mecha', value: 'mecha' },
{ label: 'Vida Escolar', value: 'vida-escolar' },
{ label: 'Josei', value: 'josei' },
{ label: 'Drama', value: 'drama' },
{ label: 'Urbano', value: 'urbano' },
{ label: 'Oriental', value: 'oriental' },
{ label: 'Horror', value: 'horror' },
{ label: 'Tragedia', value: 'tragedia' },
{ label: 'Juegos', value: 'juegos' },
],
},
browse: {
type: FilterTypes.Picker,
label: 'Novelas Populares',
value: 'browse.php',
options: [
{ label: 'Todas las Novelas', value: 'browse.php' },
{ label: '🔥 Hoy', value: 'popular.php?period=today' },
{ label: '📅 Este Mes', value: 'popular.php?period=month' },
{ label: '👑 De Siempre', value: 'popular.php?period=alltime' },
],
},
} satisfies Filters;
private loadNovels(
loadedCheerio: CheerioAPI,
typeNovel: string,
): Plugin.NovelItem[] {
const novels: Plugin.NovelItem[] = [];
loadedCheerio(typeNovel).each((_, ele) => {
const novel = loadedCheerio(ele);
novels.push({
name: novel.find('h3').text(),
path: novel.find('a').attr('href')?.replace(this.site, '') || '',
cover: novel.find('img').attr('src') || defaultCover,
});
});
return novels;
}
async popularNovels(
pageNo: number,
{
showLatestNovels,
filters,
}: Plugin.PopularNovelsOptions<typeof this.filters>,
): Promise<Plugin.NovelItem[]> {
let url = this.site;
let typeNovel = '#novelas .novel-card';
const genre = filters.genres?.value as string;
const browse = filters.browse?.value as string;
if (!showLatestNovels) {
if (browse.startsWith('popular.php')) {
url = `${this.site}${browse}`;
typeNovel = '.popular-item';
} else {
const params = new URLSearchParams();
params.append('page', String(pageNo));
if (genre) {
params.append('genre', genre);
}
url = `${this.site}${browse}?${params.toString()}`;
typeNovel = '.novels-grid .novel-card';
}
}
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = loadCheerio(body);
return this.loadNovels(loadedCheerio, typeNovel);
}
async parseNovel(novelPath: string): Promise<Plugin.SourceNovel> {
const result = await fetchApi(this.site + novelPath);
const body = await result.text();
const loadedCheerio = loadCheerio(body);
const novel: Plugin.SourceNovel = {
path: novelPath,
name: loadedCheerio('h1').text(),
};
novel.cover = loadedCheerio('img').attr('src') || defaultCover;
novel.genres = loadedCheerio('.novel-meta .novel-genres')
.text()
.trim()
.replace('\n', ', ');
novel.status = NovelStatus.Completed;
novel.summary = loadedCheerio('.novel-description-detail').text().trim();
const chapters: Plugin.ChapterItem[] = [];
loadedCheerio('.chapter-item-wrapper').each((idx, ele) => {
const cptr = loadedCheerio(ele);
const numberText = cptr.find('.chapter-number').text();
const numberMatch = numberText.match(/(\d+)/);
const chapterNumber = numberMatch ? parseInt(numberMatch[1]) : 0;
const chapter: Plugin.ChapterItem = {
name: cptr.find('.chapter-title').text(),
path: cptr.find('a').attr('href')?.replace(this.site, '') || '',
releaseTime: parseSpanishTextToISO(cptr.find('.chapter-date').text()),
chapterNumber: chapterNumber,
};
chapters.push(chapter);
});
novel.chapters = chapters;
return novel;
}
async parseChapter(chapterPath: string): Promise<string> {
const myHeaders = new Headers();
myHeaders.set(
'User-Agent',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
);
myHeaders.set(
'Accept',
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
);
myHeaders.set('Accept-Language', 'es-ES,es;q=0.9');
myHeaders.set('Referer', this.site);
myHeaders.set('Cache-Control', 'no-cache');
const result = await fetchApi(this.site + chapterPath, {
method: 'GET',
headers: myHeaders,
});
const body = await result.text();
const loadedCheerio = loadCheerio(body);
// Quita scripts
loadedCheerio('script').remove();
// Quita bloques de anuncios
loadedCheerio('.chapter-ad').remove();
// Quita tags de adsense si los hay
loadedCheerio('ins').remove();
const chapterText = loadedCheerio('.chapter-content');
let paragraph: string[] = [];
const chapterHtml: string[] = [];
const tagsPermisive: string[] = ['b', 'i', 'u', 'strong', 'em', 'span'];
chapterText.contents().each((_, element) => {
switch (element.type) {
case 'text':
if (element.data.trim()) {
paragraph.push(element.data.trim());
}
break;
case 'tag':
{
const originalTag = element.tagName;
if (tagsPermisive.includes(originalTag)) {
paragraph.push(loadedCheerio.html(element));
} else {
if (paragraph.length > 0) {
chapterHtml.push(`<p>${paragraph.join(' ').trim()}</p>`);
paragraph = [];
if (originalTag === 'br') break;
}
chapterHtml.push(loadedCheerio.html(element));
}
}
break;
}
});
// Close any remaining paragraph
if (paragraph.length > 0) {
chapterHtml.push(`<p>${paragraph.join(' ').trim()}</p>`);
}
return chapterHtml.join('');
}
async searchNovels(
searchTerm: string,
// pageNo: number,
): Promise<Plugin.NovelItem[]> {
searchTerm = searchTerm.toLowerCase();
const url = `${this.site}?search=${encodeURIComponent(searchTerm)}`;
const result = await fetchApi(url);
const body = await result.text();
const loadedCheerio = loadCheerio(body);
const typeNovel = '#novelas .novel-card';
return this.loadNovels(loadedCheerio, typeNovel);
}
}
export default new Novelyra();