updates and new extensions

This commit is contained in:
2026-01-13 17:26:06 +01:00
parent 83c51a82da
commit e8d64174fd
15 changed files with 3516 additions and 468 deletions

View File

@@ -1,143 +1,267 @@
class wattpad {
class Wattpad {
constructor() {
this.baseUrl = "https://wattpad.com";
this.baseUrl = "https://www.wattpad.com";
this.apiUrl = "https://www.wattpad.com/v4";
this.type = "book-board";
this.mediaType = "ln";
this.version = "1.0"
this.mediaType = "ln"; // Light Novel
this.version = "1.1";
}
async search(queryObj) {
const query = queryObj.query?.trim() || "";
getFilters() {
return {
sort: {
label: "Sort By (Only for Explore)",
type: "select",
options: [
{ value: "hot", label: "Hot (Trending)" },
{ value: "new", label: "New (Latest)" },
{ value: "paid", label: "Paid Stories" }
],
default: "hot"
},
updated: {
label: "Last Updated (Search Only)",
type: "select",
options: [
{ value: "", label: "Any time" },
{ value: "24", label: "Today" },
{ value: "168", label: "This Week" },
{ value: "720", label: "This Month" },
{ value: "8760", label: "This Year" }
],
default: ""
},
content: {
label: "Content Filters",
type: "multiselect",
options: [
{ value: "completed", label: "Completed stories only" },
{ value: "paid", label: "Paid stories only (Hide free)" },
{ value: "free", label: "Free stories only (Hide Paid)" },
{ value: "mature", label: "Include mature content" }
]
},
tags: {
label: "Tags (comma separated)",
type: "text",
placeholder: "e.g. romance, vampire, magic"
}
};
}
get headers() {
return {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept-Language": "en-US,en;q=0.9",
"Referer": "https://www.wattpad.com/"
};
}
async search({ query, page = 1, filters }) {
const limit = 15;
const offset = 0;
const offset = (page - 1) * limit;
const url =
`${this.baseUrl}/v4/search/stories?` +
`query=${encodeURIComponent(query)}` +
`&limit=${limit}&offset=${offset}&mature=false`;
// 1. Preparar la Query (Texto + Etiquetas)
let finalQuery = (query || "").trim();
if (filters?.tags) {
const tags = String(filters.tags).split(",");
tags.forEach(t => {
const tag = t.trim();
if (tag) {
// Wattpad requiere que los tags en la búsqueda lleven #
finalQuery += ` #${tag.replace(/^#/, '')}`;
}
});
}
finalQuery = finalQuery.trim();
const json = await fetch(url).then(r => r.json());
let url;
return json.stories.map(n => ({
id: n.id,
title: n.title,
image: n.cover,
sampleImageUrl: n.cover,
tags: n.tags,
type: "book"
}));
// 2. DECISIÓN: ¿Búsqueda o Exploración?
if (finalQuery) {
// CASO A: HAY BÚSQUEDA (Texto o Tags) -> Usar API de Search
url = new URL(`${this.apiUrl}/search/stories/`);
url.searchParams.set("query", finalQuery);
// Filtros exclusivos de búsqueda
if (filters?.updated) {
url.searchParams.set("updateYoungerThan", filters.updated);
}
} else {
// CASO B: NO HAY BÚSQUEDA (Default) -> Usar API de Stories (Explorar)
// Esto cargará "Tendencias" o "Nuevos" cuando entres sin escribir nada.
url = new URL(`${this.apiUrl}/stories/`);
// Mapear el filtro 'sort' al parámetro 'filter' de la API
// Por defecto usamos 'hot' (Tendencias)
const filterVal = filters?.sort || "hot";
url.searchParams.set("filter", filterVal);
}
// 3. Filtros Comunes (Content)
let isMature = false;
if (filters?.content) {
const contentOpts = Array.isArray(filters.content)
? filters.content
: String(filters.content).split(',');
if (contentOpts.includes("completed")) url.searchParams.set("completed", "1");
if (contentOpts.includes("paid")) url.searchParams.set("paid", "1");
if (contentOpts.includes("free")) url.searchParams.set("paid", "0");
if (contentOpts.includes("mature")) isMature = true;
}
// Wattpad requiere mature=1 explícito para mostrar contenido adulto
url.searchParams.set("mature", isMature ? "1" : "0");
// 4. Parámetros Técnicos
url.searchParams.set("limit", limit.toString());
url.searchParams.set("offset", offset.toString());
// Solicitar campos específicos para obtener portadas, autor y estado
const fields = "stories(id,title,voteCount,readCount,commentCount,description,mature,completed,cover,url,numParts,isPaywalled,paidModel,length,language(id),user(name),lastPublishedPart(createDate),promoted,sponsor(name,avatar),tags),total,nextUrl";
url.searchParams.set("fields", fields);
try {
const res = await fetch(url.toString(), { headers: this.headers });
if (!res.ok) {
console.error(`Wattpad API Error: ${res.status}`);
return [];
}
const json = await res.json();
if (!json.stories) return [];
return json.stories.map(story => ({
id: story.id,
title: story.title,
image: story.cover,
type: "book",
// Datos extra para la UI
author: story.user?.name,
status: story.completed ? "Completed" : "Ongoing",
chapters: story.numParts,
rating: story.voteCount // Opcional: usar votos como rating
}));
} catch (e) {
console.error("Wattpad search failed:", e);
return [];
}
}
async getMetadata(id) {
const html = await fetch(`${this.baseUrl}/story/${id}`).then(r => r.text());
// Obtenemos metadatos básicos de la web para no depender solo de la API
const res = await fetch(`${this.baseUrl}/story/${id}`, { headers: this.headers });
const html = await res.text();
const $ = this.cheerio.load(html);
// Intentar extraer datos del script de hidratación (más fiable)
const script = $('script')
.map((_, el) => $(el).html())
.get()
.find(t => t?.includes('window.__remixContext'));
if (!script) return null;
if (script) {
const jsonText = script.match(/window\.__remixContext\s*=\s*({[\s\S]*?});/)?.[1];
if (jsonText) {
try {
const ctx = JSON.parse(jsonText);
const route = ctx?.state?.loaderData?.["routes/story.$storyid"];
const story = route?.story;
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;
if (story) {
return {
id: story.id,
title: story.title,
format: "Novel",
score: story.voteCount ?? 0,
genres: story.tags || [],
status: story.completed ? "Completed" : "Ongoing",
published: story.createDate ? story.createDate.split("T")[0] : "???",
summary: story.description || "",
chapters: story.numParts || 0,
image: story.cover || "",
author: story.user?.name || ""
};
}
} catch (e) {}
}
}
const route = ctx?.state?.loaderData?.["routes/story.$storyid"];
const story = route?.story;
const meta = route?.meta;
if (!story) return null;
// Fallback clásico
const title = $('h1').first().text().trim();
const image = $('.story-cover img').attr('src');
const summary = $('.description').text().trim();
return {
id: story.id,
title: story.title,
id,
title: title || "Unknown",
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",
image: image || "",
summary: summary || "",
chapters: 0
};
}
async findChapters(bookId) {
const html = await fetch(`${this.baseUrl}/story/${bookId}`).then(r => r.text());
const $ = this.cheerio.load(html);
const res = await fetch(`${this.baseUrl}/story/${bookId}`, { headers: this.headers });
const html = await res.text();
const script = $('script')
.map((_, el) => $(el).html())
.get()
.find(t => t?.includes('window.__remixContext'));
// Extraer estructura de capítulos del JSON
const match = html.match(/window\.__remixContext\s*=\s*({[\s\S]*?});/);
if (!match?.[1]) return [];
if (!script) return [];
const jsonText = script.match(/window\.__remixContext\s*=\s*({[\s\S]*?});/)?.[1];
if (!jsonText) return [];
let ctx;
try {
ctx = JSON.parse(jsonText);
const ctx = JSON.parse(match[1]);
const story = ctx?.state?.loaderData?.["routes/story.$storyid"]?.story;
if (!story?.parts) return [];
return story.parts.map((part, i) => ({
id: String(part.id),
title: part.title || `Chapter ${i + 1}`,
number: i + 1,
index: i,
url: part.url
}));
} 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());
// Usamos la versión AMP para obtener el contenido limpio en una sola petición
const url = `${this.baseUrl}/amp/${chapterId}`;
const res = await fetch(url, { headers: this.headers });
const html = await res.text();
const $ = this.cheerio.load(html);
const title = $('h2').first().text().trim();
const container = $('.story-body-type');
if (!container.length) return "";
if (!container.length) return "Content not available or paid story.";
// Limpieza de elementos basura
container.find('[data-media-type="image"]').remove();
const parts = [];
let content = "";
container.find('p').each((_, el) => {
const text = $(el)
.html()
.replace(/\u00A0/g, " ")
.replace(/[ \t]+/g, " ")
.trim();
if (title) content += `<h1>${title}</h1><br>`;
if (text) parts.push(`<p>${text}</p>`);
container.contents().each((_, el) => {
if (el.tagName === 'p') {
const text = $(el).text().trim();
if (text) content += `<p>${text}</p>`;
} else if (el.tagName === 'amp-img') {
const src = $(el).attr('src');
if (src) content += `<img src="${src}" style="max-width:100%; display:block; margin: 10px auto;">`;
}
});
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();
return content;
}
}
module.exports = wattpad;
module.exports = Wattpad;