updates and new extensions
This commit is contained in:
308
book/wattpad.js
308
book/wattpad.js
@@ -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;
|
||||
Reference in New Issue
Block a user