diff --git a/anime/AniZone.js b/anime/AniZone.js new file mode 100644 index 0000000..7aed675 --- /dev/null +++ b/anime/AniZone.js @@ -0,0 +1,164 @@ +class Anizone { + constructor() { + this.type = "anime-board"; + this.version = "1.0"; + this.api = "https://anizone.to"; + } + + getSettings() { + return { + episodeServers: ["HLS"], + supportsDub: true, + }; + } + + async search(queryObj) { + const query = queryObj.query ?? ""; + + const res = await fetch( + `${this.api}/anime?search=${encodeURIComponent(query)}`, + { + headers: { + accept: "*/*", + referer: "https://anizone.to/", + "user-agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36", + }, + } + ); + + const html = await res.text(); + + const itemRegex = + /]*class="relative overflow-hidden h-26 rounded-lg[\s\S]*?]*src="([^"]+)"[^>]*alt="([^"]+)"[\s\S]*?]*href="([^"]+)"[^>]*title="([^"]+)"/g; + + const results = []; + let match; + + while ((match = itemRegex.exec(html)) !== null) { + const [, image, altTitle, href, title] = match; + const animeId = href.split("/").pop(); + + // detectar sub / dub desde el episodio 1 + let subOrDub = "sub"; + + try { + const epHtml = await fetch(`${this.api}/anime/${animeId}/1`, { + headers: { + referer: "https://anizone.to/", + "user-agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36", + }, + }).then(r => r.text()); + + const audioMatch = epHtml.match( + /
([\s\S]*?)<\/div>/ + ); + + if (audioMatch) { + const block = audioMatch[1]; + const hasJP = /Japanese/i.test(block); + const hasOther = /(English|Spanish|French|German|Italian)/i.test(block); + + if (hasJP && hasOther) subOrDub = "both"; + else if (hasOther) subOrDub = "dub"; + } + } catch {} + + results.push({ + id: animeId, + title: title || altTitle, + image, + url: href, + subOrDub, + }); + } + + return results; + } + + async getMetadata(id) { + // HARDCODED de momento + return { + id, + title: "Unknown", + summary: "", + episodes: 0, + status: "unknown", + season: null, + year: null, + genres: [], + score: 0, + image: null, + }; + } + + async findEpisodes(id) { + const html = await fetch(`${this.api}/anime/${id}/1`).then(r => r.text()); + + const regex = + /]*href="([^"]*\/anime\/[^"]+?)"[^>]*>\s*]*>\s*]*class='[^']*min-w-10[^']*'[^>]*>(\d+)<\/div>\s*]*class="[^"]*line-clamp-1[^"]*"[^>]*>([^<]+)<\/div>/g; + + const episodes = []; + let match; + + while ((match = regex.exec(html)) !== null) { + const [, href, num, title] = match; + + episodes.push({ + id: href.split("/").pop(), + number: Number(num), + title: title.trim(), + url: href, + }); + } + + return episodes; + } + + async findEpisodeServer(episode, server) { + const html = await fetch(episode.url).then(r => r.text()); + + const srcMatch = html.match( + /]+src="([^"]+\.m3u8)"/i + ); + if (!srcMatch) throw new Error("No m3u8 found"); + + const masterUrl = srcMatch[1]; + + const subtitles = []; + const trackRegex = + /]+src=([^ >]+)[^>]*label="([^"]+)"[^>]*srclang="([^"]+)"[^>]*(default)?/gi; + + let match; + while ((match = trackRegex.exec(html)) !== null) { + const [, src, label, lang, def] = match; + subtitles.push({ + id: lang, + url: src, + language: label.trim(), + isDefault: Boolean(def), + }); + } + + return { + server, + headers: { + Origin: "https://anizone.to", + Referer: "https://anizone.to/", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36", + }, + videoSources: [ + { + url: masterUrl, + type: "m3u8", + quality: "auto", + subtitles, + }, + ], + }; + } +} + +module.exports = Anizone; diff --git a/book/asmhentai.js b/book/asmhentai.js index f3aa7a0..e1e0249 100644 --- a/book/asmhentai.js +++ b/book/asmhentai.js @@ -2,7 +2,7 @@ class asmhentai { constructor() { this.baseUrl = "https://asmhentai.com"; this.type = "book-board"; - this.version = "1.0" + this.version = "1.1" this.mediaType = "manga"; } @@ -47,10 +47,9 @@ class asmhentai { if (image.startsWith("//")) image = "https:" + image; - const genres = $(".tags .tag_list .badge.tag") - .map((_, el) => $(el).clone().children().remove().end().text().trim()) + const genres = $(".tags a.tag") + .map((_, el) => $(el).text().trim()) .get() - .join(", "); return { id, @@ -67,38 +66,64 @@ class asmhentai { } async findChapters(mangaId) { - const html = await fetch(`${this.baseUrl}/g/${mangaId}/`).then(r => r.text()); - const $ = this.cheerio.load(html); - - const title = $("h1").first().text().trim() || "Chapter 1"; - - let thumb = $(".gallery img").first().attr("data-src") || ""; - if (thumb.startsWith("//")) thumb = "https:" + thumb; - - const base = thumb.match(/https:\/\/[^\/]+\/\d+\/\d+\//)?.[0]; - const pages = parseInt($(".pages").text().match(/\d+/)?.[0] || "0"); - const ext = thumb.match(/\.(jpg|png|jpeg|gif)/i)?.[1] || "jpg"; - - const chapterId = Buffer.from(JSON.stringify({ base, pages, ext })).toString("base64"); - return [{ - id: chapterId, - title, + id: mangaId.toString(), + title: "Chapter", number: 1, - releaseDate: null, index: 0 }]; } async findChapterPages(chapterId) { - const { base, pages, ext } = JSON.parse( - Buffer.from(chapterId, "base64").toString("utf8") - ); + const html = await fetch(`${this.baseUrl}/g/${chapterId}/`).then(r => r.text()); + const $ = this.cheerio.load(html); - return Array.from({ length: pages }, (_, i) => ({ - url: `${base}${i + 1}.${ext}`, - index: i - })); + const token = $('meta[name="csrf-token"]').attr("content") || ""; + const loadId = $("#load_id").val(); + const loadDir = $("#load_dir").val(); + const totalPages = $("#t_pages").val() || "0"; + + const body = new URLSearchParams({ + id: loadId, + dir: loadDir, + visible_pages: "0", + t_pages: totalPages, + type: "2", + }); + + if (token) body.append("_token", token); + + const res = await fetch(`${this.baseUrl}/gallery/`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Referer": `${this.baseUrl}/g/${chapterId}/`, + }, + body + }).then(r => r.text()); + + const $$ = this.cheerio.load(res); + + return $$("img[data-src], img[src]").get() + .map((el) => { + let url = $$(el).attr("data-src") || $$(el).attr("src"); + if (url?.startsWith("//")) url = "https:" + url; + return url; + }) + .filter(url => { + // Mantenemos el filtro que te funcionó + return url && url.includes("images.") && !url.includes("/images/"); + }) + .map((url, i) => { + // Reemplazamos "thumb" por el número del índice + 1 + // Ejemplo: .../thumb.jpg -> .../1.jpg + const newUrl = url.replace("thumb", (i + 1).toString()); + + return { + index: i, + url: newUrl + }; + }); } } diff --git a/book/comix.js b/book/comix.js new file mode 100644 index 0000000..89740ac --- /dev/null +++ b/book/comix.js @@ -0,0 +1,155 @@ +class Comix { + constructor() { + this.baseUrl = "https://comix.to"; + this.apiUrl = "https://comix.to/api/v2"; + this.type = "book-board"; + this.version = "1.0"; + this.mediaType = "manga"; + } + + get headers() { + return { + "Referer": `${this.baseUrl}/`, + "User-Agent": "Mozilla/5.0" + }; + } + + async search(queryObj) { + const q = (queryObj.query || "").trim().replace(/\s+/g, "+"); + + const url = new URL(`${this.apiUrl}/manga`); + if (q) { + url.searchParams.set("keyword", q); + url.searchParams.set("order[relevance]", "desc"); + } else { + url.searchParams.set("order[views_30d]", "desc"); + } + url.searchParams.set("limit", "50"); + url.searchParams.set("page", "1"); + + const res = await fetch(url, { headers: this.headers }); + if (!res.ok) throw new Error(`Search failed: ${res.status}`); + + const json = await res.json(); + + return json.result.items.map(m => ({ + id: m.hash_id, + title: m.title, + image: m.poster?.large || null, + rating: m.score ?? null, + type: "book" + })); + } + + async getMetadata(id) { + const url = `${this.apiUrl}/manga/${id}?includes[]=genre&includes[]=author&includes[]=artist`; + + const res = await fetch(url, { headers: this.headers }); + if (!res.ok) throw new Error(`Metadata failed: ${res.status}`); + + const { result } = await res.json(); + + return { + id: result.hash_id, + title: result.title, + format: "MANGA", + score: result.score ?? 0, + genres: result.genres?.map(g => g.name).join(", ") ?? "", + status: result.status ?? "unknown", + published: result.created_at ?? "", + summary: result.description ?? "", + chapters: result.chapters_count ?? 0, + image: result.poster?.large || null + }; + } + + async getSlug(mangaId) { + const res = await fetch(`${this.apiUrl}/manga/${mangaId}`, { + headers: this.headers + }); + if (!res.ok) return ""; + const { result } = await res.json(); + return result?.slug || ""; + } + + async findChapters(mangaId) { + const slug = await this.getSlug(mangaId); + if (!slug) return []; + + const baseUrl = `${this.apiUrl}/manga/${mangaId}/chapters?order[number]=desc&limit=100`; + + const res = await fetch(baseUrl, { headers: this.headers }); + if (!res.ok) return []; + + const first = await res.json(); + const totalPages = first.result.pagination?.last_page || 1; + + let all = [...first.result.items]; + + for (let p = 2; p <= totalPages; p++) { + const r = await fetch(`${baseUrl}&page=${p}`, { headers: this.headers }); + if (!r.ok) continue; + const d = await r.json(); + if (d?.result?.items) all.push(...d.result.items); + } + + const map = new Map(); + + for (const ch of all) { + if (ch.language !== "en") continue; + + const key = ch.number; + if (!map.has(key) || ch.is_official === 1) { + map.set(key, ch); + } + } + + return Array.from(map.values()) + .sort((a, b) => a.number - b.number) + .map((ch, i) => ({ + id: `${mangaId}|${slug}|${ch.chapter_id}|${ch.number}`, + title: ch.name + ? `Chapter ${ch.number} — ${ch.name}` + : `Chapter ${ch.number}`, + number: Number(ch.number), + releaseDate: ch.updated_at ?? null, + index: i + })); + } + + async findChapterPages(chapterId) { + const parts = chapterId.split("|"); + console.log(parts) + if (parts.length < 4) return []; + + const [hashId, slug, chapterRealId, number] = parts; + const readerUrl = `${this.baseUrl}/title/${hashId}-${slug}/${chapterRealId}-chapter-${number}`; + + const res = await fetch(readerUrl, { headers: this.headers }); + if (!res.ok) return []; + + const html = await res.text(); + + const regex = /["\\]*images["\\]*\s*:\s*(\[[^\]]*\])/s; + const match = html.match(regex); + if (!match?.[1]) return []; + + let images; + try { + images = JSON.parse(match[1]); + } catch { + images = JSON.parse(match[1].replace(/\\"/g, '"')); + } + + return images.map((img, i) => ({ + url: img.url, + index: i, + headers: { + Referer: readerUrl, + "User-Agent": "Mozilla/5.0" + } + })); + } +} + +module.exports = Comix; \ No newline at end of file diff --git a/book/mangafire.js b/book/mangafire.js new file mode 100644 index 0000000..d65da4f --- /dev/null +++ b/book/mangafire.js @@ -0,0 +1,338 @@ +class MangaFire { + constructor() { + this.baseUrl = "https://mangafire.to"; + this.type = "book-board"; + this.version = "1.0"; + this.mediaType = "manga"; + } + + async search(queryObj) { + const query = queryObj.query.trim(); + const vrf = this.generate(query); + const res = await fetch( + `${this.baseUrl}/ajax/manga/search?keyword=${query.replaceAll(" ", "+")}&vrf=${vrf}` + ); + const data = await res.json(); + if (!data?.result?.html) return []; + + const $ = this.cheerio.load(data.result.html); + + return $("a.unit") + .map((_, e) => { + const el = $(e); + return { + id: el.attr("href")?.replace("/manga/", ""), + title: el.find("h6").text().trim(), + image: el.find("img").attr("src") || null, + rating: null, + type: "book", + }; + }) + .get(); + } + + async getMetadata(id) { + const res = await fetch(`${this.baseUrl}/manga/${id}`); + const html = await res.text(); + const $ = this.cheerio.load(html); + + const info = $(".info").first(); + + const title = info.find("h1[itemprop='name']").text().trim(); + + const scoreText = info + .find("span b") + .filter((_, e) => $(e).text().includes("MAL")) + .first() + .text(); + const score = parseFloat(scoreText.replace(/[^\d.]/g, "")) || 0; + + const genres = $("span:contains('Genres:')") + .next("span") + .find("a") + .map((_, e) => $(e).text().trim()) + .get() + .join(", "); + + const status = info.find("p").first().text().trim().toLowerCase(); + + const published = $("span:contains('Published:')") + .next("span") + .text() + .trim(); + + const summary = $(".description").text().trim(); + + const image = + $(".poster img[itemprop='image']").attr("src") || null; + + return { + id, + title, + format: "MANGA", + score, + genres, + status, + published, + summary, + chapters: 0, + image, + }; + } + + async findChapters(mangaId) { + const res = await fetch(`${this.baseUrl}/manga/${mangaId}`); + const html = await res.text(); + const $ = this.cheerio.load(html); + + const langs = this.extractLanguageCodes($); + const all = []; + + for (const lang of langs) { + const chapters = await this.fetchChaptersForLanguage(mangaId, lang); + all.push(...chapters); + } + + return all; + } + + extractLanguageCodes($) { + const map = new Map(); + + $("[data-code][data-title]").each((_, e) => { + let code = $(e).attr("data-code")?.toLowerCase() || ""; + const title = $(e).attr("data-title") || ""; + + if (code === "es" && title.includes("LATAM")) code = "es-la"; + else if (code === "pt" && title.includes("Br")) code = "pt-br"; + + map.set(code, code); + }); + + return [...map.values()]; + } + + async fetchChaptersForLanguage(mangaId, lang) { + const mangaIdShort = mangaId.split(".").pop(); + const vrf = this.generate(mangaIdShort + "@chapter@" + lang); + + const res = await fetch( + `${this.baseUrl}/ajax/read/${mangaIdShort}/chapter/${lang}?vrf=${vrf}` + ); + const data = await res.json(); + if (!data?.result?.html) return []; + + const $ = this.cheerio.load(data.result.html); + const chapters = []; + + $("a[data-number][data-id]").each((i, e) => { + chapters.push({ + id: $(e).attr("data-id"), + title: $(e).attr("title") || "", + number: Number($(e).attr("data-number")) || i + 1, + language: this.normalizeLanguageCode(lang), + releaseDate: null, + index: i, + }); + }); + + return chapters.reverse().map((c, i) => ({ ...c, index: i })); + } + + normalizeLanguageCode(lang) { + const map = { + en: "en", + fr: "fr", + es: "es", + "es-la": "es-419", + pt: "pt", + "pt-br": "pt-br", + ja: "ja", + de: "de", + it: "it", + ru: "ru", + ko: "ko", + zh: "zh", + "zh-cn": "zh-cn", + "zh-tw": "zh-tw", + ar: "ar", + tr: "tr", + }; + + return map[lang] || lang; + } + + async findChapterPages(chapterId) { + const vrf = this.generate("chapter@" + chapterId); + const res = await fetch( + `${this.baseUrl}/ajax/read/chapter/${chapterId}?vrf=${vrf}` + ); + const data = await res.json(); + const images = data?.result?.images; + if (!images?.length) return []; + + return images.map((img, i) => ({ + url: img[0], + index: i, + headers: { + Referer: this.baseUrl, + }, + })); + } + + textEncode(str) { + return Uint8Array.from(Buffer.from(str, "utf-8")); + } + + textDecode(bytes) { + return Buffer.from(bytes).toString("utf-8"); + } + + atob(data) { + return Uint8Array.from(Buffer.from(data, "base64")); + } + + btoa(data) { + return Buffer.from(data).toString("base64"); + } + + add8(n) { + return (c) => (c + n) & 0xff; + } + + sub8(n) { + return (c) => (c - n + 256) & 0xff; + } + + xor8(n) { + return (c) => (c ^ n) & 0xff; + } + + rotl8(n) { + return (c) => ((c << n) | (c >> (8 - n))) & 0xff; + } + + rotr8(n) { + return (c) => ((c >> n) | (c << (8 - n))) & 0xff; + } + + scheduleC = [ + this.sub8(223), this.rotr8(4), this.rotr8(4), this.add8(234), this.rotr8(7), + this.rotr8(2), this.rotr8(7), this.sub8(223), this.rotr8(7), this.rotr8(6), + ]; + + scheduleY = [ + this.add8(19), this.rotr8(7), this.add8(19), this.rotr8(6), this.add8(19), + this.rotr8(1), this.add8(19), this.rotr8(6), this.rotr8(7), this.rotr8(4), + ]; + + scheduleB = [ + this.sub8(223), this.rotr8(1), this.add8(19), this.sub8(223), this.rotl8(2), + this.sub8(223), this.add8(19), this.rotl8(1), this.rotl8(2), this.rotl8(1), + ]; + + scheduleJ = [ + this.add8(19), this.rotl8(1), this.rotl8(1), this.rotr8(1), this.add8(234), + this.rotl8(1), this.sub8(223), this.rotl8(6), this.rotl8(4), this.rotl8(1), + ]; + + scheduleE = [ + this.rotr8(1), this.rotl8(1), this.rotl8(6), this.rotr8(1), this.rotl8(2), + this.rotr8(4), this.rotl8(1), this.rotl8(1), this.sub8(223), this.rotl8(2), + ]; + + rc4Keys = { + l: "FgxyJUQDPUGSzwbAq/ToWn4/e8jYzvabE+dLMb1XU1o=", + g: "CQx3CLwswJAnM1VxOqX+y+f3eUns03ulxv8Z+0gUyik=", + B: "fAS+otFLkKsKAJzu3yU+rGOlbbFVq+u+LaS6+s1eCJs=", + m: "Oy45fQVK9kq9019+VysXVlz1F9S1YwYKgXyzGlZrijo=", + F: "aoDIdXezm2l3HrcnQdkPJTDT8+W6mcl2/02ewBHfPzg=", + }; + + seeds32 = { + A: "yH6MXnMEcDVWO/9a6P9W92BAh1eRLVFxFlWTHUqQ474=", + V: "RK7y4dZ0azs9Uqz+bbFB46Bx2K9EHg74ndxknY9uknA=", + N: "rqr9HeTQOg8TlFiIGZpJaxcvAaKHwMwrkqojJCpcvoc=", + P: "/4GPpmZXYpn5RpkP7FC/dt8SXz7W30nUZTe8wb+3xmU=", + k: "wsSGSBXKWA9q1oDJpjtJddVxH+evCfL5SO9HZnUDFU8=", + }; + + prefixKeys = { + O: "l9PavRg=", + v: "Ml2v7ag1Jg==", + L: "i/Va0UxrbMo=", + p: "WFjKAHGEkQM=", + W: "5Rr27rWd", + }; + + rc4(key, input) { + const s = new Uint8Array(256); + for (let i = 0; i < 256; i++) s[i] = i; + + let j = 0; + for (let i = 0; i < 256; i++) { + j = (j + s[i] + key[i % key.length]) & 0xff; + [s[i], s[j]] = [s[j], s[i]]; + } + + const output = new Uint8Array(input.length); + let i = 0; + j = 0; + for (let y = 0; y < input.length; y++) { + i = (i + 1) & 0xff; + j = (j + s[i]) & 0xff; + [s[i], s[j]] = [s[j], s[i]]; + const k = s[(s[i] + s[j]) & 0xff]; + output[y] = input[y] ^ k; + } + + return output; + } + + transform(input, initSeedBytes, prefixKeyBytes, prefixLen, schedule) { + const out = []; + for (let i = 0; i < input.length; i++) { + if (i < prefixLen) { + out.push(prefixKeyBytes[i] || 0); + } + const transformed = schedule[i % 10]((input[i] ^ initSeedBytes[i % 32]) & 0xff) & 0xff; + out.push(transformed); + } + return new Uint8Array(out); + } + + generate(input) { + let encodedInput = encodeURIComponent(input); + let bytes = this.textEncode(encodedInput); + + // Etapa 1: RC4 con clave "l" + Transform con schedule_c + bytes = this.rc4(this.atob(this.rc4Keys["l"]), bytes); + const prefix_O = this.atob(this.prefixKeys["O"]); + bytes = this.transform(bytes, this.atob(this.seeds32["A"]), prefix_O, prefix_O.length, this.scheduleC); + + // Etapa 2: RC4 con clave "g" + Transform con schedule_y + bytes = this.rc4(this.atob(this.rc4Keys["g"]), bytes); + const prefix_v = this.atob(this.prefixKeys["v"]); + bytes = this.transform(bytes, this.atob(this.seeds32["V"]), prefix_v, prefix_v.length, this.scheduleY); + + // Etapa 3: RC4 con clave "B" + Transform con schedule_b + bytes = this.rc4(this.atob(this.rc4Keys["B"]), bytes); + const prefix_L = this.atob(this.prefixKeys["L"]); + bytes = this.transform(bytes, this.atob(this.seeds32["N"]), prefix_L, prefix_L.length, this.scheduleB); + + // Etapa 4: RC4 con clave "m" + Transform con schedule_j + bytes = this.rc4(this.atob(this.rc4Keys["m"]), bytes); + const prefix_p = this.atob(this.prefixKeys["p"]); + bytes = this.transform(bytes, this.atob(this.seeds32["P"]), prefix_p, prefix_p.length, this.scheduleJ); + + // Etapa 5: RC4 con clave "F" + Transform con schedule_e + bytes = this.rc4(this.atob(this.rc4Keys["F"]), bytes); + const prefix_W = this.atob(this.prefixKeys["W"]); + bytes = this.transform(bytes, this.atob(this.seeds32["k"]), prefix_W, prefix_W.length, this.scheduleE); + + // Base64URL encode + return this.btoa(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } +} + +module.exports = MangaFire; diff --git a/book/mangapill.js b/book/mangapill.js new file mode 100644 index 0000000..c143ce8 --- /dev/null +++ b/book/mangapill.js @@ -0,0 +1,182 @@ +class MangaPill { + constructor() { + this.baseUrl = "https://mangapill.com"; + this.type = "book-board"; + this.version = "1.0"; + this.mediaType = "manga"; + } + + async fetch(url) { + return fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0", + Referer: this.baseUrl, + }, + }); + } + + async search(queryObj) { + const q = queryObj.query || ""; + const res = await this.fetch( + `${this.baseUrl}/search?q=${encodeURIComponent(q)}` + ); + const html = await res.text(); + const $ = this.cheerio.load(html); + + const results = []; + + $("div.container div.my-3.justify-end > div").each((_, el) => { + const link = $(el).find("a").attr("href"); + if (!link) return; + + const id = link.split("/manga/")[1].replace(/\//g, "$"); + const title = $(el).find("div > a > div.mt-3").text().trim(); + const image = $(el).find("a img").attr("data-src") || null; + + results.push({ + id, + title, + image, + rating: null, + type: "book", + }); + }); + + return results; + } + + async getMetadata(id) { + const uriId = id.replace(/\$/g, "/"); + let res = await fetch(`${this.baseUrl}/manga/${uriId}`, { + headers: this.headers, + redirect: "manual", + }); + + // follow redirect manually + if (res.status === 301 || res.status === 302) { + const loc = res.headers.get("location"); + if (loc) { + res = await fetch(`${this.baseUrl}${loc}`, { + headers: this.headers, + }); + } + } + + if (!res.ok) { + return { + id, + title: "", + format: "MANGA", + score: 0, + genres: "", + status: "unknown", + published: "", + summary: "", + chapters: "???", + image: null, + }; + } + + const html = await res.text(); + const $ = this.cheerio.load(html); + + const title = $("h1.font-bold").first().text().trim(); + + const summary = + $("div.mb-3 p.text-sm").first().text().trim() || ""; + + const status = + $("label:contains('Status')") + .next("div") + .text() + .trim() || "unknown"; + + const published = + $("label:contains('Year')") + .next("div") + .text() + .trim() || ""; + + const genres = []; + $("label:contains('Genres')") + .parent() + .find("a") + .each((_, a) => genres.push($(a).text().trim())); + + const image = + $("img[data-src]").first().attr("data-src") || null; + + return { + id, + title, + format: "MANGA", + score: 0, + genres: genres.join(", "), + status, + published, + summary, + chapters: "???", + image + }; + } + + async findChapters(mangaId) { + const uriId = mangaId.replace(/\$/g, "/"); + const res = await this.fetch(`${this.baseUrl}/manga/${uriId}`); + const html = await res.text(); + const $ = this.cheerio.load(html); + + const chapters = []; + + $("div#chapters a").each((_, el) => { + const href = $(el).attr("href"); + if (!href) return; + + const id = href.split("/chapters/")[1].replace(/\//g, "$"); + const title = $(el).text().trim(); + const match = title.match(/Chapter\s+([\d.]+)/); + const number = match ? Number(match[1]) : 0; + + chapters.push({ + id, + title, + number, + releaseDate: null, + index: 0, + }); + }); + + chapters.reverse(); + chapters.forEach((c, i) => (c.index = i)); + return chapters; + } + + async findChapterPages(chapterId) { + const uriId = chapterId.replace(/\$/g, "/"); + const res = await this.fetch(`${this.baseUrl}/chapters/${uriId}`); + const html = await res.text(); + const $ = this.cheerio.load(html); + + const pages = []; + + $("chapter-page").each((i, el) => { + const img = $(el).find("div picture img").attr("data-src"); + if (!img) return; + + pages.push({ + url: img, + index: i, + headers: { + Referer: "https://mangapill.com/", + Origin: "https://mangapill.com", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + }, + }); + }); + + return pages; + } +} + +module.exports = MangaPill; \ No newline at end of file diff --git a/book/nhentai.js b/book/nhentai.js index 74b8abe..1aa17d3 100644 --- a/book/nhentai.js +++ b/book/nhentai.js @@ -1,182 +1,140 @@ -class nhentai { +class NHentai { constructor() { this.baseUrl = "https://nhentai.net"; this.type = "book-board"; + this.version = "1.1"; this.mediaType = "manga"; - this.version = "1.0" } - async search(queryObj) { - const q = queryObj.query.trim().replace(/\s+/g, "+"); - const url = q - ? `${this.baseUrl}/search/?q=${q}` - : `${this.baseUrl}/?q=`; + shortenTitle(title) { + return title.replace(/(\[[^]]*]|[({][^)}]*[)}])/g, "").trim(); + } - const { result: data } = await this.scrape( - url, - async (page) => { - return page.evaluate(() => { - const container = document.querySelector('.container.index-container'); - if (!container) return {results: [], hasNextPage: false}; + parseId(str) { + return str.replace(/\D/g, ""); + } - const galleryEls = container.querySelectorAll('.gallery'); - const results = []; + extractJson(scriptText) { + const m = scriptText.match(/JSON\.parse\("([\s\S]*?)"\)/); + if (!m) throw new Error("JSON.parse no encontrado"); - galleryEls.forEach(el => { - const a = el.querySelector('a.cover'); - if (!a) return; - - const href = a.getAttribute('href'); - const id = href.match(/\d+/)?.[0] || null; - - const img = a.querySelector('img.lazyload'); - const thumbRaw = img?.dataset?.src || img?.src || ""; - const thumb = thumbRaw.startsWith("//") ? "https:" + thumbRaw : thumbRaw; - const coverUrl = thumb.replace("thumb", "cover"); - - const caption = a.querySelector('.caption'); - const title = caption?.textContent.trim() || ""; - - results.push({ - id, - title, - image: coverUrl, - rating: null, - type: "book" - }); - }); - - const hasNextPage = !!document.querySelector('section.pagination a.next'); - return {results, hasNextPage}; - }); - }, - { - waitSelector: '.container.index-container', - timeout: 55000 - } + const unicodeFixed = m[1].replace( + /\\u([0-9A-Fa-f]{4})/g, + (_, h) => String.fromCharCode(parseInt(h, 16)) ); - return data?.results || []; + return JSON.parse(unicodeFixed); + } + + async search({ query = "", page = 1 }) { + if (query.startsWith("id:") || (!isNaN(query) && query.length <= 7)) { + return [await this.getMetadata(this.parseId(query))]; + } + + const url = `${this.baseUrl}/search/?q=${encodeURIComponent(query)}&page=${page}`; + const { result } = await this.scrape( + url, + page => + page.evaluate(() => document.documentElement.innerHTML), + { waitSelector: ".gallery" } + ); + + const $ = this.cheerio.load(result); + return $(".gallery").map((_, el) => ({ + id: this.parseId($(el).find("a").attr("href")), + image: $(el).find("img").attr("data-src") || $(el).find("img").attr("src"), + title: this.shortenTitle($(el).find(".caption").text()), + type: "book" + })).get(); } async getMetadata(id) { - const { result: data } = await this.scrape( - `${this.baseUrl}/g/${id}/`, - async (page) => { - return page.evaluate(() => { - const title = document.querySelector('h1.title .pretty')?.textContent?.trim() || ""; + const url = `${this.baseUrl}/g/${id}/`; - const img = document.querySelector('#cover img'); - const image = - img?.dataset?.src ? "https:" + img.dataset.src : - img?.src?.startsWith("//") ? "https:" + img.src : - img?.src || ""; + const { result } = await this.scrape( + url, + page => + page.evaluate(() => { + const html = document.documentElement.innerHTML; - const tagBlock = document.querySelector('.tag-container.field-name'); - const genres = tagBlock - ? [...tagBlock.querySelectorAll('.tags .name')].map(x => x.textContent.trim()) - : []; + const script = [...document.querySelectorAll("script")] + .find(s => + s.textContent.includes("JSON.parse") && + !s.textContent.includes("media_server") && + !s.textContent.includes("avatar_url") + )?.textContent || null; - const timeEl = document.querySelector('.tag-container.field-name time'); - const published = - timeEl?.getAttribute("datetime") || - timeEl?.textContent?.trim() || - "???"; + const thumbMatch = html.match(/thumb_cdn_urls:\s*(\[[^\]]*])/); + const thumbCdns = thumbMatch ? JSON.parse(thumbMatch[1]) : []; - return {title, image, genres, published}; - }); - }, - { - waitSelector: "#bigcontainer", - timeout: 55000 - } + return { script, thumbCdns }; + }) ); - if (!data) throw new Error(`Fallo al obtener metadatos para ID ${id}`); + if (!result?.script) { + throw new Error("Script de datos no encontrado"); + } - const formattedDate = data.published - ? new Date(data.published).toLocaleDateString("es-ES") - : "???"; + const data = this.extractJson(result.script); + const cdn = result.thumbCdns[0] || "t3.nhentai.net"; return { - id, - title: data.title || "", - format: "Manga", - score: 0, - genres: Array.isArray(data.genres) ? data.genres : [], - status: "Finished", - published: formattedDate, - summary: "", + id: id.toString(), + title: data.title.pretty || data.title.english, + format: "MANGA", + status: "completed", + genres: data.tags + .filter(t => t.type === "tag") + .map(t => t.name), + published: new Date(data.upload_date * 1000).toLocaleDateString(), + summary: `Pages: ${data.images.pages.length}\nFavorites: ${data.num_favorites}`, chapters: 1, - image: data.image || "" + image: `https://${cdn}/galleries/${data.media_id}/cover.webp` }; } async findChapters(mangaId) { - const { result: data } = await this.scrape( - `${this.baseUrl}/g/${mangaId}/`, - async (page) => { - return page.evaluate(() => { - const title = document.querySelector('#info > h1 .pretty')?.textContent?.trim() || ""; - - const img = document.querySelector('#cover img'); - const cover = - img?.dataset?.src ? "https:" + img.dataset.src : - img?.src?.startsWith("//") ? "https:" + img.src : - img?.src || ""; - - const hash = cover.match(/galleries\/(\d+)\//)?.[1] || null; - - const thumbs = document.querySelectorAll('.thumbs img'); - const pages = thumbs.length; - - const first = thumbs[0]; - const s = first?.dataset?.src || first?.src || ""; - const ext = s.match(/t\.(\w+)/)?.[1] || "jpg"; - - const langTag = [...document.querySelectorAll('#tags .tag-container')] - .find(x => x.textContent.includes("Languages:")); - - const language = langTag?.querySelector('.tags .name')?.textContent?.trim() || ""; - - return {title, cover, hash, pages, ext, language}; - }); - }, - { - waitSelector: '#bigcontainer', - timeout: 55000 - } - ); - - if (!data?.hash) throw new Error(`Fallo al obtener hash para ID ${mangaId}`); - - const encodedChapterId = Buffer.from(JSON.stringify({ - hash: data.hash, - pages: data.pages, - ext: data.ext - })).toString("base64"); - return [{ - id: encodedChapterId, - title: data.title, + id: mangaId.toString(), + title: "Chapter", number: 1, - releaseDate: null, - index: 0, + index: 0 }]; } async findChapterPages(chapterId) { - const decoded = JSON.parse(Buffer.from(chapterId, "base64").toString("utf8")); + const url = `${this.baseUrl}/g/${chapterId}/`; + const { result } = await this.scrape( + url, + page => + page.evaluate(() => { + const html = document.documentElement.innerHTML; + const cdnMatch = html.match(/image_cdn_urls:\s*(\[[^\]]*])/); + const s = [...document.querySelectorAll("script")] + .find(x => + x.textContent.includes("JSON.parse") && + !x.textContent.includes("media_server") && + !x.textContent.includes("avatar_url") + ); + return { + script: s?.textContent || null, + cdns: cdnMatch ? JSON.parse(cdnMatch[1]) : ["i.nhentai.net"] + }; + }) + ); - const { hash, pages, ext } = decoded; - const baseUrl = "https://i.nhentai.net/galleries"; + if (!result?.script) throw new Error("Datos no encontrados"); + const data = this.extractJson(result.script); + const cdn = result.cdns[0]; - return Array.from({ length: pages }, (_, i) => ({ - url: `${baseUrl}/${hash}/${i + 1}.${ext}`, - index: i, - headers: { Referer: `https://nhentai.net/g/${hash}/` } - })); + return data.images.pages.map((p, i) => { + const ext = p.t === "j" ? "jpg" : p.t === "p" ? "png" : "webp"; + return { + index: i, + url: `https://${cdn}/galleries/${data.media_id}/${i + 1}.${ext}` + }; + }); } } -module.exports = nhentai; \ No newline at end of file +module.exports = NHentai; \ No newline at end of file diff --git a/book/novelfire.js b/book/novelfire.js index cf108e4..41c37bd 100644 --- a/book/novelfire.js +++ b/book/novelfire.js @@ -3,7 +3,7 @@ class NovelFire { this.baseUrl = "https://novelfire.net"; this.type = "book-board"; this.mediaType = "ln"; - this.version = "1.0" + this.version = "1.1" } async search(queryObj) { @@ -71,18 +71,21 @@ class NovelFire { } async findChapters(bookId) { - const url = `https://novelfire.net/book/${bookId}/chapters`; - const html = await (await fetch(url)).text(); - + const chapterUrl = `https://novelfire.net/book/${bookId}/chapter-1`; + const html = await (await fetch(chapterUrl)).text(); const $ = this.cheerio.load(html); - let postId; + // csrf token + const csrf = $('meta[name="csrf-token"]').attr('content'); + if (!csrf) throw new Error("csrf-token not found"); + + // post_id desde script + let postId; $("script").each((_, el) => { const txt = $(el).html() || ""; - const m = txt.match(/listChapterDataAjax\?post_id=(\d+)/); + const m = txt.match(/post_id\s*=\s*parseInt\("(\d+)"\)/); if (m) postId = m[1]; }); - if (!postId) throw new Error("post_id not found"); const params = new URLSearchParams({ @@ -100,7 +103,13 @@ class NovelFire { const res = await fetch( `https://novelfire.net/listChapterDataAjax?${params}`, - { headers: { "x-requested-with": "XMLHttpRequest" } } + { + headers: { + "x-requested-with": "XMLHttpRequest", + "x-csrf-token": csrf, + "referer": chapterUrl + } + } ); const json = await res.json(); diff --git a/book/weebcentral.js b/book/weebcentral.js new file mode 100644 index 0000000..f2627e2 --- /dev/null +++ b/book/weebcentral.js @@ -0,0 +1,227 @@ +class WeebCentral { + constructor() { + this.baseUrl = "https://weebcentral.com"; + this.type = "book-board"; + this.version = "1.0"; + this.mediaType = "manga"; + } + + async fetch(url, options = {}) { + return fetch(url, { + ...options, + headers: { + "User-Agent": "Mozilla/5.0", + ...(options.headers || {}), + }, + }); + } + + async search(queryObj) { + const query = queryObj.query || ""; + const form = new URLSearchParams(); + form.set("text", query); + + const res = await this.fetch( + `${this.baseUrl}/search/simple?location=main`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "HX-Request": "true", + "HX-Trigger": "quick-search-input", + "HX-Target": "quick-search-result", + "HX-Current-URL": `${this.baseUrl}/`, + }, + body: form.toString(), + } + ); + + const html = await res.text(); + const $ = this.cheerio.load(html); + + const results = []; + + $("#quick-search-result > div > a").each((_, el) => { + const link = $(el).attr("href"); + if (!link) return; + + const idMatch = link.match(/\/series\/([^/]+)/); + if (!idMatch) return; + + const title = $(el).find(".flex-1").text().trim(); + + let image = + $(el).find("source").attr("srcset") || + $(el).find("img").attr("src") || + null; + + results.push({ + id: idMatch[1], + title, + image, + rating: null, + type: "book", + }); + }); + + return results; + } + + async getMetadata(id) { + const res = await this.fetch(`${this.baseUrl}/series/${id}`, { + headers: { Referer: `${this.baseUrl}/series/${id}` }, + }); + if (!res.ok) throw new Error("Metadata failed"); + + const html = await res.text(); + const $ = this.cheerio.load(html); + + const title = + $("section.md\\:w-8\\/12 h1").first().text().trim() || ""; + + const genres = []; + $("li strong") + .filter((_, el) => $(el).text().includes("Tags")) + .parent() + .find("a") + .each((_, a) => { + genres.push($(a).text().trim()); + }); + + const status = + $("li strong") + .filter((_, el) => $(el).text().includes("Status")) + .parent() + .find("a") + .first() + .text() + .trim() || "unknown"; + + const published = + $("li strong") + .filter((_, el) => $(el).text().includes("Released")) + .parent() + .find("span") + .first() + .text() + .trim() || ""; + + const summary = + $("li strong") + .filter((_, el) => $(el).text().includes("Description")) + .parent() + .find("p") + .text() + .trim() || ""; + + const image = + $("section.flex picture source").attr("srcset") || + $("section.flex picture img").attr("src") || + null; + + return { + id, + title, + format: "MANGA", + score: 0, + genres: genres.join(", "), + status, + published, + summary, + chapters: "???", + image, + }; + } + + async findChapters(mangaId) { + const res = await this.fetch( + `${this.baseUrl}/series/${mangaId}/full-chapter-list`, + { + headers: { + "HX-Request": "true", + "HX-Target": "chapter-list", + "HX-Current-URL": `${this.baseUrl}/series/${mangaId}`, + Referer: `${this.baseUrl}/series/${mangaId}`, + }, + } + ); + + const html = await res.text(); + const $ = this.cheerio.load(html); + + const chapters = []; + const numRegex = /(\d+(?:\.\d+)?)/; + + $("div.flex.items-center").each((_, el) => { + const a = $(el).find("a"); + if (!a.length) return; + + const href = a.attr("href"); + if (!href) return; + + const idMatch = href.match(/\/chapters\/([^/]+)/); + if (!idMatch) return; + + const title = a.find("span.grow > span").first().text().trim(); + const numMatch = title.match(numRegex); + + chapters.push({ + id: idMatch[1], + title, + number: numMatch ? Number(numMatch[1]) : 0, + releaseDate: null, + index: 0, + }); + }); + + chapters.reverse(); + chapters.forEach((c, i) => (c.index = i)); + return chapters; + } + + async findChapterPages(chapterId) { + const res = await this.fetch( + `${this.baseUrl}/chapters/${chapterId}/images?is_prev=False&reading_style=long_strip`, + { + headers: { + "HX-Request": "true", + "HX-Current-URL": `${this.baseUrl}/chapters/${chapterId}`, + Referer: `${this.baseUrl}/chapters/${chapterId}`, + }, + } + ); + + const html = await res.text(); + const $ = this.cheerio.load(html); + + const pages = []; + + $("section.flex-1 img").each((i, el) => { + const src = $(el).attr("src"); + if (src) { + pages.push({ + url: src, + index: i, + headers: { Referer: this.baseUrl }, + }); + } + }); + + if (pages.length === 0) { + $("img").each((i, el) => { + const src = $(el).attr("src"); + if (src) { + pages.push({ + url: src, + index: i, + headers: { Referer: this.baseUrl }, + }); + } + }); + } + + return pages; + } +} + +module.exports = WeebCentral; \ No newline at end of file diff --git a/marketplace.json b/marketplace.json index dc0acef..cd2c79d 100644 --- a/marketplace.json +++ b/marketplace.json @@ -3,11 +3,19 @@ "AnimeAV1": { "name": "AnimeAV1", "type": "anime-board", - "description": ".", + "description": "Anime provider with dubs and hard subs in spanish.", "author": "lenafx", "entry": "https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/anime/AnimeAV1.js", "domain": "https://animeav1.com/" }, + "Anizone": { + "name": "Anizone", + "type": "anime-board", + "description": "Multi language anime provider, soft subs and dubs.", + "author": "lenafx", + "entry": "https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/anime/AniZone.js", + "domain": "https://anizone.to/" + }, "animepictures": { "name": "Anime Pictures", "type": "book-board", @@ -20,11 +28,12 @@ "asmhentai": { "name": "ASM Hentai", "type": "book-board", - "description": ".", + "description": "Adult manga provider.", "author": "lenafx", "entry": "https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/book/asmhentai.js", "domain": "https://asmhentai.com/", - "nsfw": true + "nsfw": true, + "broken": true }, "gelbooru": { "name": "Gelbooru", @@ -46,7 +55,7 @@ "HiAnime": { "name": "HiAnime", "type": "anime-board", - "description": ".", + "description": "English anime provider with soft subs and dubs", "author": "lenafx", "entry": "https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/anime/HiAnime.js", "domain": "https://hianime.to/" @@ -63,15 +72,47 @@ "mangadex": { "name": "Mangadex", "type": "book-board", - "description": ".", + "description": "English manga provider.", "author": "lenafx", "entry": "https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/book/mangadex.js", "domain": "https://mangadex.org/" }, + "Comix": { + "name": "Comix", + "type": "book-board", + "description": "English manga provider.", + "author": "lenafx", + "entry": "https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/book/comix.js", + "domain": "https://comix.to/" + }, + "MangaPill": { + "name": "MangaPill", + "type": "book-board", + "description": "English manga provider.", + "author": "lenafx", + "entry": "https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/book/mangapill.js", + "domain": "https://mangafire.to/" + }, + "MangaFire": { + "name": "MangaFire", + "type": "book-board", + "description": "Multi language manga provider", + "author": "lenafx", + "entry": "https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/book/mangafire.js", + "domain": "https://mangafire.to/" + }, + "WeebCentral": { + "name": "WeebCentral", + "type": "book-board", + "description": "English manga provider.", + "author": "lenafx", + "entry": "https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/book/weebcentral.js", + "domain": "https://weebcentral.com/" + }, "mangapark": { "name": "Mangapark", "type": "book-board", - "description": ".", + "description": "English manga provider.", "author": "lenafx", "entry": "https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/book/mangapark.js", "domain": "https://mangapark.io/" @@ -79,7 +120,7 @@ "nhentai": { "name": "nhentai", "type": "book-board", - "description": ".", + "description": "Adult manga provider.", "author": "lenafx", "entry": "https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/book/nhentai.js", "domain": "https://nhentai.net/",