new format and marketplace

This commit is contained in:
2025-12-19 22:35:35 +01:00
parent 9fe48f93fe
commit 9aea0f1551
19 changed files with 1482 additions and 1327 deletions

104
book/asmhentai.js Normal file
View File

@@ -0,0 +1,104 @@
class asmhentai {
constructor() {
this.baseUrl = "https://asmhentai.com";
this.type = "book-board";
this.mediaType = "manga";
}
async search(queryObj) {
const q = (queryObj.query || "").trim().replace(/\s+/g, "+");
const html = await fetch(`${this.baseUrl}/search/?q=${q}&page=1`).then(r => r.text());
const $ = this.cheerio.load(html);
const results = [];
$(".preview_item").each((_, el) => {
const href = $(el).find(".image a").attr("href");
const id = href?.match(/\/g\/(\d+)\//)?.[1];
if (!id) return;
let img = $(el).find(".image img").attr("data-src") || $(el).find(".image img").attr("src") || "";
if (img.startsWith("//")) img = "https:" + img;
const image = img.replace("thumb.jpg", "1.jpg");
const title = $(el).find("h2.caption").text().trim();
results.push({
id,
image,
title,
rating: null,
type: "book"
});
});
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 {
id,
title: $("h1").first().text().trim(),
format: "MANGA",
score: 0,
genres,
status: "unknown",
published: "???",
summary: "",
chapters: 1,
image
};
}
async findChapters(mangaId) {
const html = await fetch(`${this.baseUrl}/g/${mangaId}/`).then(r => r.text());
const $ = this.cheerio.load(html);
const title = $("h1").first().text().trim() || "Chapter 1";
let thumb = $(".gallery img").first().attr("data-src") || "";
if (thumb.startsWith("//")) thumb = "https:" + thumb;
const base = thumb.match(/https:\/\/[^\/]+\/\d+\/\d+\//)?.[0];
const pages = parseInt($(".pages").text().match(/\d+/)?.[0] || "0");
const ext = thumb.match(/\.(jpg|png|jpeg|gif)/i)?.[1] || "jpg";
const chapterId = Buffer.from(JSON.stringify({ base, pages, ext })).toString("base64");
return [{
id: chapterId,
title,
number: 1,
releaseDate: null,
index: 0
}];
}
async findChapterPages(chapterId) {
const { base, pages, ext } = JSON.parse(
Buffer.from(chapterId, "base64").toString("utf8")
);
return Array.from({ length: pages }, (_, i) => ({
url: `${base}${i + 1}.${ext}`,
index: i
}));
}
}
module.exports = asmhentai;

188
book/lightnovelworld.js Normal file
View File

@@ -0,0 +1,188 @@
class lightnovelworld {
constructor() {
this.baseUrl = "https://lightnovelworld.org/api";
this.type = "book-board";
this.mediaType = "ln";
}
async search(queryObj) {
const query = queryObj.query?.trim() || "";
if (query !== "") {
const res = await fetch(
`${this.baseUrl}/search/?q=${encodeURIComponent(query)}&search_type=title`
);
const data = await res.json();
if (!data.novels) return [];
return data.novels.map(n => ({
id: n.slug,
title: n.title,
image: `https://lightnovelworld.org/${n.cover_path}`,
rating: `Rank ${n.rank}`,
format: "Light Novel"
}));
}
const res = await fetch("https://lightnovelworld.org/");
const html = await res.text();
const cards = html.split('class="recommendation-card"').slice(1);
const results = [];
for (const block of cards) {
const link = block.match(/href="([^"]+)"/)?.[1] || "";
const id = link.replace(/^\/novel\//, "").replace(/\/$/, "");
const title = block.match(/class="card-title"[^>]*>([^<]+)/)?.[1]?.trim() || null;
let img = block.match(/<img[^>]+src="([^"]+)"/)?.[1] || "";
if (img && !img.startsWith("http"))
img = `https://lightnovelworld.org${img}`;
if (id && title) {
results.push({
id,
title,
image: img,
rating: null,
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 {
id: id,
title: data.name || "",
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) {
const chapters = [];
let offset = 0;
const limit = 500;
while (true) {
const res = await fetch(
`https://lightnovelworld.org/api/novel/${bookId}/chapters/?offset=${offset}&limit=${limit}`
);
const data = await res.json();
if (!data.chapters) break;
chapters.push(
...data.chapters.map((c, i) => ({
id: `https://lightnovelworld.org/novel/${bookId}/chapter/${c.number}/`,
title: c.title,
number: Number(c.number),
releaseDate: null,
index: offset + i
}))
);
if (!data.has_more) break;
offset += limit;
}
return chapters;
}
async findChapterPages(chapterId) {
const data = await this.scrape(
chapterId,
(page) => page.evaluate(() => document.documentElement.outerHTML),
{
waitUntil: "domcontentloaded",
timeout: 15000
}
);
const html = data.result;
if (!html) return '<p>Error loading chapter</p>';
const cutPoints = [
'<div class="bottom-nav"',
'<div class="comments-section"',
'<div class="settings-panel"',
'&copy;'
];
let cutIndex = html.length;
for (const marker of cutPoints) {
const pos = html.indexOf(marker);
if (pos !== -1 && pos < cutIndex) cutIndex = pos;
}
const chapterHtml = html.substring(0, cutIndex);
const pMatches = [...chapterHtml.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)];
let cleanHtml = '';
for (const match of pMatches) {
let text = match[1]
.replace(/△▼△▼△▼△/g, '')
.replace(/[※▲▼■◆]/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
text = text
.replace(/&quot;/g, '"')
.replace(/&#x27;/g, "'")
.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 (!text || text.length < 3) continue;
if (/svg|button|modal|comment|loading|default|dyslexic|roboto|lora|line spacing/i.test(text)) continue;
cleanHtml += `<p>${text}</p>\n`;
}
return cleanHtml.trim() || '<p>Empty chapter</p>';
}
}
module.exports = lightnovelworld;

206
book/mangadex.js Normal file
View File

@@ -0,0 +1,206 @@
class MangaDex {
constructor() {
this.baseUrl = "https://mangadex.org";
this.apiUrl = "https://api.mangadex.org";
this.type = "book-board";
this.mediaType = "manga";
}
getHeaders() {
return {
'User-Agent': 'MangaDex-Client-Adapter/1.0',
'Content-Type': 'application/json'
};
}
async search(queryObj) {
const query = queryObj.query?.trim() || "";
const limit = 25;
const offset = (1 - 1) * limit;
const url = `${this.apiUrl}/manga?title=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&includes[]=cover_art&contentRating[]=safe&contentRating[]=suggestive&availableTranslatedLanguage[]=en`;
try {
const response = await fetch(url, { headers: this.getHeaders() });
if (!response.ok) {
console.error(`MangaDex API Error: ${response.statusText}`);
return [];
}
const json = await response.json();
if (!json || !Array.isArray(json.data)) {
return [];
}
return json.data.map(manga => {
const attributes = manga.attributes;
const titleObject = attributes.title || {};
const title = titleObject.en || Object.values(titleObject)[0] || 'Unknown Title';
const coverRelationship = manga.relationships?.find(rel => rel.type === 'cover_art');
const coverFileName = coverRelationship?.attributes?.fileName;
const coverUrl = coverFileName
? `https://uploads.mangadex.org/covers/${manga.id}/${coverFileName}.256.jpg`
: '';
return {
id: manga.id,
image: coverUrl,
title: title,
rating: null,
type: 'book'
};
});
} catch (e) {
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 {
id,
title,
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) {
console.error("MangaDex getMetadata error:", e);
return {
id,
title: "",
format: "Manga",
score: 0,
genres: [],
status: "",
published: "???",
summary: "",
chapters: 0,
image: ""
};
}
}
async findChapters(mangaId) {
if (!mangaId) return [];
const url = `${this.apiUrl}/manga/${mangaId}/feed?translatedLanguage[]=en&order[chapter]=asc&limit=500&includes[]=scanlation_group`;
try {
const response = await fetch(url, { headers: this.getHeaders() });
let chapters = [];
if (response.ok) {
const json = await response.json();
if (json && Array.isArray(json.data)) {
const allChapters = json.data
.filter(ch => ch.attributes.chapter && !ch.attributes.externalUrl)
.map((ch, index) => ({
id: ch.id,
title: ch.attributes.title || `Chapter ${ch.attributes.chapter}`,
number: ch.attributes.chapter,
index: index,
language: ch.attributes.translatedLanguage
}));
const seenChapters = new Set();
allChapters.forEach(ch => {
if (!seenChapters.has(ch.chapter)) {
seenChapters.add(ch.chapter);
chapters.push(ch);
}
});
chapters.sort((a, b) => parseFloat(a.chapter) - parseFloat(b.chapter));
}
}
return chapters;
} catch (e) {
console.error("Error finding MangaDex chapters:", e);
return { chapters: [], cover: null };
}
}
async findChapterPages(chapterId) {
if (!chapterId) return [];
const url = `${this.apiUrl}/at-home/server/${chapterId}`;
try {
const response = await fetch(url, { headers: this.getHeaders() });
if (!response.ok) throw new Error(`Failed to fetch pages: ${response.statusText}`);
const json = await response.json();
if (!json || !json.baseUrl || !json.chapter) return [];
const baseUrl = json.baseUrl;
const chapterHash = json.chapter.hash;
const imageFilenames = json.chapter.data;
return imageFilenames.map((filename, index) => ({
url: `${baseUrl}/data/${chapterHash}/${filename}`,
index: index,
headers: {
'Referer': `https://mangadex.org/chapter/${chapterId}`
}
}));
} catch (e) {
console.error("Error finding MangaDex pages:", e);
return [];
}
}
}
module.exports = MangaDex;

174
book/mangapark.js Normal file
View File

@@ -0,0 +1,174 @@
class mangapark {
constructor() {
this.baseUrl = "https://mangapark.net/apo";
this.type = "book-board";
this.mediaType = "manga";
}
async search(queryObj) {
const query = queryObj.query;
const res = await fetch(`${this.baseUrl}/`, {
method: "POST",
headers: {
"accept": "*/*",
"content-type": "application/json",
"x-apollo-operation-name": "get_searchComic",
"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"
},
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 } } } } } } } }",
variables: {
select: { word: query, size: 10, page: 1, sortby: "field_score" }
}
})
});
const data = await res.json();
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,
image: `https://mangapark.net/${m.data.urlCoverOri}`,
rating: m.data.score_val ? Math.round(m.data.score_val * 10) : null,
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 {
id,
title: comic.name || "",
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) {
const comicId = mangaId
if (!comicId) {
console.error("[MangaPark] Invalid ID format:", mangaId);
return [];
}
const res = await fetch(this.baseUrl + "/", {
method: "POST",
headers: {
"accept": "*/*",
"content-type": "application/json",
"x-apollo-operation-name": "get_comicChapterList",
"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"
},
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",
variables: { comicId }
})
});
const json = await res.json();
if (!json.data || !json.data.get_comicChapterList) return [];
let list = json.data.get_comicChapterList;
list.sort((a, b) => a.data.serial - b.data.serial);
return list.map((c, i) => ({
id: `https://mangapark.net${c.data.urlPath}`,
title: c.data.dname || c.data.title || `Chapter ${c.data.serial}`,
number: Number(c.data.serial),
releaseDate: null,
index: i
}));
}
async findChapterPages(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 scripts = html.match(/<script\b[^>]*>[\s\S]*?<\/script>/gi) || [];
let found = [];
for (const s of scripts) {
const matches = s.match(/https?:\/\/[^"' ]+\.(?:jpg|jpeg|png|webp)/gi);
if (matches) found.push(...matches);
}
const urls = [...new Set(found)];
const clean = urls.filter(u =>
!u.includes("icon") &&
!u.includes("logo") &&
!u.includes("banner") &&
!u.includes("thumb")
);
return clean.map((url, index) => ({
url,
index,
headers: { referer: 'https://mangapark.net' }
}));
}
}
module.exports = mangapark;

181
book/nhentai.js Normal file
View File

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

121
book/novelbin.js Normal file
View File

@@ -0,0 +1,121 @@
class NovelBin {
constructor() {
this.baseUrl = "https://novelbin.me";
this.type = "book-board";
this.mediaType = "ln";
}
async search(queryObj) {
const query = queryObj.query || "";
const url = `${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 html = await res.text();
const $ = this.cheerio.load(html);
const results = [];
$('h3.novel-title a').each((i, el) => {
const href = $(el).attr('href');
const title = $(el).text().trim();
const idMatch = href.match(/novel-book\/([^/?]+)/);
const id = idMatch ? idMatch[1] : null;
const img = `${this.baseUrl}/media/novel/${id}.jpg`;
results.push({
id,
title,
image: img,
rating: null,
type: "book"
});
});
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 {
id,
title,
format: "Light Novel",
score: 0,
genres,
status,
published: "???",
summary,
chapters,
image
};
}
async findChapters(bookId) {
const res = await fetch(`${this.baseUrl}/ajax/chapter-archive?novelId=${bookId}`, {
headers: {
"user-agent": "Mozilla/5.0"
}
});
const html = await res.text();
const $ = this.cheerio.load(html);
const chapters = [];
$('a[title]').each((i, el) => {
const fullUrl = $(el).attr('href');
const title = $(el).attr('title').trim();
const numMatch = title.match(/chapter\s+(\d+(?:\.\d+)?)/i);
chapters.push({
id: fullUrl,
title,
number: numMatch ? numMatch[1] : "0",
releaseDate: null,
index: i
});
});
return chapters;
}
async findChapterPages(chapterUrl) {
const {result} = await this.scrape(chapterUrl, async (page) => {
return page.evaluate(() => {
document.querySelectorAll('div[id^="pf-"]').forEach(e => e.remove());
const ps = Array.from(document.querySelectorAll("p")).map(p => p.outerHTML.trim()).filter(p => p.length > 7);
return ps.join("\n");
});
}, {
waitUntil: "domcontentloaded",
renderWaitTime: 300
});
return result || "<p>Error: chapter text not found</p>";
}
}
module.exports = NovelBin;

137
book/novelfire.js Normal file
View File

@@ -0,0 +1,137 @@
class NovelFire {
constructor() {
this.baseUrl = "https://novelfire.net";
this.type = "book-board";
this.mediaType = "ln";
}
async search(queryObj) {
const query = queryObj.query;
const res = await fetch(
`${this.baseUrl}/ajax/searchLive?inputContent=${encodeURIComponent(query)}`,
{ headers: { "accept": "application/json" } }
);
const data = await res.json();
if (!data.data) return [];
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]);
}
let status = 'unknown';
const statusClass = $('strong.ongoing, strong.completed').attr('class');
if (statusClass) {
status = statusClass.toLowerCase();
}
return {
id,
title,
format: 'Light Novel',
score: 0,
genres,
status,
published: '???',
summary,
chapters,
image
};
}
async findChapters(bookId) {
const url = `https://novelfire.net/book/${bookId}/chapters`;
const html = await (await fetch(url)).text();
const $ = this.cheerio.load(html);
let postId;
$("script").each((_, el) => {
const txt = $(el).html() || "";
const m = txt.match(/listChapterDataAjax\?post_id=(\d+)/);
if (m) postId = m[1];
});
if (!postId) throw new Error("post_id not found");
const params = new URLSearchParams({
post_id: postId,
draw: 1,
"columns[0][data]": "title",
"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 = NovelFire;

142
book/wattpad.js Normal file
View File

@@ -0,0 +1,142 @@
class wattpad {
constructor() {
this.baseUrl = "https://wattpad.com";
this.type = "book-board";
this.mediaType = "ln";
}
async search(queryObj) {
const query = queryObj.query?.trim() || "";
const limit = 15;
const offset = 0;
const url =
`${this.baseUrl}/v4/search/stories?` +
`query=${encodeURIComponent(query)}` +
`&limit=${limit}&offset=${offset}&mature=false`;
const json = await fetch(url).then(r => r.json());
return json.stories.map(n => ({
id: n.id,
title: n.title,
image: n.cover,
sampleImageUrl: n.cover,
tags: n.tags,
type: "book"
}));
}
async getMetadata(id) {
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 {
id: story.id,
title: story.title,
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) {
const html = await fetch(`${this.baseUrl}/story/${bookId}`).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 [];
const jsonText = script.match(/window\.__remixContext\s*=\s*({[\s\S]*?});/)?.[1];
if (!jsonText) return [];
let ctx;
try {
ctx = JSON.parse(jsonText);
} catch {
return [];
}
const story = ctx?.state?.loaderData?.["routes/story.$storyid"]?.story;
if (!story?.parts) return [];
return story.parts.map((p, i) => ({
id: String(p.id),
title: p.title || `Chapter ${i + 1}`,
number: i + 1,
language: story.language?.name?.toLowerCase() || "en",
index: i
}));
}
async findChapterPages(chapterId) {
const html = await fetch(`https://www.wattpad.com/amp/${chapterId}`).then(r => r.text());
const $ = this.cheerio.load(html);
const title = $('h2').first().text().trim();
const container = $('.story-body-type');
if (!container.length) return "";
container.find('[data-media-type="image"]').remove();
const parts = [];
container.find('p').each((_, el) => {
const text = $(el)
.html()
.replace(/\u00A0/g, " ")
.replace(/[ \t]+/g, " ")
.trim();
if (text) parts.push(`<p>${text}</p>`);
});
container.find('amp-img').each((_, el) => {
const src = $(el).attr('src');
const w = $(el).attr('width');
const h = $(el).attr('height');
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 = wattpad;