627 lines
19 KiB
TypeScript
627 lines
19 KiB
TypeScript
import { getCachedExtension, cacheExtension, getCache, setCache, getExtensionTitle } from '../../shared/queries';
|
|
import { queryOne, queryAll, run } from '../../shared/database';
|
|
import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions';
|
|
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
|
|
|
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
const ANILIST_URL = "https://graphql.anilist.co";
|
|
|
|
async function fetchAniList(query: string, variables: any) {
|
|
const res = await fetch(ANILIST_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json"
|
|
},
|
|
body: JSON.stringify({ query, variables })
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`AniList error ${res.status}`);
|
|
}
|
|
|
|
const json = await res.json();
|
|
return json?.data;
|
|
}
|
|
const MEDIA_FIELDS = `
|
|
id
|
|
title {
|
|
romaji
|
|
english
|
|
native
|
|
userPreferred
|
|
}
|
|
type
|
|
format
|
|
status
|
|
description
|
|
startDate { year month day }
|
|
endDate { year month day }
|
|
season
|
|
seasonYear
|
|
episodes
|
|
chapters
|
|
volumes
|
|
duration
|
|
genres
|
|
synonyms
|
|
averageScore
|
|
popularity
|
|
favourites
|
|
isAdult
|
|
siteUrl
|
|
coverImage {
|
|
extraLarge
|
|
large
|
|
medium
|
|
color
|
|
}
|
|
bannerImage
|
|
updatedAt
|
|
`;
|
|
|
|
export async function getBookById(id: string | number): Promise<Book | { error: string }> {
|
|
const row = await queryOne(
|
|
"SELECT full_data FROM books WHERE id = ?",
|
|
[id]
|
|
);
|
|
|
|
if (row) {
|
|
const parsed = JSON.parse(row.full_data);
|
|
|
|
const hasRelationImages =
|
|
parsed?.relations?.edges?.[0]?.node?.coverImage?.large;
|
|
|
|
const hasCharacterImages =
|
|
parsed?.characters?.nodes?.[0]?.image?.large;
|
|
|
|
if (hasRelationImages && hasCharacterImages) {
|
|
return parsed;
|
|
}
|
|
|
|
console.log(`[Book] Cache outdated for ID ${id}, refetching...`);
|
|
}
|
|
|
|
try {
|
|
console.log(`[Book] Local miss for ID ${id}, fetching live...`);
|
|
|
|
const query = `
|
|
query ($id: Int) {
|
|
Media(id: $id, type: MANGA) {
|
|
id idMal title { romaji english native userPreferred } type format status description
|
|
startDate { year month day } endDate { year month day } season seasonYear seasonInt
|
|
episodes duration chapters volumes countryOfOrigin isLicensed source hashtag
|
|
trailer { id site thumbnail } updatedAt coverImage { extraLarge large medium color }
|
|
bannerImage genres synonyms averageScore meanScore popularity isLocked trending favourites
|
|
tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult userId }
|
|
relations { edges { relationType node { id title { romaji } coverImage { large medium } } } }
|
|
characters(page: 1, perPage: 10) { nodes { id name { full } image { large medium } } }
|
|
studios { nodes { id name isAnimationStudio } }
|
|
isAdult nextAiringEpisode { airingAt timeUntilAiring episode }
|
|
externalLinks { url site }
|
|
rankings { id rank type format year season allTime context }
|
|
}
|
|
}`;
|
|
|
|
const response = await fetch('https://graphql.anilist.co', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
query,
|
|
variables: { id: parseInt(id.toString()) }
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data?.data?.Media) {
|
|
const media = data.data.Media;
|
|
|
|
const insertSql = `
|
|
INSERT INTO books (id, title, updatedAt, full_data)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
updatedAt = EXCLUDED.updatedAt,
|
|
full_data = EXCLUDED.full_data;
|
|
`;
|
|
|
|
await run(insertSql, [
|
|
media.id,
|
|
media.title?.userPreferred || media.title?.romaji || media.title?.english || null,
|
|
media.updatedAt || Math.floor(Date.now() / 1000),
|
|
JSON.stringify(media)
|
|
]);
|
|
|
|
return media;
|
|
}
|
|
} catch (e) {
|
|
console.error("Fetch error:", e);
|
|
}
|
|
|
|
return { error: "Book not found" };
|
|
}
|
|
|
|
export async function refreshTrendingBooks(): Promise<void> {
|
|
const query = `
|
|
query {
|
|
Page(page: 1, perPage: 10) {
|
|
media(type: MANGA, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
|
|
}
|
|
}
|
|
`;
|
|
|
|
const data = await fetchAniList(query, {});
|
|
const list = data?.Page?.media || [];
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
await queryOne("DELETE FROM trending_books");
|
|
|
|
let rank = 1;
|
|
for (const book of list) {
|
|
await queryOne(
|
|
"INSERT INTO trending_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
|
[rank++, book.id, JSON.stringify(book), now]
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function refreshPopularBooks(): Promise<void> {
|
|
const query = `
|
|
query {
|
|
Page(page: 1, perPage: 10) {
|
|
media(type: MANGA, sort: POPULARITY_DESC) { ${MEDIA_FIELDS} }
|
|
}
|
|
}
|
|
`;
|
|
|
|
const data = await fetchAniList(query, {});
|
|
const list = data?.Page?.media || [];
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
await queryOne("DELETE FROM popular_books");
|
|
|
|
let rank = 1;
|
|
for (const book of list) {
|
|
await queryOne(
|
|
"INSERT INTO popular_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)",
|
|
[rank++, book.id, JSON.stringify(book), now]
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function getTrendingBooks(): Promise<Book[]> {
|
|
const rows = await queryAll(
|
|
"SELECT full_data FROM trending_books ORDER BY rank ASC LIMIT 10"
|
|
);
|
|
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
|
}
|
|
|
|
export async function getPopularBooks(): Promise<Book[]> {
|
|
const rows = await queryAll(
|
|
"SELECT full_data FROM popular_books ORDER BY rank ASC LIMIT 10"
|
|
);
|
|
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
|
}
|
|
|
|
export async function searchBooksLocal(query: string): Promise<Book[]> {
|
|
if (!query || query.length < 2) {
|
|
return [];
|
|
}
|
|
|
|
const sql = `SELECT full_data FROM books WHERE full_data LIKE ? LIMIT 50`;
|
|
const rows = await queryAll(sql, [`%${query}%`]);
|
|
|
|
const results: Book[] = rows.map((row: { full_data: string; }) => JSON.parse(row.full_data));
|
|
|
|
const clean = results.filter(book => {
|
|
const searchTerms = [
|
|
book.title.english,
|
|
book.title.romaji,
|
|
book.title.native,
|
|
...(book.synonyms || [])
|
|
].filter(Boolean).map(t => t!.toLowerCase());
|
|
|
|
return searchTerms.some(term => term.includes(query.toLowerCase()));
|
|
});
|
|
|
|
return clean.slice(0, 10);
|
|
}
|
|
|
|
export async function searchBooksAniList(query: string): Promise<Book[]> {
|
|
const gql = `
|
|
query ($search: String) {
|
|
Page(page: 1, perPage: 5) {
|
|
media(search: $search, type: MANGA, isAdult: false) {
|
|
id title { romaji english native }
|
|
coverImage { extraLarge large }
|
|
bannerImage description averageScore format
|
|
seasonYear startDate { year }
|
|
}
|
|
}
|
|
}`;
|
|
|
|
const response = await fetch('https://graphql.anilist.co', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: JSON.stringify({ query: gql, variables: { search: query } })
|
|
});
|
|
|
|
const liveData = await response.json();
|
|
|
|
if (liveData.data && liveData.data.Page.media.length > 0) {
|
|
return liveData.data.Page.media;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
export async function getBookInfoExtension(ext: Extension | null, id: string): Promise<any[]> {
|
|
if (!ext) return [];
|
|
|
|
const extName = ext.constructor.name;
|
|
|
|
const cached = await getCachedExtension(extName, id);
|
|
if (cached) {
|
|
try {
|
|
return JSON.parse(cached.metadata);
|
|
} catch {}
|
|
}
|
|
|
|
if (ext.type === 'book-board' && ext.getMetadata) {
|
|
try {
|
|
const info = await ext.getMetadata(id);
|
|
|
|
if (info) {
|
|
const normalized = {
|
|
id: info.id ?? id,
|
|
title: info.title ?? "",
|
|
format: info.format ?? "",
|
|
score: typeof info.score === "number" ? info.score : null,
|
|
genres: Array.isArray(info.genres) ? info.genres : [],
|
|
status: info.status ?? "",
|
|
published: info.published ?? "",
|
|
summary: info.summary ?? "",
|
|
chapters: Number.isFinite(info.chapters) ? info.chapters : 1,
|
|
image: typeof info.image === "string" ? info.image : ""
|
|
};
|
|
|
|
await cacheExtension(extName, id, normalized.title, normalized);
|
|
return [normalized];
|
|
}
|
|
} catch (e) {
|
|
console.error(`Extension getInfo failed:`, e);
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
export async function searchBooksInExtension(ext: Extension | null, name: string, query: string): Promise<Book[]> {
|
|
if (!ext) return [];
|
|
|
|
if ((ext.type === 'book-board') && ext.search) {
|
|
|
|
try {
|
|
console.log(`[${name}] Searching for book: ${query}`);
|
|
|
|
const matches = await ext.search({
|
|
query: query,
|
|
media: {
|
|
romajiTitle: query,
|
|
englishTitle: query,
|
|
startDate: { year: 0, month: 0, day: 0 }
|
|
}
|
|
});
|
|
|
|
if (matches?.length) {
|
|
return matches.map(m => ({
|
|
id: m.id,
|
|
extensionName: name,
|
|
title: { romaji: m.title, english: m.title, native: null },
|
|
coverImage: { large: m.image || '' },
|
|
averageScore: m.rating || m.score || null,
|
|
format: m.format,
|
|
seasonYear: null,
|
|
isExtensionResult: true,
|
|
url: m.url,
|
|
}));
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error(`Extension search failed for ${name}:`, e);
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
async function fetchBookMetadata(id: string): Promise<Book | null> {
|
|
try {
|
|
const query = `query ($id: Int) {
|
|
Media(id: $id, type: MANGA) {
|
|
title { romaji english }
|
|
startDate { year month day }
|
|
}
|
|
}`;
|
|
|
|
const res = await fetch('https://graphql.anilist.co', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query, variables: { id: parseInt(id) } })
|
|
});
|
|
|
|
const d = await res.json();
|
|
return d.data?.Media || null;
|
|
} catch (e) {
|
|
console.error("Failed to fetch book metadata:", e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function searchChaptersInExtension(ext: Extension, name: string, lookupId: string, cacheId: string, search: boolean, origin: string, disableCache = false): Promise<ChapterWithProvider[]> {
|
|
const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${cacheId}`;
|
|
if (!disableCache) {
|
|
const cached = await getCache(cacheKey);
|
|
|
|
if (cached) {
|
|
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
|
|
|
if (!isExpired) {
|
|
console.log(`[${name}] Chapters cache hit for: ${lookupId}`);
|
|
try {
|
|
return JSON.parse(cached.result) as ChapterWithProvider[];
|
|
} catch (e) {
|
|
console.error(`[${name}] Error parsing cached chapters:`, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
console.log(`[${name}] Searching chapters for: ${lookupId}`);
|
|
|
|
let mediaId: string;
|
|
if (search) {
|
|
const matches = await ext.search!({
|
|
query: lookupId,
|
|
media: {
|
|
romajiTitle: lookupId,
|
|
englishTitle: lookupId,
|
|
startDate: { year: 0, month: 0, day: 0 }
|
|
}
|
|
});
|
|
|
|
if (!matches?.length) return [];
|
|
|
|
const nq = normalize(lookupId);
|
|
|
|
const scored = matches.map(m => {
|
|
const nt = normalize(m.title);
|
|
let score = similarity(nq, nt);
|
|
|
|
if (nt === nq || nt.includes(nq)) score += 0.5;
|
|
|
|
return { m, score };
|
|
});
|
|
|
|
scored.sort((a, b) => b.score - a.score);
|
|
|
|
if (scored[0].score < 0.4) return [];
|
|
|
|
mediaId = scored[0].m.id;
|
|
|
|
} else {
|
|
const match = await ext.getMetadata(lookupId);
|
|
mediaId = match.id;
|
|
}
|
|
|
|
const chaps = await ext.findChapters!(mediaId);
|
|
|
|
if (!chaps?.length){
|
|
return [];
|
|
}
|
|
|
|
console.log(`[${name}] Found ${chaps.length} chapters.`);
|
|
const result: ChapterWithProvider[] = chaps.map((ch) => ({
|
|
id: ch.id,
|
|
number: parseFloat(ch.number.toString()),
|
|
title: ch.title,
|
|
date: ch.releaseDate,
|
|
provider: name,
|
|
index: ch.index,
|
|
language: ch.language ?? null,
|
|
}));
|
|
|
|
await setCache(cacheKey, result, CACHE_TTL_MS);
|
|
return result;
|
|
|
|
} catch (e) {
|
|
const error = e as Error;
|
|
console.error(`Failed to fetch chapters from ${name}:`, error.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string, extensionBookId?: string): Promise<{ chapters: ChapterWithProvider[] }> {
|
|
let bookData: Book | null = null;
|
|
let searchTitle: string = "";
|
|
|
|
if (!ext) {
|
|
const result = await getBookById(id);
|
|
if (!result || "error" in result) return { chapters: [] }
|
|
bookData = result;
|
|
const titles = [bookData.title.english, bookData.title.romaji].filter(Boolean) as string[];
|
|
searchTitle = titles[0];
|
|
}
|
|
const bookExtensions = getBookExtensionsMap();
|
|
|
|
let extension;
|
|
if (!searchTitle) {
|
|
for (const [name, ext] of bookExtensions) {
|
|
const title = await getExtensionTitle(name, id)
|
|
if (title){
|
|
searchTitle = title;
|
|
extension = name;
|
|
}
|
|
}
|
|
}
|
|
|
|
const allChapters: any[] = [];
|
|
let exts = "anilist";
|
|
if (ext) exts = "ext";
|
|
|
|
for (const [name, ext] of bookExtensions) {
|
|
if (onlyProvider && name !== onlyProvider) continue;
|
|
if (extensionBookId && name === onlyProvider) {
|
|
const targetId = extensionBookId ?? id;
|
|
|
|
const chapters = await searchChaptersInExtension(
|
|
ext,
|
|
name,
|
|
targetId, // lookup
|
|
id, // cache siempre con el id normal
|
|
false,
|
|
exts,
|
|
Boolean(extensionBookId)
|
|
);
|
|
|
|
allChapters.push(...chapters);
|
|
} else {
|
|
const chapters = await searchChaptersInExtension(
|
|
ext,
|
|
name,
|
|
searchTitle,
|
|
id, // cache con id normal
|
|
true,
|
|
exts
|
|
);
|
|
|
|
allChapters.push(...chapters);
|
|
}
|
|
}
|
|
|
|
return {
|
|
chapters: allChapters.sort((a, b) => Number(a.number) - Number(b.number))
|
|
};
|
|
}
|
|
|
|
export async function getChapterContent(bookId: string, chapterId: string, providerName: string, source: string, lang: string): Promise<ChapterContent> {
|
|
const extensions = getAllExtensions();
|
|
const ext = extensions.get(providerName);
|
|
|
|
if (!ext) {
|
|
throw new Error("Provider not found");
|
|
}
|
|
|
|
const contentCacheKey = `content:${providerName}:${source}:${lang}:${bookId}:${chapterId}`;
|
|
const cachedContent = await getCache(contentCacheKey);
|
|
|
|
if (cachedContent) {
|
|
const isExpired = Date.now() - cachedContent.created_at > CACHE_TTL_MS;
|
|
|
|
if (!isExpired) {
|
|
console.log(`[${providerName}] Content cache hit for Book ID ${bookId}, Index ${chapterId}`);
|
|
try {
|
|
return JSON.parse(cachedContent.result) as ChapterContent;
|
|
} catch (e) {
|
|
console.error(`[${providerName}] Error parsing cached content:`, e);
|
|
|
|
}
|
|
} else {
|
|
console.log(`[${providerName}] Content cache expired for Book ID ${bookId}, Index ${chapterId}`);
|
|
}
|
|
}
|
|
|
|
const selectedChapter: any = {
|
|
id: chapterId,
|
|
provider: providerName
|
|
};
|
|
|
|
try {
|
|
if (!ext.findChapterPages) {
|
|
throw new Error("Extension doesn't support findChapterPages");
|
|
}
|
|
|
|
let contentResult: ChapterContent;
|
|
|
|
if (ext.mediaType === "manga") {
|
|
// Usamos el ID directamente
|
|
const pages = await ext.findChapterPages(chapterId);
|
|
contentResult = {
|
|
type: "manga",
|
|
chapterId: selectedChapter.id,
|
|
title: selectedChapter.title,
|
|
number: selectedChapter.number,
|
|
provider: providerName,
|
|
pages
|
|
};
|
|
} else if (ext.mediaType === "ln") {
|
|
const content = await ext.findChapterPages(chapterId);
|
|
contentResult = {
|
|
type: "ln",
|
|
chapterId: selectedChapter.id,
|
|
title: selectedChapter.title,
|
|
number: selectedChapter.number,
|
|
provider: providerName,
|
|
content
|
|
};
|
|
} else {
|
|
throw new Error("Unknown mediaType");
|
|
}
|
|
|
|
await setCache(contentCacheKey, contentResult, CACHE_TTL_MS);
|
|
return contentResult;
|
|
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
console.error(`[Chapter] Error loading from ${providerName}:`, error.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function similarity(s1: string, s2: string): number {
|
|
const str1 = normalize(s1);
|
|
const str2 = normalize(s2);
|
|
|
|
const longer = str1.length > str2.length ? str1 : str2;
|
|
const shorter = str1.length > str2.length ? str2 : str1;
|
|
|
|
if (longer.length === 0) return 1.0;
|
|
|
|
const editDistance = levenshteinDistance(longer, shorter);
|
|
return (longer.length - editDistance) / longer.length;
|
|
}
|
|
|
|
function levenshteinDistance(s1: string, s2: string): number {
|
|
const costs: number[] = [];
|
|
for (let i = 0; i <= s1.length; i++) {
|
|
let lastValue = i;
|
|
for (let j = 0; j <= s2.length; j++) {
|
|
if (i === 0) {
|
|
costs[j] = j;
|
|
} else if (j > 0) {
|
|
let newValue = costs[j - 1];
|
|
if (s1.charAt(i - 1) !== s2.charAt(j - 1)) {
|
|
newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1;
|
|
}
|
|
costs[j - 1] = lastValue;
|
|
lastValue = newValue;
|
|
}
|
|
}
|
|
if (i > 0) costs[s2.length] = lastValue;
|
|
}
|
|
return costs[s2.length];
|
|
}
|
|
|
|
function normalize(str: string): string {
|
|
return str
|
|
.toLowerCase()
|
|
.replace(/'/g, "'") // decodificar entidades HTML
|
|
.replace(/[^\w\s]/g, ' ') // convertir puntuación a espacios
|
|
.replace(/\s+/g, ' ') // normalizar espacios
|
|
.trim();
|
|
} |