267 lines
9.7 KiB
JavaScript
267 lines
9.7 KiB
JavaScript
class Wattpad {
|
|
constructor() {
|
|
this.baseUrl = "https://www.wattpad.com";
|
|
this.apiUrl = "https://www.wattpad.com/v4";
|
|
this.type = "book-board";
|
|
this.mediaType = "ln"; // Light Novel
|
|
this.version = "1.1";
|
|
}
|
|
|
|
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 = (page - 1) * limit;
|
|
|
|
// 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();
|
|
|
|
let url;
|
|
|
|
// 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) {
|
|
// 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) {
|
|
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;
|
|
|
|
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) {}
|
|
}
|
|
}
|
|
|
|
// Fallback clásico
|
|
const title = $('h1').first().text().trim();
|
|
const image = $('.story-cover img').attr('src');
|
|
const summary = $('.description').text().trim();
|
|
|
|
return {
|
|
id,
|
|
title: title || "Unknown",
|
|
format: "Novel",
|
|
image: image || "",
|
|
summary: summary || "",
|
|
chapters: 0
|
|
};
|
|
}
|
|
|
|
async findChapters(bookId) {
|
|
const res = await fetch(`${this.baseUrl}/story/${bookId}`, { headers: this.headers });
|
|
const html = await res.text();
|
|
|
|
// Extraer estructura de capítulos del JSON
|
|
const match = html.match(/window\.__remixContext\s*=\s*({[\s\S]*?});/);
|
|
if (!match?.[1]) return [];
|
|
|
|
try {
|
|
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 [];
|
|
}
|
|
}
|
|
|
|
async findChapterPages(chapterId) {
|
|
// 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 "Content not available or paid story.";
|
|
|
|
// Limpieza de elementos basura
|
|
container.find('[data-media-type="image"]').remove();
|
|
|
|
let content = "";
|
|
|
|
if (title) content += `<h1>${title}</h1><br>`;
|
|
|
|
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;">`;
|
|
}
|
|
});
|
|
|
|
return content;
|
|
}
|
|
}
|
|
|
|
module.exports = Wattpad; |