added manual matching on books

This commit is contained in:
2026-01-02 16:54:40 +01:00
parent f5cfa29b64
commit 59fdadd288
18 changed files with 1235 additions and 494 deletions

View File

@@ -88,9 +88,10 @@ export async function getChapters(req: any, reply: FastifyReply) {
const { id } = req.params;
const source = req.query.source || 'anilist';
const provider = req.query.provider;
const extensionBookId = req.query.extensionBookId;
const isExternal = source !== 'anilist';
return await booksService.getChaptersForBook(id, isExternal, provider);
return await booksService.getChaptersForBook(id, isExternal, provider, extensionBookId);
} catch (err) {
console.error(err);
return { chapters: [] };

View File

@@ -326,7 +326,8 @@ export async function searchBooksInExtension(ext: Extension | null, name: string
averageScore: m.rating || m.score || null,
format: m.format,
seasonYear: null,
isExtensionResult: true
isExtensionResult: true,
url: m.url,
}));
}
@@ -361,47 +362,60 @@ async function fetchBookMetadata(id: string): Promise<Book | null> {
}
}
async function searchChaptersInExtension(ext: Extension, name: string, searchTitle: string, search: boolean, origin: string): Promise<ChapterWithProvider[]> {
const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${searchTitle}`;
const cached = await getCache(cacheKey);
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 (cached) {
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
if (!isExpired) {
console.log(`[${name}] Chapters cache hit for: ${searchTitle}`);
try {
return JSON.parse(cached.result) as ChapterWithProvider[];
} catch (e) {
console.error(`[${name}] Error parsing cached chapters:`, e);
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);
}
}
} else {
console.log(`[${name}] Chapters cache expired for: ${searchTitle}`);
}
}
try {
console.log(`[${name}] Searching chapters for: ${searchTitle}`);
console.log(`[${name}] Searching chapters for: ${lookupId}`);
let mediaId: string;
if (search) {
const matches = await ext.search!({
query: searchTitle,
query: lookupId,
media: {
romajiTitle: searchTitle,
englishTitle: searchTitle,
romajiTitle: lookupId,
englishTitle: lookupId,
startDate: { year: 0, month: 0, day: 0 }
}
});
const best = matches?.[0];
if (!matches?.length) return [];
if (!best) { return [] }
const nq = normalize(lookupId);
mediaId = best.id;
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(searchTitle);
const match = await ext.getMetadata(lookupId);
mediaId = match.id;
}
@@ -432,7 +446,7 @@ async function searchChaptersInExtension(ext: Extension, name: string, searchTit
}
}
export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string): Promise<{ chapters: ChapterWithProvider[] }> {
export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string, extensionBookId?: string): Promise<{ chapters: ChapterWithProvider[] }> {
let bookData: Book | null = null;
let searchTitle: string = "";
@@ -462,11 +476,30 @@ export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?
for (const [name, ext] of bookExtensions) {
if (onlyProvider && name !== onlyProvider) continue;
if (name == extension) {
const chapters = await searchChaptersInExtension(ext, name, id, false, exts);
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, true, exts);
const chapters = await searchChaptersInExtension(
ext,
name,
searchTitle,
id, // cache con id normal
true,
exts
);
allChapters.push(...chapters);
}
}
@@ -548,4 +581,47 @@ export async function getChapterContent(bookId: string, chapterId: string, provi
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(/&#39;/g, "'") // decodificar entidades HTML
.replace(/[^\w\s]/g, ' ') // convertir puntuación a espacios
.replace(/\s+/g, ' ') // normalizar espacios
.trim();
}