From 7250a5a9ea0b834f3bd712c74302faf1ce765d9c Mon Sep 17 00:00:00 2001 From: anehemias6-star Date: Fri, 8 May 2026 18:58:25 -0300 Subject: [PATCH] feat(imReadingABook): add new Spanish plugin for imreadingabook.com --- plugins/spanish/imReadingABook.ts | 387 +++++++++++++++++++ public/static/src/es/imreadingabook/icon.png | Bin 0 -> 2963 bytes 2 files changed, 387 insertions(+) create mode 100644 plugins/spanish/imReadingABook.ts create mode 100644 public/static/src/es/imreadingabook/icon.png diff --git a/plugins/spanish/imReadingABook.ts b/plugins/spanish/imReadingABook.ts new file mode 100644 index 000000000..fa9715628 --- /dev/null +++ b/plugins/spanish/imReadingABook.ts @@ -0,0 +1,387 @@ +import { fetchApi } from '@libs/fetch'; +import { Plugin } from '@/types/plugin'; +import { load as loadCheerio } from 'cheerio'; +import { NovelStatus } from '@libs/novelStatus'; +import { defaultCover } from '@libs/defaultCover'; + +/** + * Plugin para I'm Reading A Book (imreadingabook.com) + * Sitio de traducción de novelas ligeras al español (Coreanas, Chinas, Japonesas). + * + * Estructura del sitio: + * - Es un WordPress. Los "novels" son páginas estáticas con una tabla de capítulos. + * - La lista maestra de novelas está en /nuestros-proyectos/ + * - Los capítulos son posts normales de WordPress. + * - La búsqueda usa el query param ?s=término + * + * Notas para futuros contribuidores: + * - El sitio NO tiene una página de listado paginado de novelas con portadas. + * popularNovels() parsea /nuestros-proyectos/ para obtener el índice completo. + * - El estado ("Activa", "Finalizada", "Hiatus") está inline en el texto del link. + * - Las portadas se obtienen desde el og:image de cada página de novela. + * - Los capítulos se listan en una tabla () dentro del entry-content de la página de la novela. + */ // v1.0.0 + +class ImReadingABookPlugin implements Plugin.PluginBase { + id = 'imreadingabook'; + name = "I'm Reading A Book"; + icon = 'src/es/imreadingabook/icon.png'; + site = 'https://www.imreadingabook.com'; + version = '1.0.0'; + filters = undefined; + + /** + * popularNovels: parsea la página /nuestros-proyectos/ que tiene el listado + * completo de todas las novelas del sitio. Solo existe una página (sin paginación), + * así que pageNo > 1 devuelve vacío. + */ + async popularNovels( + pageNo: number, + { showLatestNovels }: Plugin.PopularNovelsOptions, + ): Promise { + // Si el usuario pide recientes, usamos la home que lista los últimos capítulos + // y extraemos los títulos únicos de las novelas. + if (showLatestNovels) { + return this.fetchLatestNovels(pageNo); + } + + // Solo hay una "página" para el listado maestro + if (pageNo > 1) return []; + + const url = `${this.site}/nuestros-proyectos/`; + const result = await fetchApi(url); + const body = await result.text(); + const $ = loadCheerio(body); + + const novels: Plugin.NovelItem[] = []; + const seen = new Set(); + + // Los links de novelas están dentro del .entry-content como + // con texto que incluye estado entre guiones: "Finalizada – Título" + $('.entry-content a').each((_i, el) => { + const href = $(el).attr('href') || ''; + const rawText = $(el).text().trim(); + + // Filtrar links que no sean de novelas del sitio principal + if ( + !href.startsWith('https://imreadingabook.com') && + !href.startsWith('https://www.imreadingabook.com') + ) { + return; + } + + // Excluir links de mynovel.club (contenido R18 externo) + if (href.includes('mynovel.club')) return; + + // Excluir links de páginas de categorías o navegación + const excluded = [ + '/traducciones', + '/nuestros-proyectos', + '/chinas', + '/coreanas', + '/japonesas', + '/finalizadas', + '/one-shot', + '/originales', + '/accion', + '/blog', + '/recomendaciones', + '/escritura', + '/libro-3', + ]; + if (excluded.some(p => href.includes(p))) return; + + // El texto puede ser "Finalizada – Título" o solo "Título" + // Eliminamos el prefijo de estado si existe + const name = rawText + .replace(/^(Finaliz[ao]da|Activa|Hiatus|Finalizado)\s*[–-]\s*/i, '') + .trim(); + + if (!name || name.length < 3) return; + + // Construir path relativo desde la URL completa + let path: string; + try { + const urlObj = new URL(href); + path = urlObj.pathname; + } catch { + return; + } + + if (seen.has(path)) return; + seen.add(path); + + novels.push({ + name, + path, + cover: defaultCover, + }); + }); + + return novels; + } + + /** + * Obtiene las novelas más recientes desde la home del sitio. + * La home lista los últimos capítulos publicados; intentamos agrupar por novela. + */ + private async fetchLatestNovels(pageNo: number): Promise { + const url = pageNo > 1 ? `${this.site}/page/${pageNo}/` : `${this.site}/`; + const result = await fetchApi(url); + const body = await result.text(); + const $ = loadCheerio(body); + + const novels: Plugin.NovelItem[] = []; + const seen = new Set(); + + // Cada post en la home tiene una imagen y un título de capítulo. + // No es posible mapear fácilmente a la página de la novela desde aquí, + // así que devolvemos los posts recientes como ítems (cada uno es un capítulo). + // El usuario podrá abrir la novela desde el detalle. + $('article').each((_i, el) => { + const titleEl = $(el).find('h2 a, h1 a').first(); + const name = titleEl.text().trim(); + const href = titleEl.attr('href') || ''; + const cover = + $(el).find('img').first().attr('src') || + $(el).find('img').first().attr('data-src') || + defaultCover; + + if (!name || !href) return; + + let path: string; + try { + path = new URL(href).pathname; + } catch { + return; + } + + if (seen.has(path)) return; + seen.add(path); + + novels.push({ name, path, cover }); + }); + + return novels; + } + + /** + * parseNovel: parsea la página dedicada de una novela. + * + * La página de cada novela en imreadingabook contiene: + * - Imagen de portada (primer del entry-content o og:image) + * - Metadatos: Idioma Original, Estado, Otros Nombres, Sinopsis + * - Una tabla con los capítulos disponibles como links + * + * Ejemplo de URL de novela: /bienvenido-a-dungeon-hotel/ + */ + async parseNovel(novelPath: string): Promise { + const url = this.site + novelPath; + const result = await fetchApi(url); + const body = await result.text(); + const $ = loadCheerio(body); + + const novel: Plugin.SourceNovel = { + path: novelPath, + name: + $('h1.entry-title, h1.wp-block-heading').first().text().trim() || + 'Sin título', + }; + + // Portada: primero intentamos la og:image del + const ogImage = $('meta[property="og:image"]').attr('content'); + novel.cover = + ogImage || $('article img').first().attr('src') || defaultCover; + + // Parsear el contenido de la página de la novela + const content = $('.entry-content'); + + // El sitio usa formato "Clave: Valor" en texto o en párrafos con + // Buscamos texto "Estado:" para determinar el estado de la novela + const fullText = content.text(); + + // Estado + const estadoMatch = fullText.match( + /Estado\s*:\s*(Activa|Finaliz[ao]da|Hiatus|En\s*proceso)/i, + ); + if (estadoMatch) { + const estado = estadoMatch[1].toLowerCase(); + if (estado.includes('finaliz')) { + novel.status = NovelStatus.Completed; + } else if (estado === 'activa' || estado.includes('proceso')) { + novel.status = NovelStatus.Ongoing; + } else if (estado === 'hiatus') { + novel.status = NovelStatus.OnHiatus; + } + } + + // Sinopsis: buscamos el párrafo después de "Sinopsis:" + const sinopsisMatch = fullText.match( + /Sinopsis\s*:([\s\S]*?)(?=\||\n\n\n|$)/i, + ); + if (sinopsisMatch) { + novel.summary = sinopsisMatch[1] + .replace(/\s+/g, ' ') + .trim() + .substring(0, 2000); // limitar largo + } + + // Géneros: las categorías del post están en el header del artículo + // Ejemplo: "/ Acción, Aventura, Comedia, Drama, Fantasía, Romántico /" + const categories: string[] = []; + $('a[rel="category tag"], .cat-links a').each((_i, el) => { + categories.push($(el).text().trim()); + }); + if (categories.length > 0) { + novel.genres = categories.join(', '); + } + + // Autor/Traductor: el sitio muestra el autor del post (traductor) + const authorEl = $('a[rel="author"], .author a').first(); + if (authorEl.length) { + novel.author = 'Trad: ' + authorEl.text().trim(); + } + + // ---- Parsear capítulos ---- + const chapters: Plugin.ChapterItem[] = []; + + /** + * Los capítulos están en una tabla HTML dentro del entry-content. + * Cada celda
puede contener: + * - Un con el nombre del capítulo (capítulo disponible) + * - Solo texto sin link (capítulo no publicado aún) + * + * Ejemplo: + * | 01 | 02 | 03 | + * | 04 | 05 | 06 | + */ + content.find('table td a').each((i, el) => { + const chapterHref = $(el).attr('href') || ''; + const chapterName = $(el).text().trim(); + + if (!chapterHref || !chapterName) return; + + // Solo capítulos del mismo dominio + if ( + !chapterHref.startsWith('https://imreadingabook.com') && + !chapterHref.startsWith('https://www.imreadingabook.com') + ) { + return; + } + + let chapterPath: string; + try { + chapterPath = new URL(chapterHref).pathname; + } catch { + return; + } + + // Intentar extraer número de capítulo del texto + const numMatch = chapterName.match(/\d+/); + const chapterNumber = numMatch ? parseInt(numMatch[0], 10) : i + 1; + + chapters.push({ + name: chapterName, + path: chapterPath, + releaseTime: null, + chapterNumber, + }); + }); + + // Ordenar por número de capítulo (ascendente) + chapters.sort((a, b) => (a.chapterNumber || 0) - (b.chapterNumber || 0)); + + novel.chapters = chapters; + return novel; + } + + /** + * parseChapter: obtiene el texto de un capítulo. + * + * Los capítulos son posts de WordPress. El contenido está en .entry-content. + * Eliminamos: imágenes de portada al inicio, botones de navegación, + * widgets de likes, y cualquier script/iframe. + */ + async parseChapter(chapterPath: string): Promise { + const url = this.site + chapterPath; + const result = await fetchApi(url); + const body = await result.text(); + const $ = loadCheerio(body); + + const content = $('.entry-content'); + + // Eliminar elementos no deseados dentro del contenido + content + .find('script, style, iframe, .sharedaddy, .jp-relatedposts') + .remove(); + content.find('.wp-block-image, figure').first().remove(); // portada al inicio + + // Limpiar enlaces internos de navegación (← Entrada anterior / siguiente →) + // Estos están fuera del entry-content normalmente, pero por si acaso: + $( + 'a:contains("Entrada anterior"), a:contains("Entrada siguiente")', + ).remove(); + + // Obtener el HTML limpio + const chapterText = content.html() || ''; + + return chapterText; + } + + /** + * searchNovels: búsqueda usando el motor de WordPress. + * + * URL: https://www.imreadingabook.com/?s=término&page=N + * + * Los resultados son posts (capítulos y entradas de blog mezclados), + * así que filtramos para intentar devolver solo páginas de novelas. + * Esto es una limitación del sitio ya que no tiene búsqueda separada por tipo. + */ + async searchNovels( + searchTerm: string, + pageNo: number, + ): Promise { + const encodedTerm = encodeURIComponent(searchTerm); + const url = + pageNo > 1 + ? `${this.site}/page/${pageNo}/?s=${encodedTerm}` + : `${this.site}/?s=${encodedTerm}`; + + const result = await fetchApi(url); + const body = await result.text(); + const $ = loadCheerio(body); + + const novels: Plugin.NovelItem[] = []; + + $('article').each((_i, el) => { + const titleEl = $(el).find('h2 a, h1 a').first(); + const name = titleEl.text().trim(); + const href = titleEl.attr('href') || ''; + const cover = + $(el).find('img').first().attr('src') || + $(el).find('img').first().attr('data-src') || + defaultCover; + + if (!name || !href) return; + + let path: string; + try { + path = new URL(href).pathname; + } catch { + return; + } + + novels.push({ name, path, cover }); + }); + + return novels; + } + + /** + * resolveUrl: construye la URL completa desde un path relativo. + * LNReader la usa internamente en algunos contextos. + */ + resolveUrl = (path: string, _isNovel?: boolean) => this.site + path; +} + +export default new ImReadingABookPlugin(); diff --git a/public/static/src/es/imreadingabook/icon.png b/public/static/src/es/imreadingabook/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..814c5d0d5fbbd78c27b016ac98fda92cf570314e GIT binary patch literal 2963 zcmV;E3vBd>P) z>u(h26~=$ZB_Tp}j6#v(P!uEw2aFv91^YuHq7V^~3@Kn^Ah1d=snRc1RaL4`|Ab0y zRrLeV(5jUJHi6(R;Bad|AQH)mWg9R-HEcx`QIH4-Lu7?ZdHdna*t5H{I~VW8-q}a8 zY>#)&`_6OxUgw{TG~}9@GLM>49@`51-=VR12a|N z2|&bPfQJJ2ff0dkfIDK$ZQ!QBjZgbp?-lP!GGjtHVBylPDxgMS4lr9_7EmpQnZOKz zDxea~G=ZmpDPpJq9t%7Iz7-e&?ujwOz%7BBz;%IZz*hoSF(l6Rw+mOdrU&$3t&(T=Yamx18qY&c(gz$xC35liZ2A3 z1(pDd#h8UaMLLnmzvq~Ir>)1pfIuJ6E6{Ulplu-J;{`^+9MI4duNQa)cv+wsn46KX z!jYf)*~>tW81o5m_T;$@g8?5iF!JtzhNk#)z;c0Cfn@@EgiT!Xo%i&DIV^DK z*!lLpv(F^S-$N_TZfQIIHD~2{;y}*-B z<0V4AefwLp##+ge?-xF7y#cIPU)TAZmo2|7V7cg)@?_rC z^gY}00}oqITYN3|wBz*w>rK`#fp;bf`M=wqdCL9UclVErd;z+%bVF~h-CP~Ok-G%` z^x+qqGQL^!YyqOL5tmIA^1Zr>@sjU_AgxGX8Ooop`li8pf&OcOty+jT`JaEjHj(`i z#*dyJjh=8rb2HsJD=4vU@#xPrU88gRwe=sg_#of*Z0jB45VCJXZpXLB7M7&l)1j8p&Q#2ti z)UKuz`5c6SuK~7gdpqMEOFqlDTUQA35txa7U53;apsAoU#!G&b5RiO;8Dh*c_Wh}Y z@O05sP{QozM-3s!M~tZgrrS1>x**Xc=*nD>pXWi?a~)8KK^Ov)4^V0G45YV!X=oC( zbwgG|kspXdkdMGL^b1HEgw0gZ4D@l6U#OL6CgigG%;g3qAK)p0skVjGY3LJZ`oAAT zQ1T&#vE8(EMg=XfPzhB-0qz}RmMO8-vkssAACa2O$5k7&D0SngrJtaHhX)SX9n;zz_M+ZUw%^$pPTpUlOsT z?gz3b>Gg{h#akvn6MRon`ULvjEM#5y}{wRl;YZ`G;1>c->4g`b{1BNb#UTGPfj{p^#ImX>`jx9WDXJoVw@kh- zTX!wKPT~Py4zTp|Pl&3Bm&y0GbsRXcBN6jFcfhw~nWdk9R8&R0Onzoty};2OiP$N8 zAdqsdKsT803seKM%H*fnx&|Cpp8O$@YU1(p8*T_3LM7ZExK;|HO8x`jpunN+iJ0$0 z9s(=p9y{OOCx$Lm!acom(Ig*~kFM>BSfIllLP-}Nz0iI_U_UD1K30k7l7C2G|Mo;I z@SzT&#k-GOX#Y@DKmS<7$v-CW!S+Nf)L{-HsDEa{$1a z`n@Y8=@g}sBVSKZ(%H_$Iz`FGn+x_X7FdI-h^x?dX^`KGF0u{cg2iUMA+(*xnbQU;C$R9mR((m3*P~_DC0G5RN za^O`_E#=xtK>iT=GUYUI($B7DSmfOSHd^aC>w#Cqn3qvG-?`2TOc3%fi@v}8MBwa> zL~Qb(C!C75`5g;IwUn2LD#|XDq~!2|lmFO?+v&3g+Mb<>*g)9l3yOj}z=oxzyhsd< zsHkZjD(yH{vHX&sK*I$GfpY@=nLROaRRRmZ%omNkctKQM@Od2KQ8^(0 zmS}wbRnaKrAuCq%qFcmTp-^-OII(%&o+^PFRN8U2sDNO#sKnO{OYEppRPN;|(Y>q+ z(e(dEqDjyrXe#J1nh}Z;( zr{{wLSdKfG^kQo2~002ov JPDHLkV1nl?ouU8$ literal 0 HcmV?d00001