all extensions to new format :P

This commit is contained in:
2025-12-15 19:40:07 +01:00
parent 9986b64ace
commit 9fe48f93fe
19 changed files with 1725 additions and 1375 deletions

179
AnimeAV1.js Normal file
View File

@@ -0,0 +1,179 @@
class AnimeAV1 {
constructor() {
this.type = "anime-board"; // Required for scanner
this.api = "https://animeav1.com";
}
getSettings() {
return {
episodeServers: ["HLS", "HLS-DUB"],
supportsDub: true,
};
}
async search(query) {
const res = await fetch(`${this.api}/api/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: query.query }),
});
if (!res.ok) return [];
const data = await res.json();
return data.map(anime => ({
id: anime.title.toLowerCase().replace(/\s+/g, '-'),
title: anime.title,
url: `${this.api}/anime/${anime.slug}`,
subOrDub: "both",
}));
}
async getMetadata(id) {
const html = await fetch(`${this.api}/media/${id}`).then(r => r.text());
const parsed = this.parseSvelteData(html);
const media = parsed.find(x => x?.data?.media)?.data.media ?? {};
// IMAGE
const imageMatch = html.match(/<img[^>]*class="aspect-poster[^"]*"[^>]*src="([^"]+)"/i);
const image = imageMatch ? imageMatch[1] : null;
// BLOCK INFO (STATUS, SEASON, YEAR)
const infoBlockMatch = html.match(
/<div class="flex flex-wrap items-center gap-2 text-sm">([\s\S]*?)<\/div>/
);
let status = media.status ?? "Unknown";
let season = media.seasons ?? null;
let year = media.startDate ? Number(media.startDate.slice(0, 4)) : null;
if (infoBlockMatch) {
const raw = infoBlockMatch[1];
// Extraer spans internos
const spans = [...raw.matchAll(/<span[^>]*>([^<]+)<\/span>/g)].map(m => m[1].trim());
// EJEMPLO:
// ["TV Anime", "•", "2025", "•", "Temporada Otoño", "•", "En emisión"]
const clean = spans.filter(x => x !== "•");
// YEAR
const yearMatch = clean.find(x => /^\d{4}$/.test(x));
if (yearMatch) year = Number(yearMatch);
// SEASON (el que contiene "Temporada")
const seasonMatch = clean.find(x => x.toLowerCase().includes("temporada"));
if (seasonMatch) season = seasonMatch;
// STATUS (normalmente "En emisión", "Finalizado", etc)
const statusMatch = clean.find(x =>
/emisión|finalizado|completado|pausa|cancelado/i.test(x)
);
if (statusMatch) status = statusMatch;
}
return {
title: media.title ?? "Unknown",
summary: media.synopsis ?? "No summary available",
episodes: media.episodesCount ?? 0,
characters: [],
season,
status,
studio: "Unknown",
score: media.score ?? 0,
year,
genres: media.genres?.map(g => g.name) ?? [],
image
};
}
async findEpisodes(id) {
const html = await fetch(`${this.api}/media/${id}`).then(r => r.text());
const parsed = this.parseSvelteData(html);
const media = parsed.find(x => x?.data?.media)?.data?.media;
if (!media?.episodes) throw new Error("No se encontró media.episodes");
return media.episodes.map((ep, i) => ({
id: `${media.slug}$${ep.number ?? i + 1}`,
number: ep.number ?? i + 1,
title: ep.title ?? `Episode ${ep.number ?? i + 1}`,
url: `${this.api}/media/${media.slug}/${ep.number ?? i + 1}`,
}));
}
async findEpisodeServer(episodeOrId, _server) {
const ep = typeof episodeOrId === "string"
? (() => { try { return JSON.parse(episodeOrId); } catch { return { id: episodeOrId }; } })()
: episodeOrId;
const pageUrl = ep.url ?? (
typeof ep.id === "string" && ep.id.includes("$")
? `${this.api}/media/${ep.id.split("$")[0]}/${ep.number ?? ep.id.split("$")[1]}`
: undefined
);
if (!pageUrl) throw new Error("No se pudo determinar la URL del episodio.");
const html = await fetch(pageUrl, {
headers: { Cookie: "__ddg1_=;__ddg2_=;" },
}).then(r => r.text());
const parsedData = this.parseSvelteData(html);
const entry = parsedData.find(x => x?.data?.embeds) || parsedData[3];
const embeds = entry?.data?.embeds;
if (!embeds) throw new Error("No se encontraron 'embeds' en los datos del episodio.");
const selectedEmbeds =
_server === "HLS"
? embeds.SUB ?? []
: _server === "HLS-DUB"
? embeds.DUB ?? []
: (() => { throw new Error(`Servidor desconocido: ${_server}`); })();
if (!selectedEmbeds.length)
throw new Error(`No hay mirrors disponibles para ${_server === "HLS" ? "SUB" : "DUB"}.`);
const match = selectedEmbeds.find(m =>
(m.url || "").includes("zilla-networks.com/play/")
);
if (!match)
throw new Error(`No se encontró ningún embed de ZillaNetworks en ${_server}.`);
return {
server: _server,
headers: { Referer: 'null' },
videoSources: [
{
url: match.url.replace("/play/", "/m3u8/"),
type: "m3u8",
quality: "auto",
subtitles: [],
},
],
};
}
parseSvelteData(html) {
const scriptMatch = html.match(/<script[^>]*>\s*({[^<]*__sveltekit_[\s\S]*?)<\/script>/i);
if (!scriptMatch) throw new Error("No se encontró bloque SvelteKit en el HTML.");
const dataMatch = scriptMatch[1].match(/data:\s*(\[[\s\S]*?\])\s*,\s*form:/);
if (!dataMatch) throw new Error("No se encontró el bloque 'data' en el script SvelteKit.");
const jsArray = dataMatch[1];
try {
return new Function(`"use strict"; return (${jsArray});`)();
} catch {
const cleaned = jsArray.replace(/\bvoid 0\b/g, "null").replace(/undefined/g, "null");
return new Function(`"use strict"; return (${cleaned});`)();
}
}
}
module.exports = AnimeAV1;

235
HiAnime.js Normal file
View File

@@ -0,0 +1,235 @@
class HiAnime {
constructor() {
this.type = "anime-board";
this.baseUrl = "https://hianime.to";
}
getSettings() {
return {
episodeServers: ["HD-1", "HD-2", "HD-3", "HD-4"],
supportsDub: true
};
}
async search(query) {
const normalize = (str) => this.safeString(str).toLowerCase().replace(/[^a-z0-9]+/g, "");
const start = query.media.startDate;
const fetchMatches = async (url) => {
const html = await fetch(url).then(res => res.text());
const regex = /<a href="\/watch\/([^"]+)"[^>]+title="([^"]+)"[^>]+data-id="(\d+)"/g;
return [...html.matchAll(regex)].map(m => {
const id = m[3];
const pageUrl = m[1];
const title = m[2];
const jnameRegex = new RegExp(
`<h3 class="film-name">[\\s\\S]*?<a[^>]+href="\\/${pageUrl}[^"]*"[^>]+data-jname="([^"]+)"`,
"i"
);
const jnameMatch = html.match(jnameRegex);
const jname = jnameMatch ? jnameMatch[1] : null;
const imageRegex = new RegExp(
`<a href="/watch/${pageUrl.replace(/\//g, "\\/")}"[\\s\\S]*?<img[^>]+data-src="([^"]+)"`,
"i"
);
const imageMatch = html.match(imageRegex);
const image = imageMatch ? imageMatch[1] : null;
return {
id,
pageUrl,
title,
image,
normTitleJP: normalize(this.normalizeSeasonParts(jname)),
normTitle: normalize(this.normalizeSeasonParts(title)),
};
});
};
let url = `${this.baseUrl}/search?keyword=${encodeURIComponent(query.query)}&sy=${start.year}&sm=${start.month}&sort=default`;
let matches = await fetchMatches(url);
if (matches.length === 0) return [];
return matches.map(m => ({
id: `${m.id}/${query.dub ? "dub" : "sub"}`,
title: m.title,
image: m.image,
url: `${this.baseUrl}/${m.pageUrl}`,
subOrDub: query.dub ? "dub" : "sub"
}));
}
async findEpisodes(animeId) {
const [id, subOrDub] = animeId.split("/");
const res = await fetch(`${this.baseUrl}/ajax/v2/episode/list/${id}`, {
headers: { "X-Requested-With": "XMLHttpRequest" }
});
const json = await res.json();
const html = json.html;
console.log(html)
const episodes = [];
const regex = /<a[^>]*class="[^"]*\bep-item\b[^"]*"[^>]*data-number="(\d+)"[^>]*data-id="(\d+)"[^>]*href="([^"]+)"[\s\S]*?<div class="ep-name[^"]*"[^>]*title="([^"]+)"/g;
let match;
while ((match = regex.exec(html)) !== null) {
episodes.push({
id: `${match[2]}/${subOrDub}`,
number: parseInt(match[1], 10),
url: this.baseUrl + match[3],
title: match[4],
});
}
return episodes;
}
async findEpisodeServer(episode, _server) {
const [id, subOrDub] = episode.id.split("/");
let serverName = _server !== "default" ? _server : "HD-1";
if (_server === "HD-1" || _server === "HD-2" || _server === "HD-3") {
const serverJson = await fetch(`${this.baseUrl}/ajax/v2/episode/servers?episodeId=${id}`, {
headers: { "X-Requested-With": "XMLHttpRequest" }
}).then(res => res.json());
const serverHtml = serverJson.html;
const regex = new RegExp(
`<div[^>]*class="item server-item"[^>]*data-type="${subOrDub}"[^>]*data-id="(\\d+)"[^>]*>\\s*<a[^>]*>\\s*${serverName}\\s*</a>`,
"i"
);
const match = regex.exec(serverHtml);
if (!match) throw new Error(`Server "${serverName}" (${subOrDub}) not found`);
const serverId = match[1];
const sourcesJson = await fetch(`${this.baseUrl}/ajax/v2/episode/sources?id=${serverId}`, {
headers: { "X-Requested-With": "XMLHttpRequest" }
}).then(res => res.json());
let decryptData = null;
let requiredHeaders = {};
try {
// Pass true to get headers back
decryptData = await this.extractMegaCloud(sourcesJson.link, true);
if (decryptData && decryptData.headersProvided) {
requiredHeaders = decryptData.headersProvided;
}
} catch (err) {
console.warn("Primary decrypter failed:", err);
}
if (!decryptData) {
console.warn("Primary decrypter failed — trying ShadeOfChaos fallback...");
const fallbackRes = await fetch(
`https://ac-api.ofchaos.com/api/anime/embed/convert/v2?embedUrl=${encodeURIComponent(sourcesJson.link)}`
);
decryptData = await fallbackRes.json();
// CRITICAL: Fallback headers must mimic the browser behavior expected by the provider
// These MUST be used by a server-side proxy; the browser player cannot set them.
requiredHeaders = {
"Referer": "https://megacloud.club/",
"Origin": "https://megacloud.club",
"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",
"X-Requested-With": "XMLHttpRequest"
};
}
const streamSource =
decryptData.sources.find((s) => s.type === "hls") ||
decryptData.sources.find((s) => s.type === "mp4");
if (!streamSource?.file) throw new Error("No valid stream file found");
const subtitles = (decryptData.tracks || [])
.filter((t) => t.kind === "captions")
.map((track, index) => ({
id: `sub-${index}`,
language: track.label || "Unknown",
url: track.file,
isDefault: !!track.default,
}));
return {
server: serverName,
headers: requiredHeaders,
videoSources: [{
url: streamSource.file,
type: streamSource.type === "hls" ? "m3u8" : "mp4",
quality: "auto",
subtitles
}]
};
}
else if (_server === "HD-4") {
// Implementation for HD-4 if needed
return null;
}
}
safeString(str) {
return (typeof str === "string" ? str : "");
}
normalizeSeasonParts(title) {
const s = this.safeString(title);
return s.toLowerCase()
.replace(/[^a-z0-9]+/g, "")
.replace(/\d+(st|nd|rd|th)/g, (m) => m.replace(/st|nd|rd|th/, ""))
.replace(/season|cour|part/g, "");
}
async extractMegaCloud(embedUrl, returnHeaders = false) {
const url = new URL(embedUrl);
const baseDomain = `${url.protocol}//${url.host}/`;
const headers = {
"Accept": "*/*",
"X-Requested-With": "XMLHttpRequest",
"Referer": baseDomain,
"Origin": `${url.protocol}//${url.host}`,
"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 fetch(embedUrl, { headers }).then((r) => r.text());
const fileIdMatch = html.match(/<title>\s*File\s+#([a-zA-Z0-9]+)\s*-/i);
if (!fileIdMatch) throw new Error("file_id not found in embed page");
const fileId = fileIdMatch[1];
let nonce = null;
const match48 = html.match(/\b[a-zA-Z0-9]{48}\b/);
if (match48) nonce = match48[0];
else {
const match3x16 = [...html.matchAll(/["']([A-Za-z0-9]{16})["']/g)];
if (match3x16.length >= 3) {
nonce = match3x16[0][1] + match3x16[1][1] + match3x16[2][1];
}
}
if (!nonce) throw new Error("nonce not found");
const sourcesJson = await fetch(
`${baseDomain}embed-2/v3/e-1/getSources?id=${fileId}&_k=${nonce}`,
{ headers }
).then((r) => r.json());
return {
sources: sourcesJson.sources,
tracks: sourcesJson.tracks || [],
intro: sourcesJson.intro || null,
outro: sourcesJson.outro || null,
server: sourcesJson.server || null,
headersProvided: returnHeaders ? headers : undefined
};
}
}
module.exports = HiAnime;

View File

@@ -1,113 +1,119 @@
class ZeroChan { class ZeroChan {
baseUrl = "https://zerochan.net"; baseUrl = "https://zerochan.net";
constructor(fetchPath, cheerioPath, browser) { constructor() {
this.browser = browser;
this.type = "image-board"; this.type = "image-board";
} }
async fetchSearchResult(query = "hello", page = 1, perPage = 48) { async search(query = "thighs", page = 1, perPage = 48) {
const url = `${this.baseUrl}/${query.trim().replace(/\s+/g, "+")}?p=${page}`; const url = `${this.baseUrl}/${query.trim().replace(/\s+/g, "+")}?p=${page}`;
const data = await this.browser.scrape( const { result } = await this.scrape(
url, url,
() => { async (page) => {
const list = document.querySelectorAll("#thumbs2 li"); return page.evaluate(() => {
if (list.length === 0) { const list = document.querySelectorAll("#thumbs2 li");
return { results: [], hasNextPage: false }; if (list.length === 0) {
} return {results: [], hasNextPage: false};
const results = []; }
list.forEach(li => { const results = [];
const id = li.getAttribute("data-id");
if (!id) return;
const img = li.querySelector("img"); list.forEach(li => {
const imgUrl = const id = li.getAttribute("data-id");
img?.getAttribute("data-src") || if (!id) return;
img?.getAttribute("src") ||
null;
if (!imgUrl) return; const img = li.querySelector("img");
const imgUrl =
img?.getAttribute("data-src") ||
img?.getAttribute("src") ||
null;
const tagLinks = li.querySelectorAll("p a"); if (!imgUrl) return;
const tags = [...tagLinks]
.map(a => a.textContent.trim())
.filter(Boolean);
results.push({ const tagLinks = li.querySelectorAll("p a");
id, const tags = [...tagLinks]
image: imgUrl, .map(a => a.textContent.trim())
sampleImageUrl: imgUrl, .filter(Boolean);
tags,
type: "preview" results.push({
id,
image: imgUrl,
tags,
});
}); });
const hasNextPage =
document.querySelector('nav.pagination a[rel="next"]') !== null;
return {results, hasNextPage};
}); });
const hasNextPage = document.querySelector('nav.pagination a[rel="next"]') !== null;
return {
results,
hasNextPage
};
}, },
{ waitSelector: "#thumbs2 li", timeout: 15000, renderWaitTime: 3000, loadImages: true } {
waitSelector: "#thumbs2 li",
timeout: 15000,
renderWaitTime: 3000,
loadImages: true
}
); );
console.log(data)
return { return {
results: data.results, results: result.results.map(r => ({
hasNextPage: data.hasNextPage, id: r.id,
image: r.image,
tags: r.tags
})),
hasNextPage: result.hasNextPage,
page page
}; };
} }
async fetchInfo(id) { async getInfo(id) {
const url = `${this.baseUrl}/${id}`; const url = `${this.baseUrl}/${id}`;
const data = await this.browser.scrape( const { result } = await this.scrape(
url, url,
() => { async (page) => {
const preview = document.querySelector("a.preview"); return page.evaluate(() => {
if (!preview) { const preview = document.querySelector("a.preview");
if (!preview) {
return {
fullImage: null,
tags: [],
createdAt: Date.now()
};
}
const fullImage = preview.getAttribute("href") || null;
const img = preview.querySelector("img");
const alt = img?.getAttribute("alt") || "";
let tags = [];
if (alt.startsWith("Tags:")) {
tags = alt
.replace("Tags:", "")
.split(",")
.map(t => t.trim())
.filter(Boolean);
}
return { return {
fullImage: null, fullImage,
tags: [], tags,
createdAt: Date.now() createdAt: Date.now()
}; };
} });
const fullImage = preview.getAttribute("href") || null;
const img = preview.querySelector("img");
const alt = img?.getAttribute("alt") || "";
let tags = [];
if (alt.startsWith("Tags:")) {
tags = alt
.replace("Tags:", "")
.split(",")
.map(t => t.trim())
.filter(Boolean);
}
return {
fullImage,
tags,
createdAt: Date.now()
};
}, },
{ waitSelector: "a.preview img", timeout: 15000 } { waitSelector: "a.preview img", timeout: 15000 }
); );
return { return {
id, id,
fullImage: data.fullImage, image: result.fullImage,
tags: data.tags, tags: result.tags
createdAt: data.createdAt,
rating: "Unknown"
}; };
} }
} }
module.exports = { ZeroChan }; module.exports = ZeroChan;

View File

@@ -1,84 +0,0 @@
class Anime_pictures {
baseUrl = "https://anime-pictures.net";
constructor(fetchPath, cheerioPath, browser) {
this.browser = browser;
this.type = "image-board";
}
async fetchSearchResult(query = "thighs", page = 1, perPage = 48) {
const url = `${this.baseUrl}/posts?page=${page - 1}&search_tag=${query}&order_by=date&lang=en`;
const data = await this.browser.scrape(
url,
() => {
const items = document.querySelectorAll('.img-block.img-block-big');
const results = [];
items.forEach(div => {
const link = div.querySelector('a');
const img = div.querySelector('img');
if (!link || !img) return;
let href = link.getAttribute('href') || "";
let idMatch = href.match(/\/posts\/(\d+)/);
let id = idMatch ? idMatch[1] : null;
let imgUrl = img.getAttribute('src');
let tagsRaw = img.getAttribute('alt') || "";
let tags = tagsRaw.trim().split(/\s+/).filter(Boolean);
if (id && imgUrl) {
results.push({
id,
image: imgUrl,
sampleImageUrl: imgUrl,
tags,
type: "preview"
});
}
});
const nextPageBtn = document.querySelector('.numeric_pages a.desktop_only');
const hasNextPage = !!nextPageBtn;
return { results, hasNextPage };
},
{ waitSelector: '.img-block.img-block-big', timeout: 15000 }
);
return {
results: data.results,
hasNextPage: data.hasNextPage,
page
};
}
async fetchInfo(id) {
const url = `${this.baseUrl}/posts/${id}?lang=en`;
const data = await this.browser.scrape(
url,
() => {
const img = document.querySelector('#big_preview');
const fullImage = img ? img.src : null;
const tagLinks = document.querySelectorAll('.tags li a');
const tags = [...tagLinks].map(a => a.textContent.trim());
return { fullImage, tags };
},
{ waitSelector: '#big_preview', timeout: 15000 }
);
return {
id,
fullImage: data.fullImage,
tags: data.tags,
createdAt: Date.now(),
rating: "Unknown"
};
}
}
module.exports = { Anime_pictures };

94
animepictures.js Normal file
View File

@@ -0,0 +1,94 @@
class Animepictures {
baseUrl = "https://anime-pictures.net";
constructor() {
this.type = "image-board";
}
async search(query = "thighs", page = 1, perPage = 42) {
const url = `${this.baseUrl}/posts?page=${page - 1}&search_tag=${query}&order_by=date&lang=en`;
const { result } = await this.scrape(
url,
async (page) => {
return page.evaluate(() => {
const items = document.querySelectorAll('.img-block.img-block-big');
const results = [];
items.forEach(div => {
const link = div.querySelector('a');
const img = div.querySelector('img');
if (!link || !img) return;
const href = link.getAttribute('href') || "";
const idMatch = href.match(/\/posts\/(\d+)/);
const id = idMatch ? idMatch[1] : null;
const imgUrl = img.getAttribute('src');
const tagsRaw = img.getAttribute('alt') || "";
const tags = tagsRaw.trim().split(/\s+/).filter(Boolean);
if (id && imgUrl) {
results.push({
id: id,
//full res image: imgUrl.replace("opreviews", "oimages").replace("_cp.avif", ".jpeg"),
image: imgUrl,
tags: tags,
});
}
});
const nextPageBtn = document.querySelector('.numeric_pages a.desktop_only');
const hasNextPage = !!nextPageBtn;
return {results, hasNextPage};
});
},
{ waitSelector: '.img-block.img-block-big', timeout: 15000 }
);
return {
results: result.results,
hasNextPage: result.hasNextPage,
page,
//headers: {
// "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",
// "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
// "Accept-Language": "en-US,en;q=0.9",
// "Referer": "https://anime-pictures.net/",
// "Sec-Fetch-Dest": "document",
// "Sec-Fetch-Mode": "navigate",
// "Sec-Fetch-Site": "none",
// "Sec-Fetch-User": "?1"
//}
};
}
async getInfo(id) {
const url = `${this.baseUrl}/posts/${id}?lang=en`;
const { result } = await this.scrape(
url,
async (page) => {
return page.evaluate(() => {
const img = document.querySelector('#big_preview');
const image = img ? img.src : null;
const tagLinks = document.querySelectorAll('.tags li a');
const tags = [...tagLinks].map(a => a.textContent.trim());
return {image, tags};
});
},
{ waitSelector: '#big_preview', timeout: 15000 }
);
return {
id,
image: result.image,
tags: result.tags,
};
}
}
module.exports = Animepictures;

View File

@@ -1,125 +1,104 @@
class asmhentai { class asmhentai {
constructor(fetchPath, cheerioPath, browser) { constructor() {
this.baseUrl = "https://asmhentai.com/"; this.baseUrl = "https://asmhentai.com";
this.fetch = require(fetchPath);
this.cheerio = require(cheerioPath);
this.browser = browser;
this.type = "book-board"; this.type = "book-board";
this.mediaType = "manga";
} }
async fetchSearchResult(query = "", page = 1) { async search(queryObj) {
const q = query.trim().replace(/\s+/g, "+"); const q = (queryObj.query || "").trim().replace(/\s+/g, "+");
const url = q ? `${this.baseUrl}/search/?q=${q}&page=${page}` : `${this.baseUrl}/?q=&page=${page}`; const html = await fetch(`${this.baseUrl}/search/?q=${q}&page=1`).then(r => r.text());
const res = await this.fetch(url);
const html = await res.text();
const $ = this.cheerio.load(html); const $ = this.cheerio.load(html);
const items = $(".ov_item .preview_item");
const results = []; const results = [];
items.each((_, el) => { $(".preview_item").each((_, el) => {
const $el = $(el); const href = $(el).find(".image a").attr("href");
const id = href?.match(/\/g\/(\d+)\//)?.[1];
if (!id) return;
const href = $el.find(".image a").attr("href") || ""; let img = $(el).find(".image img").attr("data-src") || $(el).find(".image img").attr("src") || "";
const id = href.match(/\d+/)?.[0] || null; if (img.startsWith("//")) img = "https:" + img;
const img = $el.find(".image img"); const image = img.replace("thumb.jpg", "1.jpg");
const raw = img.attr("data-src") || img.attr("src") || ""; const title = $(el).find("h2.caption").text().trim();
let image = raw.startsWith("//") ? "https:" + raw : raw;
const sampleImageUrl = image.replace("thumb", "cover");
image = image.replace(/[^\/]+$/, "1.jpg");
const title = ($el.find(".cpt h2.caption").text() || "").trim();
const tagsRaw = $el.attr("data-tags") || "";
const tags = tagsRaw.split(" ").filter(Boolean);
results.push({ results.push({
id, id,
image, image,
sampleImageUrl,
title, title,
tags, rating: null,
type: "book" type: "book"
}); });
}); });
const hasNextPage = $('ul.pagination a.page-link').filter((_, el) => $(el).text().trim().toLowerCase() === "next").length > 0; return results;
}
async getMetadata(id) {
const html = await fetch(`${this.baseUrl}/g/${id}/`).then(r => r.text());
const $ = this.cheerio.load(html);
let image =
$('a[href^="/gallery/"] img').attr("data-src") ||
$('a[href^="/gallery/"] img').attr("src") ||
"";
if (image.startsWith("//")) image = "https:" + image;
const genres = $(".tags .tag_list .badge.tag")
.map((_, el) => $(el).clone().children().remove().end().text().trim())
.get()
.join(", ");
return { return {
results, id,
hasNextPage, title: $("h1").first().text().trim(),
page format: "MANGA",
score: 0,
genres,
status: "unknown",
published: "???",
summary: "",
chapters: 1,
image
}; };
} }
async findChapters(mangaId) { async findChapters(mangaId) {
const res = await this.fetch(`${this.baseUrl}/g/${mangaId}/`); const html = await fetch(`${this.baseUrl}/g/${mangaId}/`).then(r => r.text());
const html = await res.text();
const $ = this.cheerio.load(html); const $ = this.cheerio.load(html);
const title = $(".right .info h1").first().text().trim() || ""; const title = $("h1").first().text().trim() || "Chapter 1";
let cover = $(".cover img").attr("data-src") || $(".cover img").attr("src") || ""; let thumb = $(".gallery img").first().attr("data-src") || "";
if (cover.startsWith("//")) cover = "https:" + cover; if (thumb.startsWith("//")) thumb = "https:" + thumb;
const firstThumb = $('.gallery a img').first(); const base = thumb.match(/https:\/\/[^\/]+\/\d+\/\d+\//)?.[0];
let t = firstThumb.attr("data-src") || ""; const pages = parseInt($(".pages").text().match(/\d+/)?.[0] || "0");
const ext = thumb.match(/\.(jpg|png|jpeg|gif)/i)?.[1] || "jpg";
if (t.startsWith("//")) t = "https:" + t; const chapterId = Buffer.from(JSON.stringify({ base, pages, ext })).toString("base64");
// ex: https://images.asmhentai.com/017/598614/8t.jpg return [{
const baseMatch = t.match(/https:\/\/[^\/]+\/\d+\/\d+\//); id: chapterId,
const basePath = baseMatch ? baseMatch[0] : null; title,
number: 1,
const pagesText = $(".pages h3").text(); // "Pages: 39" releaseDate: null,
const pagesMatch = pagesText.match(/(\d+)/); index: 0
const pages = pagesMatch ? parseInt(pagesMatch[1]) : 0; }];
let ext = "jpg";
const extMatch = t.match(/\.(jpg|png|jpeg|gif)/i);
if (extMatch) ext = extMatch[1];
let language = "unknown";
const langFlag = $('.info a[href^="/language/"] img').attr("src") || "";
if (langFlag.includes("en")) language = "english";
if (langFlag.includes("jp")) language = "japanese";
const encodedChapterId = Buffer.from(
JSON.stringify({
base: basePath,
pages,
ext
})
).toString("base64");
return {
chapters: [
{
id: encodedChapterId,
title,
chapter: 1,
index: 0,
language
}
],
cover
};
} }
async findChapterPages(chapterId) { async findChapterPages(chapterId) {
const decoded = JSON.parse( const { base, pages, ext } = JSON.parse(
Buffer.from(chapterId, "base64").toString("utf8") Buffer.from(chapterId, "base64").toString("utf8")
); );
const { base, pages, ext } = decoded;
return Array.from({ length: pages }, (_, i) => ({ return Array.from({ length: pages }, (_, i) => ({
url: `${base}${i + 1}.${ext}`, url: `${base}${i + 1}.${ext}`,
index: i, index: i
})); }));
} }
} }
module.exports = { asmhentai }; module.exports = asmhentai;

View File

@@ -1,139 +1,83 @@
class Gelbooru { class Gelbooru {
baseUrl = "https://gelbooru.com"; baseUrl = "https://gelbooru.com";
constructor(fetchPath, cheerioPath) { constructor() {
this.fetch = require(fetchPath);
this.load = require(cheerioPath).load;
this.type = "image-board"; this.type = "image-board";
} }
async fetchSearchResult(query, page = 1, perPage = 42) { async search(query = "thighs", page = 1, perPage = 42) {
if (!query) query = "original"; const url = `${this.baseUrl}/index.php?page=post&s=list&tags=${encodeURIComponent(query)}&pid=${(page - 1) * perPage}`;
const url = `${this.baseUrl}/index.php?page=post&s=list&tags=${query}&pid=${(page - 1) * perPage}`; const html = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0" }
const response = await this.fetch(url, { }).then(r => r.text());
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
});
const data = await response.text();
const $ = this.load(data);
const $ = this.cheerio.load(html);
const results = []; const results = [];
$('.thumbnail-container a').each((i, e) => { $("article.thumbnail-preview > a[id^='p']").each((_, el) => {
const $e = $(e); const id = $(el).attr("id")?.slice(1); // p13123834 → 13123834
const href = $e.attr('href'); if (!id) return;
const idMatch = href.match(/id=(\d+)/); const img = $(el).find("img");
const id = idMatch ? idMatch[1] : null; const image = img.attr("src");
const image = $e.find('img').attr('src'); const tags = img.attr("alt")
?.replace(/^Rule 34 \|\s*/, "")
?.split(",")
?.map(t => t.trim())
?.filter(Boolean) || [];
const tags = $e.find('img').attr('alt')?.trim()?.split(' ').filter(tag => tag !== ""); results.push({ id, image, tags });
if (id && image) {
results.push({
id: id,
image: image,
tags: tags,
type: 'preview'
});
}
}); });
const pagination = $('.pagination a'); // pagination
const totalPages = Math.max(
let totalPages = 1; page,
pagination.each((i, e) => { ...$("a[href*='pid=']")
const href = $(e).attr('href'); .map((_, el) =>
if (href && href.includes('pid=')) { Math.floor(
const pidMatch = href.match(/pid=(\d+)/); parseInt($(el).attr("href")?.match(/pid=(\d+)/)?.[1] || 0) / perPage
if (pidMatch) { ) + 1
const pid = parseInt(pidMatch[1], 10); )
totalPages = Math.max(totalPages, Math.floor(pid / perPage) + 1); .get()
} );
}
});
const currentPage = page;
const nextPage = currentPage < totalPages ? currentPage + 1 : null;
const previousPage = currentPage > 1 ? currentPage - 1 : null;
const hasNextPage = nextPage !== null;
return { return {
total: totalPages * perPage, results,
next: nextPage !== null ? nextPage : 0, page,
previous: previousPage !== null ? previousPage : 0, hasNextPage: page < totalPages
pages: totalPages,
page: currentPage,
hasNextPage,
results
}; };
} }
async fetchInfo(id) { async getInfo(id) {
const url = `${this.baseUrl}/index.php?page=post&s=view&id=${id}`; const html = await fetch(
`${this.baseUrl}/index.php?page=post&s=view&id=${id}`,
{ headers: { "User-Agent": "Mozilla/5.0" } }
).then(r => r.text());
const response = await this.fetch(url, { const $ = this.cheerio.load(html);
headers: { const container = $("section.image-container");
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
});
const original = await response.text();
const $ = this.load(original); let image =
container.find("#image").attr("src") ||
container.attr("data-file-url") ||
container.attr("data-large-file-url") ||
null;
let fullImage; // tags
const tags = container
fullImage = $('#gelcom_img').attr('src') || $('#gelcom_mp4').attr('src'); .attr("data-tags")
?.trim()
if (!fullImage) { ?.split(/\s+/)
fullImage = $('#right-col a[href*="/images/"]').attr('href') || $('#right-col a[href*="/videos/"]').attr('href'); ?.filter(Boolean) || [];
}
if (fullImage && fullImage.startsWith('/')) {
fullImage = new URL(fullImage, this.baseUrl).href;
}
const tagsList = $('#tag-list a');
const tags = tagsList.map((i, el) => $(el).text().trim()).get();
const stats = $('#post-view-image-container + br + br + br + br + ul, #stats');
const postedData = stats.find("li:contains('Posted:')").text().trim();
const createdAt = new Date(postedData.split("Posted: ")[1]).getTime();
const publishedBy = stats.find("li:contains('User:') a").text().trim() || null;
const rating = stats.find("li:contains('Rating:')").text().trim().split("Rating: ")[1];
const comments = $('#comment-list .comment').map((i, el) => {
const $e = $(el);
const id = $e.attr('id')?.replace('c', '');
const user = $e.find('.comment-user a').text().trim();
const comment = $e.find('.comment-body').text().trim();
return {
id,
user,
comment,
}
}).get().filter(Boolean).filter((comment) => comment.comment !== '');
return { return {
id, id,
fullImage, image,
resizedImageUrl: fullImage, tags
tags, };
createdAt,
publishedBy,
rating,
comments
}
} }
} }
module.exports = { Gelbooru }; module.exports = Gelbooru;

View File

@@ -1,24 +1,22 @@
class Giphy { class Giphy {
baseUrl = "https://giphy.com"; baseUrl = "https://giphy.com";
constructor(fetchPath, cheerioPath, browser) { constructor() {
this.browser = browser;
this.type = "image-board"; this.type = "image-board";
} }
async fetchSearchResult(query = "hello", page = 1, perPage = 48) { async search(query = "hello", page = 1, perPage = 48) {
const url = `${this.baseUrl}/search/${query.trim().replace(/\s+/g, "-")}`; const url = `${this.baseUrl}/search/${query.trim().replace(/\s+/g, "-")}`;
const data = await this.browser.scrape( const data = await this.scrape(
url, url,
() => { (page) => page.evaluate(() => {
const items = document.querySelectorAll('a[data-giphy-id]'); const items = document.querySelectorAll('a[data-giphy-id]');
const results = []; const results = [];
items.forEach(el => { items.forEach(el => {
const id = el.getAttribute('data-giphy-id'); const id = el.getAttribute('data-giphy-id');
// solo coger sources válidos
const srcWebp = el.querySelector('source[type="image/webp"][srcset^="http"]'); const srcWebp = el.querySelector('source[type="image/webp"][srcset^="http"]');
const srcImg = el.querySelector('img'); const srcImg = el.querySelector('img');
@@ -27,20 +25,14 @@ class Giphy {
srcImg?.src || srcImg?.src ||
null; null;
// ignorar 1x1 base64
if (!rawSrc || rawSrc.startsWith("data:")) return; if (!rawSrc || rawSrc.startsWith("data:")) return;
const imgUrl = rawSrc;
const alt = srcImg?.getAttribute("alt") || ""; const alt = srcImg?.getAttribute("alt") || "";
const tags = alt.trim().split(/\s+/).filter(Boolean); const tags = alt.trim().split(/\s+/).filter(Boolean);
results.push({ results.push({
id, id,
image: imgUrl, image: rawSrc,
sampleImageUrl: imgUrl,
tags,
type: "preview"
}); });
}); });
@@ -48,24 +40,61 @@ class Giphy {
results, results,
hasNextPage: false hasNextPage: false
}; };
}, }),
{ waitSelector: 'picture img, a[data-giphy-id] img', scrollToBottom: true, timeout: 15000} {
waitSelector: 'picture img, a[data-giphy-id] img',
scrollToBottom: true,
timeout: 15000
}
); );
return { return {
results: data.results, results: data.result.results.map(r => ({
hasNextPage: data.hasNextPage, id: r.id,
image: r.image
})),
hasNextPage: data.result.hasNextPage,
page page
}; };
} }
async fetchInfo(id) { async getInfo(id) {
const url = `https://giphy.com/gifs/${id}`;
const data = await this.scrape(
url,
(page) => page.evaluate(() => {
const scripts = document.querySelectorAll(
'script[type="application/ld+json"]'
);
let imgsrc = null;
scripts.forEach(script => {
try {
const json = JSON.parse(script.textContent);
if (json?.["@type"] === "Article" && json?.image?.url) {
imgsrc = json.image.url;
}
} catch {}
});
return {
image: imgsrc
};
}),
{
waitSelector: 'script[type="application/ld+json"]',
timeout: 15000
}
);
return { return {
id, id,
createdAt: Date.now(), image: data.result.image
rating: "Unknown"
}; };
} }
} }
module.exports = { Giphy }; module.exports = Giphy;

View File

@@ -1,156 +1,188 @@
class ligntnovelworld { class lightnovelworld {
constructor(fetchPath, cheerioPath, browser) { constructor() {
this.browser = browser;
this.fetch = require(fetchPath);
this.cheerio = require(cheerioPath);
this.baseUrl = "https://lightnovelworld.org/api"; this.baseUrl = "https://lightnovelworld.org/api";
this.type = "book-board"; this.type = "book-board";
this.mediaType = "ln";
} }
async fetchSearchResult(query = "", page = 1) { async search(queryObj) {
if (query.trim() !== "") { const query = queryObj.query?.trim() || "";
const res = await this.fetch(`${this.baseUrl}/search/?q=${encodeURIComponent(query)}&search_type=title`);
if (query !== "") {
const res = await fetch(
`${this.baseUrl}/search/?q=${encodeURIComponent(query)}&search_type=title`
);
const data = await res.json(); const data = await res.json();
const results = data.novels.map(n => ({ if (!data.novels) return [];
return data.novels.map(n => ({
id: n.slug, id: n.slug,
title: n.title, title: n.title,
image: `https://lightnovelworld.org/${n.cover_path}`, image: `https://lightnovelworld.org/${n.cover_path}`,
sampleImageUrl: `https://lightnovelworld.org/${n.cover_path}`, rating: `Rank ${n.rank}`,
tags: [], format: "Light Novel"
type: "book"
})); }));
return {
results,
hasNextPage: false,
page
};
} }
const res = await this.fetch("https://lightnovelworld.org/"); const res = await fetch("https://lightnovelworld.org/");
const html = await res.text(); const html = await res.text();
const $ = this.cheerio.load(html);
const cards = $(".recommendations-grid .recommendation-card"); const cards = html.split('class="recommendation-card"').slice(1);
const results = []; const results = [];
cards.each((_, el) => { for (const block of cards) {
const card = $(el); const link = block.match(/href="([^"]+)"/)?.[1] || "";
const link = card.find("a.card-cover-link").attr("href") || "";
const id = link.replace(/^\/novel\//, "").replace(/\/$/, ""); const id = link.replace(/^\/novel\//, "").replace(/\/$/, "");
const title = card.find(".card-title").text().trim();
const img = card.find(".card-cover img").attr("src") || ""; const title = block.match(/class="card-title"[^>]*>([^<]+)/)?.[1]?.trim() || null;
const imageUrl = img.startsWith("http") ? img : `https://lightnovelworld.org${img}`;
const tags = card.find(".card-genres .genre-tag").map((_, t) => $(t).text().trim()).get(); let img = block.match(/<img[^>]+src="([^"]+)"/)?.[1] || "";
if (img && !img.startsWith("http"))
img = `https://lightnovelworld.org${img}`;
results.push({ if (id && title) {
id, results.push({
title, id,
image: imageUrl, title,
sampleImageUrl: imageUrl, image: img,
tags, rating: null,
type: "book" format: "Light Novel"
}); });
}); }
}
return results;
}
async getMetadata(id){
const res = await fetch(`https://lightnovelworld.org/novel/${id}`);
const html = await res.text();
const match = html.match(
/<script type="application\/ld\+json">([\s\S]*?)<\/script>/
);
let data = {};
if(match){
try{
data = JSON.parse(match[1]);
}catch(e){}
}
const rawScore = Number(data.aggregateRating?.ratingValue || 1);
const score100 = Math.round((rawScore / 5) * 100);
return { return {
results, id: id,
hasNextPage: false, title: data.name || "",
page format: "Light Novel",
score: score100,
genres: Array.isArray(data.genre) ? data.genre : [],
status: data.status || "",
published: "???",
summary: data.description || "",
chapters: data.numberOfPages ? Number(data.numberOfPages) : 1,
image: data.image
? (data.image.startsWith("http")
? data.image
: `https://lightnovelworld.org${data.image}`)
: ""
}; };
} }
async findChapters(bookId) { async findChapters(bookId) {
const chapters = [];
let offset = 0; let offset = 0;
const limit = 500; const limit = 500;
const chapters = [];
while (true) { while (true) {
const res = await this.fetch(`https://lightnovelworld.org/api/novel/${bookId}/chapters/?offset=${offset}&limit=${limit}`); const res = await fetch(
`https://lightnovelworld.org/api/novel/${bookId}/chapters/?offset=${offset}&limit=${limit}`
);
const data = await res.json(); const data = await res.json();
if (!data.chapters) break;
chapters.push( chapters.push(
...data.chapters.map(c => ({ ...data.chapters.map((c, i) => ({
id: `https://lightnovelworld.org/novel/${bookId}/chapter/${c.number}/`, id: `https://lightnovelworld.org/novel/${bookId}/chapter/${c.number}/`,
title: c.title, title: c.title,
chapter: c.number, number: Number(c.number),
language: 'en' releaseDate: null,
index: offset + i
})) }))
); );
if (!data.has_more) break; if (!data.has_more) break;
offset += limit; offset += limit;
} }
return { chapters: chapters}; return chapters;
} }
async findChapterPages(chapterId) { async findChapterPages(chapterId) {
const res = await this.fetch(chapterId, { const data = await this.scrape(
headers: { chapterId,
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', (page) => page.evaluate(() => document.documentElement.outerHTML),
'referer': chapterId.replace(/\/\d+\/$/, ''), {
'sec-ch-ua': '"Chromium";v="139", "Not;A=Brand";v="99"', waitUntil: "domcontentloaded",
'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' timeout: 15000
} }
}); );
const html = await res.text(); const html = data.result;
const $ = this.cheerio.load(html); if (!html) return '<p>Error loading chapter</p>';
const contentDiv = $('#chapterText'); const cutPoints = [
'<div class="bottom-nav"',
'<div class="comments-section"',
'<div class="settings-panel"',
'&copy;'
];
if (!contentDiv || contentDiv.length === 0) { let cutIndex = html.length;
return [{ for (const marker of cutPoints) {
type: 'text', const pos = html.indexOf(marker);
content: '<p>Error: content not found</p>', if (pos !== -1 && pos < cutIndex) cutIndex = pos;
index: 0
}];
} }
contentDiv.find('script').remove(); const chapterHtml = html.substring(0, cutIndex);
contentDiv.find('style').remove();
contentDiv.find('ins').remove();
contentDiv.find("[id^='pf-']").remove();
contentDiv.find('.chapter-ad-container').remove();
contentDiv.find('.ad-unit').remove();
contentDiv.find('.ads').remove();
contentDiv.find('.adsbygoogle').remove();
contentDiv.find('.nf-ads').remove();
contentDiv.find('div[align="center"]').remove();
contentDiv.find("div[style*='text-align:center']").remove();
const paragraphs = contentDiv.find('p'); const pMatches = [...chapterHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)];
let cleanHtml = ''; let cleanHtml = '';
paragraphs.each((_, el) => { for (const match of pMatches) {
const p = $(el); let text = match[1]
let text = p.text() || ''; .replace(/△▼△▼△▼△/g, '')
.replace(/[※▲▼■◆]/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
text = text.replace(/△▼△▼△▼△/g, '').replace(/[※]+/g, '').replace(/\s{2,}/g, ' ').trim(); text = text
const htmlP = p.html()?.trim() || ''; .replace(/&quot;/g, '"')
const isEmpty = htmlP === '' || htmlP === '&nbsp;' || text.trim() === ''; .replace(/&#x27;/g, "'")
const isAd = text.includes('Remove Ads') || text.includes('Buy no ads') || text.includes('novelfire'); .replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&ldquo;/g, '“')
.replace(/&rdquo;/g, '”')
.replace(/&lsquo;/g, '')
.replace(/&rsquo;/g, '')
.replace(/&mdash;/g, '—')
.replace(/&ndash;/g, '');
if (!isEmpty && !isAd) { if (!text || text.length < 3) continue;
if (p.text() !== text) p.text(text); if (/svg|button|modal|comment|loading|default|dyslexic|roboto|lora|line spacing/i.test(text)) continue;
cleanHtml += $.html(p);
}
});
if (!cleanHtml.trim()) { cleanHtml = contentDiv.html() || ''; } cleanHtml += `<p>${text}</p>\n`;
}
return [ return cleanHtml.trim() || '<p>Empty chapter</p>';
{
type: 'text',
content: cleanHtml.trim(),
index: 0
}
];
} }
} }
module.exports = { novelupdates: ligntnovelworld }; module.exports = lightnovelworld;

View File

@@ -1,10 +1,9 @@
class MangaDex { class MangaDex {
constructor(fetchPath, cheerioPath, browser) { constructor() {
this.fetchPath = fetchPath;
this.browser = browser;
this.baseUrl = "https://mangadex.org"; this.baseUrl = "https://mangadex.org";
this.apiUrl = "https://api.mangadex.org"; this.apiUrl = "https://api.mangadex.org";
this.type = "book-board"; this.type = "book-board";
this.mediaType = "manga";
} }
getHeaders() { getHeaders() {
@@ -14,38 +13,26 @@ class MangaDex {
}; };
} }
async _fetch(url, options = {}) { async search(queryObj) {
if (typeof fetch === 'function') { const query = queryObj.query?.trim() || "";
return fetch(url, options);
}
const nodeFetch = require(this.fetchPath);
return nodeFetch(url, options);
}
async fetchSearchResult(query = "", page = 1) {
const limit = 25; const limit = 25;
const offset = (page - 1) * limit; const offset = (1 - 1) * limit;
let url; const url = `${this.apiUrl}/manga?title=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&includes[]=cover_art&contentRating[]=safe&contentRating[]=suggestive&availableTranslatedLanguage[]=en`;
if (!query || query.trim() === "") {
url = `${this.apiUrl}/manga?limit=${limit}&offset=${offset}&includes[]=cover_art&contentRating[]=safe&contentRating[]=suggestive&availableTranslatedLanguage[]=en&order[followedCount]=desc`;
} else {
url = `${this.apiUrl}/manga?title=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&includes[]=cover_art&contentRating[]=safe&contentRating[]=suggestive&availableTranslatedLanguage[]=en`;
}
try { try {
const response = await this._fetch(url, { headers: this.getHeaders() }); const response = await fetch(url, { headers: this.getHeaders() });
if (!response.ok) { if (!response.ok) {
console.error(`MangaDex API Error: ${response.statusText}`); console.error(`MangaDex API Error: ${response.statusText}`);
return { results: [], hasNextPage: false, page }; return [];
} }
const json = await response.json(); const json = await response.json();
if (!json || !Array.isArray(json.data)) { if (!json || !Array.isArray(json.data)) {
return { results: [], hasNextPage: false, page }; return [];
} }
const results = json.data.map(manga => { return json.data.map(manga => {
const attributes = manga.attributes; const attributes = manga.attributes;
const titleObject = attributes.title || {}; const titleObject = attributes.title || {};
const title = titleObject.en || Object.values(titleObject)[0] || 'Unknown Title'; const title = titleObject.en || Object.values(titleObject)[0] || 'Unknown Title';
@@ -57,36 +44,91 @@ class MangaDex {
? `https://uploads.mangadex.org/covers/${manga.id}/${coverFileName}.256.jpg` ? `https://uploads.mangadex.org/covers/${manga.id}/${coverFileName}.256.jpg`
: ''; : '';
const fullCoverUrl = coverFileName
? `https://uploads.mangadex.org/covers/${manga.id}/${coverFileName}`
: '';
const tags = attributes.tags
? attributes.tags.map(t => t.attributes.name.en)
: [];
return { return {
id: manga.id, id: manga.id,
image: coverUrl, image: coverUrl,
sampleImageUrl: fullCoverUrl,
title: title, title: title,
tags: tags, rating: null,
type: 'book' type: 'book'
}; };
}); });
const total = json.total || 0; } catch (e) {
const hasNextPage = (offset + limit) < total; console.error("Error during MangaDex search:", e);
return [];
}
}
async getMetadata(id) {
try {
const res = await fetch(`https://api.mangadex.org/manga/${id}?includes[]=cover_art`);
if (!res.ok) throw new Error("MangaDex API error");
const json = await res.json();
const manga = json.data;
const attr = manga.attributes;
const title =
attr.title?.en ||
Object.values(attr.title || {})[0] ||
"";
const summary =
attr.description?.en ||
Object.values(attr.description || {})[0] ||
"";
const genres = manga.relationships
?.filter(r => r.type === "tag")
?.map(r =>
r.attributes?.name?.en ||
Object.values(r.attributes?.name || {})[0]
)
?.filter(Boolean) || [];
const coverRel = manga.relationships.find(r => r.type === "cover_art");
const coverFile = coverRel?.attributes?.fileName;
const image = coverFile
? `https://uploads.mangadex.org/covers/${id}/${coverFile}.512.jpg`
: "";
const score100 = 0;
const statusMap = {
ongoing: "Ongoing",
completed: "Completed",
hiatus: "Hiatus",
cancelled: "Cancelled"
};
return { return {
results, id,
hasNextPage, title,
page format: "Manga",
score: score100,
genres,
status: statusMap[attr.status] || "",
published: attr.year ? String(attr.year) : "???",
summary,
chapters: attr.lastChapter ? Number(attr.lastChapter) || 0 : 0,
image
}; };
} catch (e) { } catch (e) {
console.error("Error during MangaDex search:", e); console.error("MangaDex getMetadata error:", e);
return { results: [], hasNextPage: false, error: e.message }; return {
id,
title: "",
format: "Manga",
score: 0,
genres: [],
status: "",
published: "???",
summary: "",
chapters: 0,
image: ""
};
} }
} }
@@ -96,7 +138,7 @@ class MangaDex {
const url = `${this.apiUrl}/manga/${mangaId}/feed?translatedLanguage[]=en&order[chapter]=asc&limit=500&includes[]=scanlation_group`; const url = `${this.apiUrl}/manga/${mangaId}/feed?translatedLanguage[]=en&order[chapter]=asc&limit=500&includes[]=scanlation_group`;
try { try {
const response = await this._fetch(url, { headers: this.getHeaders() }); const response = await fetch(url, { headers: this.getHeaders() });
let chapters = []; let chapters = [];
if (response.ok) { if (response.ok) {
@@ -107,7 +149,7 @@ class MangaDex {
.map((ch, index) => ({ .map((ch, index) => ({
id: ch.id, id: ch.id,
title: ch.attributes.title || `Chapter ${ch.attributes.chapter}`, title: ch.attributes.title || `Chapter ${ch.attributes.chapter}`,
chapter: ch.attributes.chapter, number: ch.attributes.chapter,
index: index, index: index,
language: ch.attributes.translatedLanguage language: ch.attributes.translatedLanguage
})); }));
@@ -124,23 +166,7 @@ class MangaDex {
} }
} }
let highResCover = null; return chapters;
try {
const mangaRes = await this._fetch(`${this.apiUrl}/manga/${mangaId}?includes[]=cover_art`);
if (mangaRes.ok) {
const mangaJson = await mangaRes.json();
const coverRel = mangaJson.data.relationships.find(r => r.type === 'cover_art');
if(coverRel && coverRel.attributes && coverRel.attributes.fileName) {
highResCover = `https://uploads.mangadex.org/covers/${mangaId}/${coverRel.attributes.fileName}`;
}
}
} catch(e) { }
return {
chapters: chapters,
cover: highResCover
};
} catch (e) { } catch (e) {
console.error("Error finding MangaDex chapters:", e); console.error("Error finding MangaDex chapters:", e);
return { chapters: [], cover: null }; return { chapters: [], cover: null };
@@ -153,7 +179,7 @@ class MangaDex {
const url = `${this.apiUrl}/at-home/server/${chapterId}`; const url = `${this.apiUrl}/at-home/server/${chapterId}`;
try { try {
const response = await this._fetch(url, { headers: this.getHeaders() }); const response = await fetch(url, { headers: this.getHeaders() });
if (!response.ok) throw new Error(`Failed to fetch pages: ${response.statusText}`); if (!response.ok) throw new Error(`Failed to fetch pages: ${response.statusText}`);
const json = await response.json(); const json = await response.json();
@@ -177,4 +203,4 @@ class MangaDex {
} }
} }
module.exports = { MangaDex }; module.exports = MangaDex;

View File

@@ -1,25 +1,20 @@
class mangapark { class mangapark {
constructor(fetchPath, cheerioPath, browser) { constructor() {
this.baseUrl = "https://mangapark.net/apo"; this.baseUrl = "https://mangapark.net/apo";
this.fetch = require(fetchPath)
this.browser = browser;
this.type = "book-board"; this.type = "book-board";
this.mediaType = "manga";
} }
async fetchSearchResult(query = "", page = 1) { async search(queryObj) {
const res = await this.fetch(`${this.baseUrl}/`, { const query = queryObj.query;
const res = await fetch(`${this.baseUrl}/`, {
method: "POST", method: "POST",
headers: { headers: {
"accept": "*/*", "accept": "*/*",
"content-type": "application/json", "content-type": "application/json",
"x-apollo-operation-name": "get_searchComic", "x-apollo-operation-name": "get_searchComic",
"sec-ch-ua": "\"Chromium\";v=\"139\", \"Not;A=Brand\";v=\"99\"", "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"
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"Referer": `https://mangapark.net/search?word=${encodeURIComponent(query)}`
}, },
body: JSON.stringify({ body: JSON.stringify({
query: "query get_searchComic($select: SearchComic_Select) { get_searchComic(select: $select) { reqPage reqSize reqSort reqWord newPage paging { total pages page init size skip limit prev next } items { id data { id dbStatus name origLang tranLang urlPath urlCover600 urlCoverOri genres altNames authors artists is_hot is_new sfw_result score_val follows reviews comments_total max_chapterNode { id data { id dateCreate dbStatus isFinal sfw_result dname urlPath is_new userId userNode { id data { id name uniq avatarUrl urlPath } } } } } sser_follow sser_lastReadChap { date chapterNode { id data { id dbStatus isFinal sfw_result dname urlPath is_new userId userNode { id data { id name uniq avatarUrl urlPath } } } } } } } }", query: "query get_searchComic($select: SearchComic_Select) { get_searchComic(select: $select) { reqPage reqSize reqSort reqWord newPage paging { total pages page init size skip limit prev next } items { id data { id dbStatus name origLang tranLang urlPath urlCover600 urlCoverOri genres altNames authors artists is_hot is_new sfw_result score_val follows reviews comments_total max_chapterNode { id data { id dateCreate dbStatus isFinal sfw_result dname urlPath is_new userId userNode { id data { id name uniq avatarUrl urlPath } } } } } sser_follow sser_lastReadChap { date chapterNode { id data { id dbStatus isFinal sfw_result dname urlPath is_new userId userNode { id data { id name uniq avatarUrl urlPath } } } } } } } }",
@@ -30,38 +25,96 @@ class mangapark {
}); });
const data = await res.json(); const data = await res.json();
const results = data.data.get_searchComic.items.map(m => ({
id: m.data.urlPath, if (!data.data || !data.data.get_searchComic || !data.data.get_searchComic.items) {
return [];
}
return data.data.get_searchComic.items.map(m => ({
id: m.data.urlPath.split('/title/')[1]?.split('-')[0] || mangaId.split('/comic/')[1]?.split('-')[0], // This identifies the book
title: m.data.name, title: m.data.name,
image: `https://mangapark.net/${m.data.urlCoverOri}`, image: `https://mangapark.net/${m.data.urlCoverOri}`,
sampleImageUrl: `https://mangapark.net/${m.data.urlCoverOri}`, rating: m.data.score_val ? Math.round(m.data.score_val * 10) : null,
tags: m.data.genres, type: "book",
type: "book" headers: {
referer: "https://mangapark.net"
}
})); }));
}
async getMetadata(id) {
const res = await fetch(`https://mangapark.net/title/${id}`);
const html = await res.text();
const match = html.match(
/<script type="qwik\/json">([\s\S]*?)<\/script>/
);
if (!match) throw new Error("qwik json not found");
function decodeQwik(obj) {
const refs = obj.refs || {};
function walk(v) {
if (typeof v === "string" && refs[v] !== undefined) {
return walk(refs[v]);
}
if (Array.isArray(v)) {
return v.map(walk);
}
if (v && typeof v === "object") {
const out = {};
for (const k in v) out[k] = walk(v[k]);
return out;
}
return v;
}
return walk(obj);
}
const raw = JSON.parse(match[1]);
const data = decodeQwik(raw);
const comic =
data?.objs?.find(o => o && o.name && o.summary) ||
data?.state?.comic;
if (!comic) throw new Error("comic not found");
const score100 = comic.score_avg
? Math.round((Number(comic.score_avg) / 10) * 100)
: 0;
return { return {
results: results, id,
hasNextPage: false, title: comic.name || "",
page format: "Manga",
score: score100,
genres: comic.genres || [],
status: comic.originalStatus || comic.status || "",
published: comic.originalPubFrom
? String(comic.originalPubFrom)
: "???",
summary: comic.summary || "",
chapters: comic.chaps_normal || comic.chapters_count || 0,
image: comic.urlCoverOri
? `https://mangapark.net${comic.urlCoverOri}`
: ""
}; };
} }
async findChapters(mangaId) { async findChapters(mangaId) {
const comicId = mangaId.split('/title/')[1]?.split('-')[0]; const comicId = mangaId
if (!comicId) throw new Error('comicId inválido en mangaId'); if (!comicId) {
const res = await this.fetch(this.baseUrl + "/", { console.error("[MangaPark] Invalid ID format:", mangaId);
return [];
}
const res = await fetch(this.baseUrl + "/", {
method: "POST", method: "POST",
headers: { headers: {
"accept": "*/*", "accept": "*/*",
"content-type": "application/json", "content-type": "application/json",
"x-apollo-operation-name": "get_comicChapterList", "x-apollo-operation-name": "get_comicChapterList",
"sec-ch-ua": "\"Chromium\";v=\"139\", \"Not;A=Brand\";v=\"99\"", "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"
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"Referer": `https://mangapark.net${mangaId}`
}, },
body: JSON.stringify({ body: JSON.stringify({
query: "query get_comicChapterList($comicId: ID!) { get_comicChapterList(comicId: $comicId){ id data { id comicId isFinal volume serial dname title urlPath sfw_result } } }\n", query: "query get_comicChapterList($comicId: ID!) { get_comicChapterList(comicId: $comicId){ id data { id comicId isFinal volume serial dname title urlPath sfw_result } } }\n",
@@ -71,25 +124,27 @@ class mangapark {
const json = await res.json(); const json = await res.json();
if (!json.data || !json.data.get_comicChapterList) return [];
let list = json.data.get_comicChapterList; let list = json.data.get_comicChapterList;
list.sort((a, b) => a.data.serial - b.data.serial); list.sort((a, b) => a.data.serial - b.data.serial);
let chapters = list.map((c, i) => ({ return list.map((c, i) => ({
id: `https://mangapark.net${c.data.urlPath}`, id: `https://mangapark.net${c.data.urlPath}`,
title: c.data.dname || c.data.title || `Chapter ${c.data.serial}`, title: c.data.dname || c.data.title || `Chapter ${c.data.serial}`,
chapter: Number(c.data.serial), number: Number(c.data.serial),
index: i, releaseDate: null,
language: "en" index: i
})); }));
return {
chapters: chapters,
};
} }
async findChapterPages(chapterUrl) { async findChapterPages(chapterUrl) {
const res = await this.fetch(chapterUrl); const res = await fetch(chapterUrl, {
headers: {
"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"
}
});
const html = await res.text(); const html = await res.text();
const scripts = html.match(/<script\b[^>]*>[\s\S]*?<\/script>/gi) || []; const scripts = html.match(/<script\b[^>]*>[\s\S]*?<\/script>/gi) || [];
@@ -110,10 +165,10 @@ class mangapark {
return clean.map((url, index) => ({ return clean.map((url, index) => ({
url, url,
index index,
headers: { referer: 'https://mangapark.net' }
})); }));
} }
} }
module.exports = { mangapark }; module.exports = mangapark;

View File

@@ -1,130 +1,181 @@
class nhentai { class nhentai {
constructor(fetchPath, cheerioPath, browser) { constructor() {
this.baseUrl = "https://nhentai.net"; this.baseUrl = "https://nhentai.net";
this.browser = browser; this.type = "book-board";
this.type = "book-board"; this.mediaType = "manga";
} }
async fetchSearchResult(query = "", page = 1) { async search(queryObj) {
const q = query.trim().replace(/\s+/g, "+"); const q = queryObj.query.trim().replace(/\s+/g, "+");
const url = q ? `${this.baseUrl}/search/?q=${q}&page=${page}` : `${this.baseUrl}/?q=&page=${page}`; const url = q
? `${this.baseUrl}/search/?q=${q}`
: `${this.baseUrl}/?q=`;
const data = await this.browser.scrape( const { result: data } = await this.scrape(
url, url,
() => { async (page) => {
const container = document.querySelector('.container.index-container'); return page.evaluate(() => {
if (!container) return { results: [], hasNextPage: false }; const container = document.querySelector('.container.index-container');
if (!container) return {results: [], hasNextPage: false};
const galleryEls = container.querySelectorAll('.gallery'); const galleryEls = container.querySelectorAll('.gallery');
const results = []; const results = [];
galleryEls.forEach(el => { galleryEls.forEach(el => {
const a = el.querySelector('a.cover'); const a = el.querySelector('a.cover');
if (!a) return; if (!a) return;
const href = a.getAttribute('href'); const href = a.getAttribute('href');
const id = href.match(/\d+/)?.[0] || null; const id = href.match(/\d+/)?.[0] || null;
const img = a.querySelector('img.lazyload'); const img = a.querySelector('img.lazyload');
const thumbRaw = img?.dataset?.src || img?.src || ""; const thumbRaw = img?.dataset?.src || img?.src || "";
const thumb = thumbRaw.startsWith("//") ? "https:" + thumbRaw : thumbRaw; const thumb = thumbRaw.startsWith("//") ? "https:" + thumbRaw : thumbRaw;
const coverUrl = thumb.replace("thumb", "cover"); const coverUrl = thumb.replace("thumb", "cover");
const caption = a.querySelector('.caption'); const caption = a.querySelector('.caption');
const title = caption?.textContent.trim() || ""; const title = caption?.textContent.trim() || "";
const tagsRaw = el.getAttribute('data-tags') || ""; results.push({
const tags = tagsRaw.split(" ").filter(Boolean); id,
title,
image: coverUrl,
rating: null,
type: "book"
});
});
results.push({ const hasNextPage = !!document.querySelector('section.pagination a.next');
id, return {results, hasNextPage};
image: thumb, });
sampleImageUrl: coverUrl, },
title, {
tags, waitSelector: '.container.index-container',
type: "book" timeout: 55000
}); }
}); );
const hasNextPage = !!document.querySelector('section.pagination a.next'); return data?.results || [];
}
return { async getMetadata(id) {
results, const { result: data } = await this.scrape(
hasNextPage `${this.baseUrl}/g/${id}/`,
}; async (page) => {
},{ waitSelector: '.container.index-container', timeout: 5000} return page.evaluate(() => {
); const title = document.querySelector('h1.title .pretty')?.textContent?.trim() || "";
return { const img = document.querySelector('#cover img');
results: data.results, const image =
hasNextPage: data.hasNextPage, img?.dataset?.src ? "https:" + img.dataset.src :
page img?.src?.startsWith("//") ? "https:" + img.src :
}; img?.src || "";
}
const tagBlock = document.querySelector('.tag-container.field-name');
const genres = tagBlock
? [...tagBlock.querySelectorAll('.tags .name')].map(x => x.textContent.trim())
: [];
async findChapters(mangaId) { const timeEl = document.querySelector('.tag-container.field-name time');
const data = await this.browser.scrape( const published =
`https://nhentai.net/g/${mangaId}/`, timeEl?.getAttribute("datetime") ||
() => { timeEl?.textContent?.trim() ||
const title = document.querySelector('#info > h1 .pretty')?.textContent?.trim() || ""; "???";
const img = document.querySelector('#cover img'); return {title, image, genres, published};
const cover = img?.dataset?.src ? "https:" + img.dataset.src : img?.src?.startsWith("//") ? "https:" + img.src : img?.src || ""; });
},
{
waitSelector: "#bigcontainer",
timeout: 55000
}
);
const hash = cover.match(/galleries\/(\d+)\//)?.[1] || null; if (!data) throw new Error(`Fallo al obtener metadatos para ID ${id}`);
const thumbs = document.querySelectorAll('.thumbs img'); const formattedDate = data.published
const pages = thumbs.length; ? new Date(data.published).toLocaleDateString("es-ES")
: "???";
const first = thumbs[0]; return {
const s = first?.dataset?.src || first?.src || ""; id,
const ext = s.match(/t\.(\w+)/)?.[1] || "jpg"; title: data.title || "",
format: "Manga",
score: 0,
genres: Array.isArray(data.genres) ? data.genres : [],
status: "Finished",
published: formattedDate,
summary: "",
chapters: 1,
image: data.image || ""
};
}
const langTag = [...document.querySelectorAll('#tags .tag-container')].find(x => x.textContent.includes("Languages:")); async findChapters(mangaId) {
const language = langTag?.querySelector('.tags .name')?.textContent?.trim() || ""; const { result: data } = await this.scrape(
`${this.baseUrl}/g/${mangaId}/`,
async (page) => {
return page.evaluate(() => {
const title = document.querySelector('#info > h1 .pretty')?.textContent?.trim() || "";
return { title, cover, hash, pages, ext, language }; const img = document.querySelector('#cover img');
}, { waitSelector: '#bigcontainer', timeout: 4000 } const cover =
); img?.dataset?.src ? "https:" + img.dataset.src :
img?.src?.startsWith("//") ? "https:" + img.src :
img?.src || "";
const encodedChapterId = Buffer.from(JSON.stringify({ const hash = cover.match(/galleries\/(\d+)\//)?.[1] || null;
hash: data.hash,
pages: data.pages,
ext: data.ext
})).toString("base64");
return { const thumbs = document.querySelectorAll('.thumbs img');
chapters: [ const pages = thumbs.length;
{
id: encodedChapterId,
title: data.title,
chapter: 1,
index: 0,
language: data.language
}
],
cover: data.cover
};
} const first = thumbs[0];
const s = first?.dataset?.src || first?.src || "";
const ext = s.match(/t\.(\w+)/)?.[1] || "jpg";
async findChapterPages(chapterId) { const langTag = [...document.querySelectorAll('#tags .tag-container')]
const decoded = JSON.parse( .find(x => x.textContent.includes("Languages:"));
Buffer.from(chapterId, "base64").toString("utf8")
);
const { hash, pages, ext } = decoded; const language = langTag?.querySelector('.tags .name')?.textContent?.trim() || "";
const baseUrl = "https://i.nhentai.net/galleries";
return Array.from({ length: pages }, (_, i) => ({ return {title, cover, hash, pages, ext, language};
url: `${baseUrl}/${hash}/${i + 1}.${ext}`, });
index: i, },
headers: { {
Referer: `https://nhentai.net/g/${hash}/` 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,
number: 1,
releaseDate: null,
index: 0,
}];
}
async findChapterPages(chapterId) {
const decoded = JSON.parse(Buffer.from(chapterId, "base64").toString("utf8"));
const { hash, pages, ext } = decoded;
const baseUrl = "https://i.nhentai.net/galleries";
return Array.from({ length: pages }, (_, i) => ({
url: `${baseUrl}/${hash}/${i + 1}.${ext}`,
index: i,
headers: { Referer: `https://nhentai.net/g/${hash}/` }
}));
}
} }
module.exports = { nhentai }; module.exports = nhentai;

View File

@@ -1,129 +1,121 @@
class NovelBin { class NovelBin {
constructor(fetchPath, cheerioPath, browser) { constructor() {
this.browser = browser;
this.fetch = require(fetchPath);
this.cheerio = require(cheerioPath);
this.baseUrl = "https://novelbin.me"; this.baseUrl = "https://novelbin.me";
this.type = "book-board"; this.type = "book-board";
this.mediaType = "ln";
} }
async fetchSearchResult(query = "", page = 1) { async search(queryObj) {
const url = !query || query.trim() === "" const query = queryObj.query || "";
? `${this.baseUrl}/sort/novelbin-hot?page=${page}` const url = `${this.baseUrl}/search?keyword=${encodeURIComponent(query)}`;
: `${this.baseUrl}/search?keyword=${encodeURIComponent(query)}`;
const res = await fetch(url, {
headers: {
"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",
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"referer": this.baseUrl + "/"
}
});
const res = await this.fetch(url);
const html = await res.text(); const html = await res.text();
const $ = this.cheerio.load(html); const $ = this.cheerio.load(html);
const results = []; const results = [];
$(".list-novel .row, .col-novel-main .list-novel .row").each((i, el) => { $('h3.novel-title a').each((i, el) => {
const titleEl = $(el).find("h3.novel-title a"); const href = $(el).attr('href');
if (!titleEl.length) return; const title = $(el).text().trim();
const title = titleEl.text().trim();
let href = titleEl.attr("href");
if (!href) return;
if (!href.startsWith("http")) { href = `${this.baseUrl}${href}` }
const idMatch = href.match(/novel-book\/([^/?]+)/); const idMatch = href.match(/novel-book\/([^/?]+)/);
const id = idMatch ? idMatch[1] : null; const id = idMatch ? idMatch[1] : null;
if (!id) return;
const coverUrl = `${this.baseUrl}/media/novel/${id}.jpg`; const img = `${this.baseUrl}/media/novel/${id}.jpg`;
results.push({ results.push({
id, id,
title, title,
image: coverUrl, image: img,
sampleImageUrl: coverUrl, rating: null,
tags: [],
type: "book" type: "book"
}); });
}); });
const hasNextPage = $(".PagedList-skipToNext a").length > 0; return results;
}
async getMetadata(id) {
const res = await fetch(`${this.baseUrl}/novel-book/${id}`);
const html = await res.text();
const $ = this.cheerio.load(html);
const getMeta = (property) => $(`meta[property='${property}']`).attr('content') || "";
const title = getMeta("og:novel:novel_name") || $('title').text() || "";
const summary = $('meta[name="description"]').attr('content') || "";
const genresRaw = getMeta("og:novel:genre");
const genres = genresRaw ? genresRaw.split(',').map(g => g.trim()) : [];
const status = getMeta("og:novel:status") || "";
const image = getMeta("og:image");
const lastChapterName = getMeta("og:novel:lastest_chapter_name");
const chaptersMatch = lastChapterName.match(/Chapter\s+(\d+)/i);
const chapters = chaptersMatch ? Number(chaptersMatch[1]) : 0;
return { return {
results, id,
hasNextPage, title,
page format: "Light Novel",
score: 0,
genres,
status,
published: "???",
summary,
chapters,
image
}; };
} }
async findChapters(bookId) { async findChapters(bookId) {
const res = await this.fetch(`${this.baseUrl}/novel-book/${bookId}`); const res = await fetch(`${this.baseUrl}/ajax/chapter-archive?novelId=${bookId}`, {
headers: {
"user-agent": "Mozilla/5.0"
}
});
const html = await res.text(); const html = await res.text();
const $ = this.cheerio.load(html); const $ = this.cheerio.load(html);
const chapters = []; const chapters = [];
$("#chapter-archive ul.list-chapter li a").each((i, el) => { $('a[title]').each((i, el) => {
const a = $(el); const fullUrl = $(el).attr('href');
const title = a.attr("title") || a.text().trim(); const title = $(el).attr('title').trim();
let href = a.attr("href"); const numMatch = title.match(/chapter\s+(\d+(?:\.\d+)?)/i);
if (!href) return;
if (href.startsWith("https://novelbin.me")) { href = href.replace("https://novelbin.me", "") }
const match = title.match(/chapter\s*([\d.]+)/i);
const chapterNumber = match ? match[1] : "0";
chapters.push({ chapters.push({
id: href, id: fullUrl,
title: title.trim(), title,
chapter: chapterNumber, number: numMatch ? numMatch[1] : "0",
language: "en" releaseDate: null,
index: i
}); });
}); });
return { chapters: chapters }; return chapters;
} }
async findChapterPages(chapterId) { async findChapterPages(chapterUrl) {
const url = chapterId.startsWith('http') ? chapterId : `${this.baseUrl}${chapterId}`; const {result} = await this.scrape(chapterUrl, async (page) => {
return page.evaluate(() => {
const content = await this.browser.scrape( document.querySelectorAll('div[id^="pf-"]').forEach(e => e.remove());
url, const ps = Array.from(document.querySelectorAll("p")).map(p => p.outerHTML.trim()).filter(p => p.length > 7);
() => { return ps.join("\n");
const contentDiv = document.querySelector('#chr-content, .chr-c'); });
if (!contentDiv) return "<p>Error: Could not find content.</p>"; }, {
contentDiv.querySelectorAll('script, div[id^="pf-"], div[style*="text-align:center"], ins, div[align="center"], .ads, .adsbygoogle').forEach(el => el.remove()); waitUntil: "domcontentloaded",
renderWaitTime: 300
const paragraphs = contentDiv.querySelectorAll('p'); });
let cleanHtml = ''; return result || "<p>Error: chapter text not found</p>";
paragraphs.forEach(p => {
let text = p.textContent || '';
text = text.replace(/△▼△▼△▼△/g, '');
text = text.replace(/[※\s]{2,}/g, '');
const html = p.innerHTML.trim();
const isAd = text.includes('Remove Ads From') || text.includes('Buy no ads experience');
const isEmpty = html === '' || html === '&nbsp;' || text.trim() === '';
if (!isAd && !isEmpty) {
if (p.textContent !== text) {
p.textContent = text;
}
cleanHtml += p.outerHTML;
}
});
return cleanHtml || contentDiv.innerHTML;
},
{ waitSelector: '#chr-content', timeout: 2000 }
);
return [{
type: 'text',
content: content,
index: 0
}];
} }
} }
module.exports = NovelBin;
module.exports = { NovelBin };

View File

@@ -1,155 +1,137 @@
class novelfire { class NovelFire {
constructor(fetchPath, cheerioPath, browser) { constructor() {
this.browser = browser;
this.fetch = require(fetchPath);
this.cheerio = require(cheerioPath);
this.baseUrl = "https://novelfire.net"; this.baseUrl = "https://novelfire.net";
this.type = "book-board"; this.type = "book-board";
this.mediaType = "ln";
} }
async fetchSearchResult(query = "", page = 1) { async search(queryObj) {
let html; const query = queryObj.query;
if (query.trim() === "") { const res = await fetch(
const res = await this.fetch(`${this.baseUrl}/home`); `${this.baseUrl}/ajax/searchLive?inputContent=${encodeURIComponent(query)}`,
html = await res.text(); { headers: { "accept": "application/json" } }
} else { );
const res = await this.fetch(
`${this.baseUrl}/ajax/searchLive?inputContent=${encodeURIComponent(query)}` const data = await res.json();
); if (!data.data) return [];
const data = await res.json();
html = data.html; return data.data.map(item => ({
id: item.slug,
title: item.title,
image: `https://novelfire.net/${item.image}`,
rating: item.rank ?? null,
type: "book"
}));
}
async getMetadata(id) {
const url = `https://novelfire.net/book/${id}`;
const html = await (await fetch(url)).text();
const $ = this.cheerio.load(html);
const title = $('h1[itemprop="name"]').first().text().trim() || null;
const summary = $('meta[itemprop="description"]').attr('content') || null;
const image =
$('figure.cover img').attr('src') ||
$('img.cover').attr('src') ||
$('img[src*="server-"]').attr('src') ||
null;
const genres = $('.categories a.property-item')
.map((_, el) => $(el).attr('title') || $(el).text().trim())
.get();
let chapters = null;
const latest = $('.chapter-latest-container .latest').text();
if (latest) {
const m = latest.match(/Chapter\s+(\d+)/i);
if (m) chapters = Number(m[1]);
} }
const $ = this.cheerio.load(html); let status = 'unknown';
const results = []; const statusClass = $('strong.ongoing, strong.completed').attr('class');
if (statusClass) {
$(".novel-item").each((_, el) => { status = statusClass.toLowerCase();
const a = $(el).find("a"); }
const href = a.attr("href") || "";
const title = $(el).find(".novel-title").text().trim();
const img = $(el).find("img");
const image = img.attr("data-src") || img.attr("src") || "";
const id = href.replace("https://novelfire.net/book/", "").replace(/\/$/, "");
results.push({
id,
title,
image,
sampleImageUrl: image,
tags: [],
type: "book"
});
});
return { return {
results, id,
hasNextPage: false, title,
page format: 'Light Novel',
score: 0,
genres,
status,
published: '???',
summary,
chapters,
image
}; };
} }
async findChapters(bookId) { async findChapters(bookId) {
const url = `https://novelfire.net/book/${bookId}/chapter-1`; const url = `https://novelfire.net/book/${bookId}/chapters`;
const html = await (await fetch(url)).text();
const options = await this.browser.scrape(
url,
async () => {
const sleep = ms => new Promise(r => setTimeout(r, ms));
const select = document.querySelector('.chapindex');
if (!select) return [];
select.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
select.dispatchEvent(new MouseEvent('click', { bubbles: true }));
for (let i = 0; i < 20; i++) {
if (document.querySelectorAll('.select2-results__option').length > 0) break;
await sleep(300);
}
return [...select.querySelectorAll('option')].map(opt => ({
id: opt.value,
title: opt.textContent.trim(),
chapter: Number(opt.dataset.n_sort || 0),
}));
},
{
waitSelector: '.chapindex',
timeout: 10000
}
);
return {
chapters: options.map(o => ({
id: `https://novelfire.net/book/${bookId}/chapter-${o.chapter}`,
title: o.title,
chapter: o.chapter,
language: "en"
}))
};
}
async findChapterPages(chapterId) {
const res = await this.fetch(chapterId);
const html = await res.text();
const $ = this.cheerio.load(html); const $ = this.cheerio.load(html);
const contentDiv = $("#content"); let postId;
if (!contentDiv || contentDiv.length === 0) { $("script").each((_, el) => {
return [{ const txt = $(el).html() || "";
type: "text", const m = txt.match(/listChapterDataAjax\?post_id=(\d+)/);
content: "<p>Error: content not found</p>", if (m) postId = m[1];
index: 0
}];
}
contentDiv.find("script").remove();
contentDiv.find("ins").remove();
contentDiv.find("[id^='pf-']").remove();
contentDiv.find(".ads").remove();
contentDiv.find(".adsbygoogle").remove();
contentDiv.find("div[style*='text-align:center']").remove();
contentDiv.find("div[align='center']").remove();
contentDiv.find(".nf-ads").remove();
contentDiv.find("nfne597").remove();
const paragraphs = contentDiv.find("p");
let cleanHtml = "";
paragraphs.each((_, el) => {
const p = $(el);
let text = p.text() || "";
text = text.replace(/△▼△▼△▼△/g, "");
text = text.replace(/[※]+/g, "");
text = text.replace(/\s{2,}/g, " ");
const htmlP = p.html()?.trim() || "";
const isEmpty = htmlP === "" || htmlP === "&nbsp;" || text.trim() === "";
const isAd = text.includes("Remove Ads") || text.includes("Buy no ads") || text.includes("novelfire");
if (!isEmpty && !isAd) {
if (p.text() !== text) p.text(text);
cleanHtml += $.html(p);
}
}); });
if (!cleanHtml.trim()) { cleanHtml = contentDiv.html(); } if (!postId) throw new Error("post_id not found");
return [ const params = new URLSearchParams({
{ post_id: postId,
type: "text", draw: 1,
content: cleanHtml.trim(), "columns[0][data]": "title",
index: 0 "columns[0][orderable]": "false",
} "columns[1][data]": "created_at",
]; "columns[1][orderable]": "true",
"order[0][column]": 1,
"order[0][dir]": "asc",
start: 0,
length: 1000
});
const res = await fetch(
`https://novelfire.net/listChapterDataAjax?${params}`,
{ headers: { "x-requested-with": "XMLHttpRequest" } }
);
const json = await res.json();
if (!json?.data) throw new Error("Invalid response");
return json.data.map((c, i) => ({
id: `https://novelfire.net/book/${bookId}/chapter-${c.n_sort}`,
title: c.title,
number: Number(c.n_sort),
release_date: c.created_at ?? null,
index: i,
language: "en"
}));
}
async findChapterPages(url) {
const html = await (await fetch(url)).text();
const $ = this.cheerio.load(html);
const $content = $("#content").clone();
$content.find("script, ins, .nf-ads, img, nfn2a74").remove();
$content.find("*").each((_, el) => {
$(el).removeAttr("id").removeAttr("class").removeAttr("style");
});
return $content.html()
.replace(/adsbygoogle/gi, "")
.replace(/novelfire/gi, "")
.trim();
} }
} }
module.exports = { novelupdates: novelfire }; module.exports = NovelFire;

View File

@@ -1,170 +1,84 @@
class Realbooru { class Realbooru {
baseUrl = "https://realbooru.com"; baseUrl = "https://realbooru.com";
headers = { headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' "User-Agent": "Mozilla/5.0"
}; };
constructor(fetchPath, cheerioPath) { constructor() {
this.fetch = require(fetchPath);
this.cheerio = require(cheerioPath);
this.type = "image-board"; this.type = "image-board";
} }
LoadDoc(body) { async search(query = "original", page = 1, perPage = 42) {
return this.cheerio.load(body);
}
async fetchSearchResult(query, page = 1, perPage = 42) {
if (!query) query = "original";
const offset = (page - 1) * perPage; const offset = (page - 1) * perPage;
const url = `${this.baseUrl}/index.php?page=post&s=list&tags=${query}&pid=${offset}`;
try { const tags = query
const response = await this.fetch(url, { headers: this.headers }); .trim()
const data = await response.text(); .split(/\s+/)
.join("+") + "+";
const $ = this.cheerio.load(data); const url = `${this.baseUrl}/index.php?page=post&s=list&tags=${tags}&pid=${offset}`;
const html = await fetch(url, { headers: this.headers }).then(r => r.text());
const $ = this.cheerio.load(html);
const results = []; const results = [];
$('#post-list a[id^="p"], #post-list a[href*="&s=view"], .thumb a').each((i, e) => { $('div.col.thumb').each((_, el) => {
const $a = $(e); const id = ($(el).attr('id') || "").replace('s', '');
const img = $(el).find('img');
const href = $a.attr('href'); let image = img.attr('src');
let id = null; if (image && !image.startsWith('http')) image = 'https:' + image;
if (href) {
const idMatch = href.match(/&id=(\d+)/);
if (idMatch) {
id = idMatch[1];
}
}
if (!id) { const title = img.attr('title') || '';
id = $a.closest('span, div').attr('id')?.replace('s', '').replace('post_', ''); const tags = title
} .split(',')
.map(t => t.trim())
.filter(Boolean);
const imageElement = $a.find('img').first(); if (id && image) {
let image = imageElement.attr('src'); results.push({ id, image, tags });
if (image && !image.startsWith('http')) {
image = `https:${image}`;
}
let tags = imageElement.attr('alt')?.trim()?.split(' ').filter(tag => tag !== "");
if (!tags || tags.length === 0) {
tags = $a.attr('title')?.trim()?.split(' ').filter(tag => tag !== "");
}
if (id && image) {
results.push({
id: id,
image: image,
tags: tags,
type: 'preview'
});
}
});
const pagination = $('#paginator .pagination');
const lastPageLink = pagination.find('a[alt="last page"]');
let totalPages = 1;
if (lastPageLink.length > 0) {
const pid = lastPageLink.attr('href')?.split('pid=')[1];
totalPages = Math.ceil(parseInt(pid || "0", 10) / perPage) + 1;
} else {
const pageLinks = pagination.find('a');
if (pageLinks.length > 0) {
const lastLinkText = pageLinks.eq(-2).text();
totalPages = parseInt(lastLinkText, 10) || 1;
} else if (results.length > 0) {
totalPages = 1;
}
} }
});
const currentPage = page; let totalPages = page;
const hasNextPage = currentPage < totalPages; const lastPid = $('a[alt="last page"]').attr('href')?.match(/pid=(\d+)/);
const next = hasNextPage ? (currentPage + 1) : 0; if (lastPid) {
const previous = currentPage > 1 ? (currentPage - 1) : 0; totalPages = Math.floor(parseInt(lastPid[1], 10) / perPage) + 1;
const total = totalPages * perPage;
return { total, next, previous, pages: totalPages, page: currentPage, hasNextPage, results };
} catch (e) {
console.error("Error during Realbooru search:", e);
return { total: 0, next: 0, previous: 0, pages: 1, page: 1, hasNextPage: false, results: [] };
} }
return {
results,
page,
hasNextPage: page < totalPages
};
} }
async fetchInfo(id) { async getInfo(id) {
const url = `${this.baseUrl}/index.php?page=post&s=view&id=${id}`; const url = `${this.baseUrl}/index.php?page=post&s=view&id=${id}`;
const html = await fetch(url, { headers: this.headers }).then(r => r.text());
const $ = this.cheerio.load(html);
const fetchHeaders = { ...this.headers }; let image =
$('video source').attr('src') ||
$('#image').attr('src') ||
null;
const response = await this.fetch(url, { headers: fetchHeaders }); if (image && !image.startsWith('http')) {
const original = await response.text(); image = this.baseUrl + image;
const $ = this.cheerio.load(original);
let fullImage = $('#image').attr('src') || $('video').attr('src');
const originalLink = $('div.link-list a:contains("Original image")').attr('href');
if (originalLink) {
fullImage = originalLink;
} }
if (fullImage && !fullImage.startsWith('http')) { const tags = [];
fullImage = `https:${fullImage}`; $('#tagLink a').each((_, el) => {
} tags.push($(el).text().trim());
});
let resizedImageUrl = $('#image-holder img').attr('src');
if (resizedImageUrl && !resizedImageUrl.startsWith('http')) {
resizedImageUrl = `https:${resizedImageUrl}`;
} else if (!resizedImageUrl) {
resizedImageUrl = fullImage;
}
const tags = $('.tag-list a.tag-link').map((i, el) => $(el).text().trim()).get();
const stats = $('#stats ul');
const postedData = stats.find("li:contains('Posted:')").text().trim();
const postedDateMatch = postedData.match(/Posted: (.*?) by/);
const createdAt = postedDateMatch ? new Date(postedDateMatch[1]).getTime() : undefined;
const publishedByMatch = postedData.match(/by\s*(.*)/);
const publishedBy = publishedByMatch ? publishedByMatch[1].trim() : undefined;
const rating = stats.find("li:contains('Rating:')").text().trim().split("Rating: ")[1] || undefined;
const comments = $('#comment-list div').map((i, el) => {
const $el = $(el);
const id = $el.attr('id')?.replace('c', '');
const user = $el.find('.col1').text().trim().split("\n")[0];
const comment = $el.find('.col2').text().trim();
if (id && user && comment) {
return { id, user, comment };
}
return null;
}).get().filter(Boolean);
return { return {
id, id,
fullImage, image,
resizedImageUrl, tags
tags,
createdAt,
publishedBy,
rating,
comments
}; };
} }
} }
module.exports = { Realbooru }; module.exports = Realbooru;

102
rule34.js
View File

@@ -5,79 +5,61 @@ class Rule34 {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}; };
constructor(fetchPath, cheerioPath) { constructor() {
this.fetch = require(fetchPath);
this.cheerio = require(cheerioPath);
this.type = "image-board"; this.type = "image-board";
} }
async fetchSearchResult(query, page = 1, perPage = 42) { async search(query = "alisa_mikhailovna_kujou", page = 1, perPage = 42) {
if (!query) query = "alisa_mikhailovna_kujou";
const offset = (page - 1) * perPage; const offset = (page - 1) * perPage;
const url = `${this.baseUrl}/index.php?page=post&s=list&tags=${query}&pid=${offset}`; const url = `${this.baseUrl}/index.php?page=post&s=list&tags=${query}&pid=${offset}`;
const response = await this.fetch(url, { headers: this.headers }); const response = await fetch(url, { headers: this.headers });
const data = await response.text(); const data = await response.text();
const $ = this.cheerio.load(data); const $ = this.cheerio.load(data);
const results = []; const results = [];
$('.image-list span').each((i, e) => { $('.image-list span').each((_, e) => {
const $e = $(e); const $e = $(e);
const id = $e.attr('id')?.replace('s', ''); const id = $e.attr('id')?.replace('s', '');
let image = $e.find('img').attr('src'); let image = $e.find('img').attr('src');
if (image && !image.startsWith('http')) { if (image && !image.startsWith('http')) {
image = `https:${image}`; image = `https:${image}`;
} }
const tags = $e.find('img').attr('alt')?.trim()?.split(' ').filter(tag => tag !== ""); const tags = $e.find('img')
.attr('alt')
?.trim()
.split(' ')
.filter(Boolean);
if (id && image) { if (id && image) {
results.push({ results.push({
id: id, id,
image: image, image,
tags: tags, tags
type: 'preview'
}); });
} }
}); });
const pagination = $('#paginator .pagination'); const pagination = $('#paginator .pagination');
const lastPageLink = pagination.find('a[alt="last page"]'); const lastPageLink = pagination.find('a[alt="last page"]');
let totalPages = 1; let totalPages = 1;
if (lastPageLink.length) {
if (lastPageLink.length > 0) { const pid = Number(lastPageLink.attr('href')?.split('pid=')[1] ?? 0);
totalPages = Math.ceil(pid / perPage) + 1;
const pid = lastPageLink.attr('href')?.split('pid=')[1];
totalPages = Math.ceil(parseInt(pid || "0", 10) / perPage) + 1;
} else {
const pageLinks = pagination.find('a');
if (pageLinks.length > 0) {
const lastLinkText = pageLinks.eq(-2).text();
totalPages = parseInt(lastLinkText, 10) || 1;
} else if (results.length > 0) {
totalPages = 1;
}
} }
const currentPage = page; return {
const hasNextPage = currentPage < totalPages; page,
const next = hasNextPage ? (currentPage + 1) : 0; hasNextPage: page < totalPages,
const previous = currentPage > 1 ? (currentPage - 1) : 0; results
};
const total = totalPages * perPage;
return { total, next, previous, pages: totalPages, page: currentPage, hasNextPage, results };
} }
async fetchInfo(id) { async getInfo(id) {
const url = `${this.baseUrl}/index.php?page=post&s=view&id=${id}`; const url = `${this.baseUrl}/index.php?page=post&s=view&id=${id}`;
const resizeCookies = { const resizeCookies = {
@@ -91,55 +73,27 @@ class Rule34 {
const resizeHeaders = { ...this.headers, 'cookie': cookieString }; const resizeHeaders = { ...this.headers, 'cookie': cookieString };
const [resizedResponse, nonResizedResponse] = await Promise.all([ const [resizedResponse, nonResizedResponse] = await Promise.all([
this.fetch(url, { headers: resizeHeaders }), fetch(url, { headers: resizeHeaders }),
this.fetch(url, { headers: fetchHeaders }) fetch(url, { headers: fetchHeaders })
]); ]);
const [resized, original] = await Promise.all([resizedResponse.text(), nonResizedResponse.text()]); const [resized, original] = await Promise.all([resizedResponse.text(), nonResizedResponse.text()]);
const $resized = this.cheerio.load(resized);
const $ = this.cheerio.load(original); const $ = this.cheerio.load(original);
let resizedImageUrl = $resized('#image').attr('src');
if (resizedImageUrl && !resizedImageUrl.startsWith('http')) {
resizedImageUrl = `https:${resizedImageUrl}`;
}
let fullImage = $('#image').attr('src'); let fullImage = $('#image').attr('src');
if (fullImage && !fullImage.startsWith('http')) { if (fullImage && !fullImage.startsWith('http')) {
fullImage = `https:${fullImage}`; fullImage = `https:${fullImage}`;
} }
const tags = $('#image').attr('alt')?.trim()?.split(' ').filter(tag => tag !== ""); const tags = $('#image').attr('alt')?.trim()?.split(' ').filter(tag => tag !== "");
const stats = $('#stats ul');
const postedData = stats.find('li:nth-child(2)').text().trim();
const createdAt = new Date(postedData.split("Posted: ")[1].split("by")[0]).getTime();
const publishedBy = postedData.split("by")[1].trim();
const rating = stats.find("li:contains('Rating:')").text().trim().split("Rating: ")[1];
const comments = $('#comment-list div').map((i, el) => {
const $el = $(el);
const id = $el.attr('id')?.replace('c', '');
const user = $el.find('.col1').text().trim().split("\n")[0];
const comment = $el.find('.col2').text().trim();
if (id && user && comment) {
return { id, user, comment };
}
return null;
}).get().filter(Boolean);
return { return {
id, id,
fullImage, image: fullImage,
resizedImageUrl, tags
tags,
createdAt,
publishedBy,
rating,
comments
}; };
} }
} }
module.exports = { Rule34 }; module.exports = Rule34;

138
tenor.js
View File

@@ -1,61 +1,65 @@
class Tenor { class Tenor {
baseUrl = "https://tenor.com"; baseUrl = "https://tenor.com";
constructor(fetchPath, cheerioPath, browser) { constructor() {
this.browser = browser;
this.type = "image-board"; this.type = "image-board";
this.lastQuery = null; this.lastQuery = null;
this.seenIds = new Set(); this.seenIds = new Set();
} }
async fetchSearchResult(query = "hello", page = 1, perPage = 48) { async search(query, page = 1, perPage = 48) {
query = query?.trim() || "thighs";
if (query !== this.lastQuery) { if (query !== this.lastQuery) {
this.lastQuery = query; this.lastQuery = query;
this.seenIds.clear(); this.seenIds.clear();
} }
const url = `${this.baseUrl}/search/${query.replace(" ", "-")}-gifs`; const url = `${this.baseUrl}/search/${query.replaceAll(" ", "-")}-gifs`;
const data = await this.browser.scrape( const { result } = await this.scrape(
url, url,
() => { async (page) => {
// Fallback selectors: try specific class first, then generic figure return page.evaluate(() => {
const items = document.querySelectorAll('div.GifList figure, figure'); const items = document.querySelectorAll('div.GifList figure, figure');
const results = []; const results = [];
items.forEach(fig => { items.forEach(fig => {
const link = fig.querySelector('a'); const link = fig.querySelector('a');
const img = fig.querySelector('img'); const img = fig.querySelector('img');
if (!link || !img) return; if (!link || !img) return;
const href = link.getAttribute('href') || ""; const href = link.getAttribute('href') || "";
const idMatch = href.match(/-(\d+)(?:$|\/?$)/);
const id = idMatch ? idMatch[1] : null;
let idMatch = href.match(/-(\d+)(?:$|\/?$)/); const imgUrl =
const id = idMatch ? idMatch[1] : null; img.getAttribute('src') ||
img.getAttribute('data-src');
// Tenor lazy loads images, so we check 'src' AND 'data-src' const tagsRaw = img.getAttribute('alt') || "";
const imgUrl = img.getAttribute('src') || img.getAttribute('data-src'); const tags = tagsRaw.trim().split(/\s+/).filter(Boolean);
const tagsRaw = img.getAttribute('alt') || "";
const tags = tagsRaw.trim().split(/\s+/).filter(Boolean);
if (id && imgUrl && !imgUrl.includes('placeholder')) { if (id && imgUrl && !imgUrl.includes("placeholder")) {
results.push({ results.push({
id, id,
image: imgUrl, image: imgUrl,
sampleImageUrl: imgUrl, sampleImageUrl: imgUrl,
tags, tags,
type: "preview" type: "preview"
}); });
} }
});
const uniqueResults = Array.from(
new Map(results.map(r => [r.id, r])).values()
);
return {results: uniqueResults, hasNextPage: true};
}); });
const uniqueResults = Array.from(new Map(results.map(item => [item.id, item])).values());
return { results: uniqueResults, hasNextPage: true };
}, },
{ {
waitSelector: 'figure', waitSelector: "figure",
timeout: 30000, timeout: 30000,
scrollToBottom: true, scrollToBottom: true,
renderWaitTime: 3000, renderWaitTime: 3000,
@@ -63,61 +67,49 @@ class Tenor {
} }
); );
const newResults = data.results.filter(item => !this.seenIds.has(item.id)); const newResults = result.results.filter(r => !this.seenIds.has(r.id));
newResults.forEach(r => this.seenIds.add(r.id));
newResults.forEach(item => this.seenIds.add(item.id));
return { return {
results: newResults, results: newResults.map(r => ({
hasNextPage: data.hasNextPage, id: r.id,
image: r.image,
tags: r.tags,
})),
hasNextPage: result.hasNextPage,
page page
}; };
} }
async fetchInfo(id) { async getInfo(id) {
const url = `${this.baseUrl}/view/gif-${id}`; const url = `${this.baseUrl}/view/gif-${id}`;
const data = await this.browser.scrape( const { result } = await this.scrape(
url, url,
() => { async (page) => {
const img = document.querySelector('img[alt]'); return page.evaluate(() => {
const fullImage = img?.src || null; const img = document.querySelector(".Gif img");
const fullImage = img?.src || null;
const tags = [...document.querySelectorAll('.tag-list li a .RelatedTag')] const tags = [...document.querySelectorAll(".tag-list li a .RelatedTag")]
.map(tag => tag.textContent.trim()) .map(t => t.textContent.trim())
.filter(Boolean); .filter(Boolean);
let createdAt = Date.now(); return { fullImage, tags };
});
const detailNodes = [...document.querySelectorAll('.gif-details dd')];
const createdNode = detailNodes.find(n => n.textContent.includes("Created:"));
if (createdNode) {
const raw = createdNode.textContent.replace("Created:", "").trim();
const parts = raw.split(/[\/,: ]+/);
if (parts.length >= 6) {
let [dd, mm, yyyy, hh, min, ss] = parts.map(p => parseInt(p, 10));
createdAt = new Date(yyyy, mm - 1, dd, hh, min, ss).getTime();
}
}
return {
fullImage,
tags,
createdAt
};
}, },
{ waitSelector: 'img[alt]', timeout: 15000 } { waitSelector: ".Gif img", timeout: 15000 }
); );
return { return {
id, id,
fullImage: data.fullImage, image: result.fullImage,
tags: data.tags, tags: result.tags,
createdAt: data.createdAt, title: result.tags?.join(" ") || `Tenor GIF ${id}`,
rating: "Unknown" headers: ""
}; };
} }
} }
module.exports = { Tenor }; module.exports = Tenor;

View File

@@ -8,12 +8,11 @@ class WaifuPics {
'happy', 'wink', 'poke', 'dance', 'cringe' 'happy', 'wink', 'poke', 'dance', 'cringe'
]; ];
constructor(fetchPath, cheerioPath) { constructor() {
this.fetch = require(fetchPath);
this.type = "image-board"; this.type = "image-board";
} }
async fetchSearchResult(query, page = 1, perPage = 42) { async search(query, page = 1, perPage = 42) {
if (!query) query = "waifu"; if (!query) query = "waifu";
const category = query.trim().split(' ')[0]; const category = query.trim().split(' ')[0];
@@ -34,7 +33,7 @@ class WaifuPics {
try { try {
const response = await this.fetch(`${this.baseUrl}/many/sfw/${category}`, { const response = await fetch(`${this.baseUrl}/many/sfw/${category}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exclude: [] }), body: JSON.stringify({ exclude: [] }),
@@ -49,13 +48,12 @@ class WaifuPics {
const results = data.files.map((url, index) => { const results = data.files.map((url, index) => {
const id = url.substring(url.lastIndexOf('/') + 1) || `${category}-${index}`; const id = url.substring(url.lastIndexOf('/') + 1) || `${category}-${index}`;
const uniqueId = `${page}-${id}`; const uniqueId = `${id}`;
return { return {
id: uniqueId, id: uniqueId,
image: url, image: url,
tags: [category], tags: [category],
type: 'preview'
}; };
}); });
@@ -83,19 +81,13 @@ class WaifuPics {
} }
} }
async fetchInfo(id) { async getInfo(id) {
console.log(`[WaifuPics] fetchInfo called for ${id}, but this API only provides direct URLs.`);
return { return {
id: id, id,
fullImage: `https://i.waifu.pics/${id}`, image: `https://i.waifu.pics/${id}`,
resizedImageUrl: `https://i.waifu.pics/${id}`, tags: []
tags: [],
createdAt: null,
publishedBy: 'Waifu.pics',
rating: 'sfw',
comments: []
}; };
} }
} }
module.exports = { WaifuPics }; module.exports = WaifuPics;

View File

@@ -1,59 +1,23 @@
class wattpad { class wattpad {
constructor(fetchPath, cheerioPath, browser) { constructor() {
this.browser = browser;
this.fetch = require(fetchPath);
this.cheerio = require(cheerioPath);
this.baseUrl = "https://wattpad.com"; this.baseUrl = "https://wattpad.com";
this.type = "book-board"; this.type = "book-board";
this.mediaType = "ln";
} }
async fetchSearchResult(query = "", page = 1) { async search(queryObj) {
if (!query || query.trim() === "") { const query = queryObj.query?.trim() || "";
const res = await this.fetch("https://www.wattpad.com/");
const html = await res.text();
const $ = this.cheerio.load(html);
const results = [];
$("li.splide__slide").each((_, el) => {
const li = $(el);
const link = li.find("a[data-testid='coverLink']").attr("href") || "";
const img = li.find("img[data-testid='image']").attr("src") || "";
const title = li.find("img[data-testid='image']").attr("alt") || "";
if (link && img) {
const id = link.split("/story/")[1]?.split(/[^0-9]/)[0] || null;
if (id) {
results.push({
id,
title,
image: img,
sampleImageUrl: img,
tags: [],
type: "book"
});
}
}
});
return {
results,
hasNextPage: false,
page: 1
};
}
const limit = 15; const limit = 15;
const offset = (page - 1) * limit; const offset = 0;
const url = `${this.baseUrl}/v4/search/stories?query=${query}&fields=stories%28id%2Ctitle%2CvoteCount%2CreadCount%2CcommentCount%2Cdescription%2Ccompleted%2Cmature%2Ccover%2Curl%2CisPaywalled%2CpaidModel%2Clength%2Clanguage%28id%29%2Cuser%28name%29%2CnumParts%2ClastPublishedPart%28createDate%29%2Cpromoted%2Csponsor%28name%2Cavatar%29%2Ctags%2Ctracking%28clickUrl%2CimpressionUrl%2CthirdParty%28impressionUrls%2CclickUrls%29%29%2Ccontest%28endDate%2CctaLabel%2CctaURL%29%29%2Ctotal%2Ctags%2Cnexturl&limit=${limit}&mature=false&offset=${offset}`; const url =
`${this.baseUrl}/v4/search/stories?` +
`query=${encodeURIComponent(query)}` +
`&limit=${limit}&offset=${offset}&mature=false`;
const res = await this.fetch(url); const json = await fetch(url).then(r => r.json());
const json = await res.json();
const results = json.stories.map(n => ({ return json.stories.map(n => ({
id: n.id, id: n.id,
title: n.title, title: n.title,
image: n.cover, image: n.cover,
@@ -61,104 +25,118 @@ class wattpad {
tags: n.tags, tags: n.tags,
type: "book" type: "book"
})); }));
}
const totalPages = Math.ceil(json.total / limit); async getMetadata(id) {
const hasNextPage = page < totalPages; const html = await fetch(`${this.baseUrl}/story/${id}`).then(r => r.text());
const $ = this.cheerio.load(html);
const script = $('script')
.map((_, el) => $(el).html())
.get()
.find(t => t?.includes('window.__remixContext'));
if (!script) return null;
const jsonText = script.match(/window\.__remixContext\s*=\s*({[\s\S]*?});/)?.[1];
if (!jsonText) return null;
let ctx;
try {
ctx = JSON.parse(jsonText);
} catch {
return null;
}
const route = ctx?.state?.loaderData?.["routes/story.$storyid"];
const story = route?.story;
const meta = route?.meta;
if (!story) return null;
return { return {
results, id: story.id,
hasNextPage, title: story.title,
page format: "Novel",
score: story.voteCount ?? null,
genres: story.tags || [],
status: story.completed ? "Completed" : "Ongoing",
published: story.createDate?.split("T")[0] || "???",
summary: story.description || meta?.description || "",
chapters: story.numParts || story.parts?.length || 1,
image: story.cover || meta?.image || "",
language: story.language?.name?.toLowerCase() || "unknown",
}; };
} }
async findChapters(bookId) { async findChapters(bookId) {
const res = await this.fetch(`${this.baseUrl}/story/${bookId}`); const html = await fetch(`${this.baseUrl}/story/${bookId}`).then(r => r.text());
const html = await res.text();
const $ = this.cheerio.load(html); const $ = this.cheerio.load(html);
const chapters = []; const script = $('script')
.map((_, el) => $(el).html())
.get()
.find(t => t?.includes('window.__remixContext'));
$('div.Y26Ib ul[aria-label="story-parts"] li a').each((i, el) => { if (!script) return [];
const href = $(el).attr("href") || "";
const match = href.match(/wattpad\.com\/(\d+)/);
const id = match ? match[1] : null;
const titleText = $(el).find('div.wpYp-').text().trim(); const jsonText = script.match(/window\.__remixContext\s*=\s*({[\s\S]*?});/)?.[1];
if (!jsonText) return [];
let chapterNumber = i + 1; let ctx;
let title = titleText; try {
ctx = JSON.parse(jsonText);
} catch {
return [];
}
const match2 = titleText.match(/^(\d+)\s*-\s*(.*)$/); const story = ctx?.state?.loaderData?.["routes/story.$storyid"]?.story;
if (match2) { if (!story?.parts) return [];
chapterNumber = parseInt(match[1], 10);
title = match2[2].trim();
}
chapters.push({ return story.parts.map((p, i) => ({
id: id, id: String(p.id),
title, title: p.title || `Chapter ${i + 1}`,
chapter: chapterNumber, number: i + 1,
language: "en" language: story.language?.name?.toLowerCase() || "en",
}); index: i
}); }));
return { chapters };
} }
async findChapterPages(chapterId) { async findChapterPages(chapterId) {
const ampUrl = `https://www.wattpad.com/amp/${chapterId}`; const html = await fetch(`https://www.wattpad.com/amp/${chapterId}`).then(r => r.text());
const res = await this.fetch(ampUrl);
const html = await res.text();
const $ = this.cheerio.load(html); const $ = this.cheerio.load(html);
const title = $("#amp-reading h2").first().text().trim(); const title = $('h2').first().text().trim();
const titleHtml = title ? `<h1>${title}</h1>\n\n` : "";
const paragraphsHtml = []; const container = $('.story-body-type');
if (!container.length) return "";
$(".story-body-type p").each((i, el) => { container.find('[data-media-type="image"]').remove();
const p = $(el);
if (p.attr("data-media-type") !== "image") { const parts = [];
let h = p.html() || "";
h = h
.replace(/<br\s*\/?>/gi, "<br>")
.replace(/\u00A0/g, " ")
.replace(/[ \t]+/g, " ")
.trim();
if (h.length > 0) { container.find('p').each((_, el) => {
paragraphsHtml.push(`<p>${h}</p>`); const text = $(el)
} .html()
return; .replace(/\u00A0/g, " ")
} .replace(/[ \t]+/g, " ")
.trim();
const ampImg = p.find("amp-img").first(); if (text) parts.push(`<p>${text}</p>`);
if (ampImg.length) {
const src = ampImg.attr("src") || "";
const width = ampImg.attr("width") || "";
const height = ampImg.attr("height") || "";
if (src) {
paragraphsHtml.push(
`<img src="${src}" width="${width}" height="${height}">`
);
}
}
}); });
const cleanHTML = titleHtml + paragraphsHtml.join("\n\n");
return [ container.find('amp-img').each((_, el) => {
{ const src = $(el).attr('src');
type: "text", const w = $(el).attr('width');
content: cleanHTML.trim(), const h = $(el).attr('height');
index: 0 if (src) parts.push(`<img src="${src}" width="${w}" height="${h}">`);
} });
];
return (
(title ? `<h1>${title}</h1>\n\n` : "") +
parts.join("\n\n")
).trim();
} }
} }
module.exports = { novelupdates: wattpad }; module.exports = wattpad;