const KLMANGA_ORIGIN = "https://klmanga.mom"; const RAWKUMA_ORIGIN = "https://rawkuma.net"; const DEFAULT_CACHE_TTL_MS = 30 * 60 * 1000; const DEFAULT_CONCURRENCY = 8; const DEFAULT_FETCH_TIMEOUT_MS = 5000; const DEFAULT_RAWKUMA_NONCE_TTL_MS = 10 * 60 * 1000; const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0"; const MIN_MATCH_SCORE = 0.75; const MIN_MATCH_MARGIN = 0.1; const nativeChapterCache = new Map(); const nativeChapterInFlight = new Map(); const rawkumaNonceCache = { value: null, expiresAt: 0, promise: null, }; const cacheTtlMs = (env) => { const milliseconds = Number.parseInt(env.RAWKUMA_CACHE_TTL_MS || "", 10); return Number.isFinite(milliseconds) && milliseconds > 0 ? milliseconds : DEFAULT_CACHE_TTL_MS; }; const concurrencyLimit = (env) => { const concurrency = Number.parseInt(env.RAWKUMA_CONCURRENCY || "", 10); return Number.isFinite(concurrency) && concurrency > 0 ? concurrency : DEFAULT_CONCURRENCY; }; const fetchTimeoutMs = (env) => { const milliseconds = Number.parseInt(env.RAWKUMA_FETCH_TIMEOUT_MS || "", 10); return Number.isFinite(milliseconds) && milliseconds > 0 ? milliseconds : DEFAULT_FETCH_TIMEOUT_MS; }; const rawkumaNonceTtlMs = (env) => { const milliseconds = Number.parseInt(env.RAWKUMA_NONCE_TTL_MS || "", 10); return Number.isFinite(milliseconds) && milliseconds > 0 ? milliseconds : DEFAULT_RAWKUMA_NONCE_TTL_MS; }; const getCachedChapterCount = (title) => { const cached = nativeChapterCache.get(title); if (!cached) return undefined; if (Date.now() >= cached.expiresAt) { nativeChapterCache.delete(title); return undefined; } return cached.chapter; }; const setCachedChapterCount = (env, title, chapter) => { if (chapter === null) return; nativeChapterCache.set(title, { chapter, expiresAt: Date.now() + cacheTtlMs(env), }); }; const fetchText = async (env, requestHeaders, url, init = {}) => { const headers = new Headers(requestHeaders); const targetUrl = new URL(url); const initHeaders = new Headers(init.headers); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs(env)); for (const [key, value] of initHeaders.entries()) headers.set(key, value); headers.set( "Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", ); headers.set("Accept-Encoding", "identity"); headers.set("Origin", targetUrl.origin); headers.set("Referer", `${targetUrl.origin}/`); if (!headers.has("User-Agent")) headers.set("User-Agent", DEFAULT_USER_AGENT); headers.delete("Content-Length"); try { return await ( await fetch(url, { ...init, headers, signal: init.signal || controller.signal, }) ).text(); } finally { clearTimeout(timeout); } }; const decodeHtml = (value) => value .replaceAll("&", "&") .replaceAll("&", "&") .replaceAll(""", '"') .replaceAll("'", "'") .replaceAll("'", "'") .replaceAll("<", "<") .replaceAll(">", ">"); const normalizeTitle = (value) => String(value || "") .toLowerCase() .normalize("NFKC") .replace(/&/g, " and ") .replace(/[^\p{L}\p{N}]+/gu, " ") .replace(/\s+/g, " ") .trim(); const tokenizeTitle = (value) => normalizeTitle(value) .split(" ") .filter((token) => token.length > 1); const compareTitles = (left, right) => { const normalizedLeft = normalizeTitle(left); const normalizedRight = normalizeTitle(right); if (!normalizedLeft || !normalizedRight) return 0; if (normalizedLeft === normalizedRight) return 1; if ( normalizedLeft.includes(normalizedRight) || normalizedRight.includes(normalizedLeft) ) return 0.92; const leftTokens = tokenizeTitle(left); const rightTokens = tokenizeTitle(right); if (!leftTokens.length || !rightTokens.length) return 0; const overlappingTokenCount = leftTokens.filter((token) => rightTokens.includes(token), ).length; return ( overlappingTokenCount / Math.max(leftTokens.length, rightTokens.length) ); }; const titleCandidates = (entry) => [entry.nativeTitle, entry.englishTitle, entry.romajiTitle] .filter(Boolean) .map((title) => String(title).trim()) .filter((title) => title && title !== "null") .filter((title, index, array) => array.indexOf(title) === index); const pickBestSearchResult = (results, entry) => { const candidates = titleCandidates(entry); let best = null; let secondBestScore = 0; for (const result of results) { const score = candidates.reduce( (maximumScore, candidate) => Math.max(maximumScore, compareTitles(candidate, result.title)), 0, ); if (!best || score > best.score) { secondBestScore = best?.score || 0; best = { ...result, score }; continue; } if (score > secondBestScore) secondBestScore = score; } if (!best) return null; if (best.score < MIN_MATCH_SCORE) return null; if (best.score - secondBestScore < MIN_MATCH_MARGIN) return null; return best; }; const parseRawkumaNonce = (text) => text.match(/name=['"]search_nonce['"]\s+value=['"]([^'"]+)['"]/i)?.[1] || null; const parseRawkumaSearchResults = (text) => [ ...text.matchAll( /]+href=["'](https:\/\/rawkuma\.net\/manga\/[^"']+)["'][^>]*>[\s\S]*?]*>([\s\S]*?)<\/h3>/gi, ), ].map((match) => ({ url: decodeHtml(match[1]).trim(), title: decodeHtml(match[2]) .replace(/<[^>]+>/g, "") .trim(), })); const parseRawkumaChapterNumbers = (text) => [ ...text.matchAll(/data-chapter-number=["'](\d+(?:\.\d+)?)["']/gi), ...text.matchAll( /]+href=["'][^"']*\/chapter-[^"']*["'][^>]*>\s*Chapter\s+(\d+(?:\.\d+)?)\s*<\/a>/gi, ), ] .map((match) => Number.parseFloat(match[1])) .filter((value) => Number.isFinite(value)) .sort((left, right) => right - left); const parseRawkumaChapterListUrl = (text) => decodeHtml( text.match( /]+id=["']chapter-list["'][^>]+hx-get=["']([^"']+)["']/i, )?.[1] || "", ).trim() || null; const getRawkumaNonce = async (env, requestHeaders) => { if (rawkumaNonceCache.value && Date.now() < rawkumaNonceCache.expiresAt) return rawkumaNonceCache.value; if (rawkumaNonceCache.promise) return rawkumaNonceCache.promise; const promise = fetchText( env, requestHeaders, `${RAWKUMA_ORIGIN}/wp-admin/admin-ajax.php?type=search_form&action=get_nonce`, ) .then((nonceText) => parseRawkumaNonce(nonceText)) .then((nonce) => { if (!nonce) return null; rawkumaNonceCache.value = nonce; rawkumaNonceCache.expiresAt = Date.now() + rawkumaNonceTtlMs(env); return nonce; }) .finally(() => { rawkumaNonceCache.promise = null; }); rawkumaNonceCache.promise = promise; return promise; }; const fetchRawkumaChapterCount = async (env, requestHeaders, entry) => { const nonce = await getRawkumaNonce(env, requestHeaders); if (!nonce) return null; for (const candidate of titleCandidates(entry)) { const searchText = await fetchText( env, requestHeaders, `${RAWKUMA_ORIGIN}/wp-admin/admin-ajax.php?nonce=${encodeURIComponent( nonce, )}&action=search`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", }, body: new URLSearchParams({ query: candidate, }), }, ); const bestMatch = pickBestSearchResult( parseRawkumaSearchResults(searchText), entry, ); if (!bestMatch) continue; const mangaText = await fetchText(env, requestHeaders, bestMatch.url); const chapterListUrl = parseRawkumaChapterListUrl(mangaText); const chapterListText = chapterListUrl ? await fetchText(env, requestHeaders, chapterListUrl) : mangaText; const chapters = parseRawkumaChapterNumbers(chapterListText); if (!chapters.length) continue; return chapters[0] ?? null; } return null; }; const parseKlmangaSearchResults = (text) => [ ...text.matchAll( /]+href=["'](https:\/\/klmanga\.mom\/manga-raw\/[^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, ), ] .map((match) => ({ url: decodeHtml(match[1]).trim(), title: decodeHtml(match[2]) .replace(/<[^>]+>/g, "") .replace(/\(Raw\s*-\s*Free\)/gi, "") .trim(), })) .filter((result) => result.title.length > 0); const parseKlmangaChapterNumbers = (text) => [...text.matchAll(/第(\d+(?:\.\d+)?)話/gu)] .map((match) => Number.parseFloat(match[1])) .filter((value) => Number.isFinite(value)) .sort((left, right) => right - left); const fetchKlmangaChapterCount = async (env, requestHeaders, entry) => { for (const candidate of titleCandidates(entry)) { const searchText = await fetchText( env, requestHeaders, `${KLMANGA_ORIGIN}/?s=${encodeURIComponent(candidate)}`, ); const bestMatch = pickBestSearchResult( parseKlmangaSearchResults(searchText), entry, ); if (!bestMatch) continue; const mangaText = await fetchText(env, requestHeaders, bestMatch.url); const chapters = parseKlmangaChapterNumbers(mangaText); if (!chapters.length) continue; return chapters[0] ?? null; } return null; }; const fetchNativeChapterCountUncached = async (env, requestHeaders, entry) => { const providers = [fetchKlmangaChapterCount, fetchRawkumaChapterCount]; for (const provider of providers) { const chapter = await provider(env, requestHeaders, entry).catch( () => null, ); if (chapter !== null) return chapter; } return null; }; const fetchNativeChapterCount = async (env, requestHeaders, entry) => { const normalizedTitle = entry.nativeTitle?.trim(); if (!normalizedTitle) return null; const cachedChapter = getCachedChapterCount(normalizedTitle); if (cachedChapter !== undefined) return cachedChapter; const existing = nativeChapterInFlight.get(normalizedTitle); if (existing) return existing; const promise = fetchNativeChapterCountUncached(env, requestHeaders, entry) .catch(() => null) .then((chapter) => { setCachedChapterCount(env, normalizedTitle, chapter); return chapter; }) .finally(() => { nativeChapterInFlight.delete(normalizedTitle); }); nativeChapterInFlight.set(normalizedTitle, promise); return promise; }; export const fetchRawkumaChapterCounts = async (env, requestHeaders, manga) => { const results = {}; const entries = [...manga]; const workerCount = Math.min(concurrencyLimit(env), entries.length); if (!workerCount) return results; let nextIndex = 0; await Promise.all( Array.from({ length: workerCount }, async () => { while (nextIndex < entries.length) { const currentIndex = nextIndex; nextIndex += 1; const entry = entries[currentIndex]; const chapter = await fetchNativeChapterCount( env, requestHeaders, entry, ); results[String(entry.anilistId)] = { chapter }; } }), ); return results; };