mangaFireService.ts
1 import axios from 'axios'; 2 import { decode } from 'html-entities'; 3 import { MANGA_API_URL } from '@/constants/Config'; 4 import { 5 searchAnilistMangaByName, 6 updateMangaStatus, 7 isLoggedInToAniList, 8 } from '@/services/anilistService'; 9 import { getMangaData, setMangaData } from '@/services/bookmarkService'; 10 import { setLastReadManga } from './readChapterService'; 11 import { performanceMonitor } from '@/utils/performance'; 12 13 export interface MangaItem { 14 id: string; 15 title: string; 16 banner: string; 17 imageUrl: string; 18 link: string; 19 type: string; 20 } 21 22 export interface MangaDetails { 23 title: string; 24 alternativeTitle: string; 25 status: string; 26 description: string; 27 author: string[]; 28 published: string; 29 genres: string[]; 30 rating: string; 31 reviewCount: string; 32 bannerImage: string; 33 chapters: { number: string; title: string; date: string; url: string }[]; 34 } 35 36 const USER_AGENT = 37 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0'; 38 39 const MAX_RETRIES = 3; 40 const RETRY_DELAY = 1000; 41 42 // Utility function for retrying API calls with exponential backoff 43 async function retryApiCall<T>( 44 operation: () => Promise<T>, 45 maxRetries: number = MAX_RETRIES 46 ): Promise<T> { 47 let lastError: Error; 48 49 for (let attempt = 1; attempt <= maxRetries; attempt++) { 50 try { 51 return await operation(); 52 } catch (error) { 53 lastError = error as Error; 54 55 if (attempt === maxRetries) { 56 throw lastError; 57 } 58 59 const delay = RETRY_DELAY * Math.pow(2, attempt - 1); 60 console.log( 61 `API call failed (attempt ${attempt}), retrying in ${delay}ms...` 62 ); 63 await new Promise((resolve) => setTimeout(resolve, delay)); 64 } 65 } 66 67 throw lastError!; 68 } 69 70 // Validate URL before making requests 71 function validateUrl(url: string): boolean { 72 try { 73 new URL(url); 74 return true; 75 } catch { 76 return false; 77 } 78 } 79 80 export const searchManga = async (keyword: string): Promise<MangaItem[]> => { 81 if (!keyword || keyword.trim().length === 0) { 82 throw new Error('Search keyword is required'); 83 } 84 85 return performanceMonitor.measureAsync(`searchManga:${keyword}`, () => 86 retryApiCall(async () => { 87 const searchUrl = `${MANGA_API_URL}/filter?keyword=${encodeURIComponent(keyword.trim())}`; 88 89 if (!validateUrl(searchUrl)) { 90 throw new Error('Invalid search URL'); 91 } 92 93 const response = await axios.get(searchUrl, { 94 headers: { 95 'User-Agent': USER_AGENT, 96 }, 97 timeout: 10000, 98 }); 99 100 if (!response.data || typeof response.data !== 'string') { 101 throw new Error('Invalid response data'); 102 } 103 104 const html = response.data as string; 105 return parseSearchResults(html); 106 }) 107 ); 108 }; 109 110 // Extract search result parsing into separate function 111 function parseSearchResults(html: string): MangaItem[] { 112 const mangaRegex = 113 /<div class="unit item-\d+">.*?<a href="(\/manga\/[^"]+)".*?<img src="([^"]+)".*?<span class="type">([^<]+)<\/span>.*?<a href="\/manga\/[^"]+">([^<]+)<\/a>/gs; 114 const matches = [...html.matchAll(mangaRegex)]; 115 116 return matches 117 .map((match) => { 118 const link = match[1]; 119 const id = link ? link.split('/').pop() || '' : ''; 120 const imageUrl = match[2]; 121 122 // Validate image URL 123 const validImageUrl = validateUrl(imageUrl || '') ? imageUrl : ''; 124 125 return { 126 id, 127 link: `${MANGA_API_URL}${link || ''}`, 128 title: decode(match[4]?.trim() || ''), 129 banner: validImageUrl || '', 130 imageUrl: validImageUrl || '', 131 type: decode(match[3]?.trim() || ''), 132 }; 133 }) 134 .filter((item) => item.id && item.title); // Filter out incomplete results 135 } 136 137 export const fetchMangaDetails = async (id: string): Promise<MangaDetails> => { 138 if (!id || id.trim().length === 0) { 139 throw new Error('Manga ID is required'); 140 } 141 142 return performanceMonitor.measureAsync(`fetchMangaDetails:${id}`, () => 143 retryApiCall(async () => { 144 const detailsUrl = `${MANGA_API_URL}/manga/${id.trim()}`; 145 146 if (!validateUrl(detailsUrl)) { 147 throw new Error('Invalid manga details URL'); 148 } 149 150 const response = await axios.get(detailsUrl, { 151 headers: { 152 'User-Agent': USER_AGENT, 153 }, 154 timeout: 15000, // Longer timeout for details page 155 }); 156 157 if (!response.data || typeof response.data !== 'string') { 158 throw new Error('Invalid response data'); 159 } 160 161 const html = response.data as string; 162 const details = parseMangaDetails(html); 163 return { ...details, id: id.trim() }; 164 }) 165 ); 166 }; 167 168 const parseMangaDetails = (html: string): MangaDetails => { 169 const title = decode( 170 html.match(/<h1 itemprop="name">(.*?)<\/h1>/)?.[1] || 'Unknown Title' 171 ); 172 const alternativeTitle = decode(html.match(/<h6>(.*?)<\/h6>/)?.[1] || ''); 173 const status = html.match(/<p>(.*?)<\/p>/)?.[1] || 'Unknown Status'; 174 175 const descriptionMatch = html.match( 176 /<div class="modal fade" id="synopsis">[\s\S]*?<div class="modal-content p-4">\s*<div class="modal-close"[^>]*>[\s\S]*?<\/div>\s*([\s\S]*?)\s*<\/div>/ 177 ); 178 let description = descriptionMatch?.[1] 179 ? decode(descriptionMatch[1].trim()) || 'No description available' 180 : 'No description available'; 181 182 description = description 183 .replace(/<br\s*\/?>/gi, '\n') 184 .replace(/<p>/gi, '') 185 .replace(/<\/p>/gi, '\n\n') 186 .replace(/<(?:.|\n)*?>/gm, '') 187 .trim(); 188 189 const authorMatch = html.match( 190 /<span>Author:<\/span>.*?<span>(.*?)<\/span>/s 191 ); 192 const authors = authorMatch?.[1] 193 ? authorMatch[1] 194 .match(/<a[^>]*>(.*?)<\/a>/g) 195 ?.map((a) => a.replace(/<[^>]*>/g, '')) || [] 196 : []; 197 198 const published = 199 html.match(/<span>Published:<\/span>.*?<span>(.*?)<\/span>/s)?.[1] || 200 'Unknown'; 201 202 const genresMatch = html.match( 203 /<span>Genres:<\/span>.*?<span>(.*?)<\/span>/s 204 ); 205 const genres = genresMatch?.[1] 206 ? genresMatch[1] 207 .match(/<a[^>]*>(.*?)<\/a>/g) 208 ?.map((a) => a.replace(/<[^>]*>/g, '')) || [] 209 : []; 210 211 const rating = 212 html.match( 213 /<span class="live-score" itemprop="ratingValue">(.*?)<\/span>/ 214 )?.[1] || 'N/A'; 215 const reviewCount = 216 html.match(/<span itemprop="reviewCount".*?>(.*?)<\/span>/)?.[1] || '0'; 217 const bannerImageMatch = html.match( 218 /<div class="poster">.*?<img src="(.*?)" itemprop="image"/s 219 ); 220 const bannerImage = bannerImageMatch ? bannerImageMatch[1] : ''; 221 222 const chaptersRegex = 223 /<li class="item".*?<a href="(.*?)".*?<span>Chapter (\d+):.*?<\/span>.*?<span>(.*?)<\/span>/g; 224 const chapters = []; 225 let match; 226 while ((match = chaptersRegex.exec(html)) !== null) { 227 chapters.push({ 228 url: match[1] || '', 229 number: match[2] || '', 230 title: `Chapter ${match[2] || ''}`, 231 date: match[3] || '', 232 }); 233 } 234 235 return { 236 title, 237 alternativeTitle, 238 status, 239 description, 240 author: authors, 241 published, 242 genres, 243 rating, 244 reviewCount, 245 bannerImage: bannerImage || '', 246 chapters: chapters.filter(ch => ch.number && ch.url && ch.date), 247 }; 248 }; 249 250 export const getChapterUrl = (id: string, chapterNumber: string): string => { 251 return `${MANGA_API_URL}/read/${id}/en/chapter-${chapterNumber}`; 252 }; 253 export const markChapterAsRead = async ( 254 id: string, 255 chapterNumber: string, 256 mangaTitle: string 257 ) => { 258 if (!id || !chapterNumber || !mangaTitle) { 259 console.error('Invalid parameters for markChapterAsRead:', { 260 id, 261 chapterNumber, 262 mangaTitle, 263 }); 264 return; 265 } 266 267 try { 268 console.log('Updating last read manga in mangaFireService:', { 269 id, 270 mangaTitle, 271 chapterNumber, 272 }); 273 await setLastReadManga(id, mangaTitle, chapterNumber); 274 275 const mangaData = await getMangaData(id); 276 if (mangaData) { 277 const updatedReadChapters = Array.from( 278 new Set([...mangaData.readChapters, chapterNumber]) 279 ); 280 const highestChapter = Math.max( 281 ...updatedReadChapters.map((ch) => parseFloat(ch)) 282 ).toString(); 283 await setMangaData({ 284 ...mangaData, 285 readChapters: updatedReadChapters, 286 lastReadChapter: highestChapter, 287 lastUpdated: Date.now(), 288 }); 289 290 console.log( 291 `Marked chapter ${chapterNumber} as read for manga ${id} (${mangaTitle})` 292 ); 293 } else { 294 await setMangaData({ 295 id, 296 title: mangaTitle, 297 bannerImage: '', 298 bookmarkStatus: null, 299 readChapters: [chapterNumber], 300 lastReadChapter: chapterNumber, 301 lastUpdated: Date.now(), 302 }); 303 } 304 } catch (error) { 305 console.error('Error marking chapter as read:', error); 306 } 307 }; 308 309 export const getBookmarkStatus = async (id: string): Promise<string | null> => { 310 try { 311 const mangaData = await getMangaData(id); 312 return mangaData?.bookmarkStatus || null; 313 } catch (error) { 314 console.error('Error getting bookmark status:', error); 315 return null; 316 } 317 }; 318 319 export const updateAniListProgress = async ( 320 id: string, 321 mangaTitle: string, 322 progress: number, 323 bookmarkStatus: string 324 ) => { 325 if (!mangaTitle) { 326 console.error('Manga title is undefined for id:', id); 327 return; 328 } 329 330 try { 331 const isLoggedIn = await isLoggedInToAniList(); 332 if (!isLoggedIn) { 333 console.log('User is not logged in to AniList. Skipping update.'); 334 return; 335 } 336 337 const anilistManga = await searchAnilistMangaByName(mangaTitle); 338 if (anilistManga) { 339 let status: string; 340 switch (bookmarkStatus) { 341 case 'To Read': 342 status = 'PLANNING'; 343 break; 344 case 'Reading': 345 status = 'CURRENT'; 346 break; 347 case 'Read': 348 status = 'COMPLETED'; 349 break; 350 default: 351 status = 'CURRENT'; 352 } 353 await updateMangaStatus(anilistManga.id, status, progress); 354 console.log( 355 `Updated AniList progress for "${mangaTitle}" (${id}) to ${progress} chapters with status ${status}` 356 ); 357 } else { 358 console.log(`Manga "${mangaTitle}" (${id}) not found on AniList`); 359 } 360 } catch (error) { 361 console.error('Error updating AniList progress:', error); 362 } 363 }; 364 365 export const parseNewReleases = (html: string): MangaItem[] => { 366 const homeSwiperRegex = /<section class="home-swiper">([\s\S]*?)<\/section>/g; 367 const homeSwiperMatches = Array.from(html.matchAll(homeSwiperRegex)); 368 369 for (const match of homeSwiperMatches) { 370 const swiperContent = match[1]; 371 372 if (swiperContent && swiperContent.includes('<h2>New Release</h2>')) { 373 const itemRegex = 374 /<div class="swiper-slide unit[^"]*">\s*<a href="\/manga\/([^"]+)">\s*<div class="poster">\s*<div><img src="([^"]+)" alt="([^"]+)"><\/div>\s*<\/div>\s*<span>([^<]+)<\/span>\s*<\/a>\s*<\/div>/g; 375 const matches = Array.from(swiperContent?.matchAll(itemRegex) || []); 376 377 return matches.map((match) => ({ 378 id: match[1] || '', 379 imageUrl: match[2] || '', 380 title: decode(match[4]?.trim() || ''), 381 banner: '', 382 link: `/manga/${match[1] || ''}`, 383 type: 'manga', 384 })); 385 } 386 } 387 388 console.log('Could not find "New Release" section'); 389 return []; 390 }; 391 392 export const parseMostViewedManga = (html: string): MangaItem[] => { 393 const regex = 394 /<div class="swiper-slide unit[^>]*>.*?<a href="\/manga\/([^"]+)".*?<b>(\d+)<\/b>.*?<img src="([^"]+)".*?alt="([^"]+)".*?<\/a>/gs; 395 const matches = [...html.matchAll(regex)]; 396 return matches.slice(0, 10).map((match) => ({ 397 id: match[1] || '', 398 rank: parseInt(match[2] || '0'), 399 imageUrl: match[3] || '', 400 title: decode(match[4] || ''), 401 banner: '', 402 link: `/manga/${match[1] || ''}`, 403 type: 'manga', 404 })); 405 }; 406 407 // Generate JavaScript injection code for cleaning up web content 408 export const getInjectedJavaScript = (backgroundColor: string) => { 409 const cleanupFunctions = { 410 removeElements: ` 411 function removeElements(selectors) { 412 selectors.forEach(selector => { 413 try { 414 const elements = document.querySelectorAll(selector); 415 elements.forEach(el => el.remove()); 416 } catch (e) { 417 console.warn('Error removing element:', selector, e); 418 } 419 }); 420 }`, 421 422 hideElements: ` 423 function hideElements(selectors) { 424 selectors.forEach(selector => { 425 try { 426 const elements = document.querySelectorAll(selector); 427 elements.forEach(el => { 428 el.style.display = 'none'; 429 el.style.visibility = 'hidden'; 430 el.style.opacity = '0'; 431 el.style.pointerEvents = 'none'; 432 }); 433 } catch (e) { 434 console.warn('Error hiding element:', selector, e); 435 } 436 }); 437 }`, 438 439 adjustBackground: ` 440 function adjustBackground() { 441 try { 442 const bgSpan = document.querySelector('span.bg'); 443 if (bgSpan) { 444 bgSpan.style.backgroundImage = 'none'; 445 bgSpan.style.backgroundColor = '${backgroundColor}'; 446 } 447 document.body.style.backgroundImage = 'none'; 448 document.body.style.backgroundColor = '${backgroundColor}'; 449 } catch (e) { 450 console.warn('Error adjusting background:', e); 451 } 452 }`, 453 454 blockScripts: ` 455 function blockMaliciousScripts() { 456 try { 457 const scriptBlocker = { 458 apply: function(target, thisArg, argumentsList) { 459 const src = argumentsList[0]?.src || ''; 460 if (src.includes('ads') || src.includes('analytics') || src.includes('tracker')) { 461 return null; 462 } 463 return target.apply(thisArg, argumentsList); 464 } 465 }; 466 document.createElement = new Proxy(document.createElement, scriptBlocker); 467 } catch (e) { 468 console.warn('Error setting up script blocker:', e); 469 } 470 }`, 471 472 disablePopups: ` 473 function disablePopups() { 474 try { 475 window.open = function() { return null; }; 476 window.alert = function() { return null; }; 477 window.confirm = function() { return null; }; 478 window.prompt = function() { return null; }; 479 } catch (e) { 480 console.warn('Error disabling popups:', e); 481 } 482 }`, 483 }; 484 485 return ` 486 (function() { 487 ${cleanupFunctions.removeElements} 488 ${cleanupFunctions.hideElements} 489 ${cleanupFunctions.adjustBackground} 490 ${cleanupFunctions.blockScripts} 491 ${cleanupFunctions.disablePopups} 492 493 function cleanPage() { 494 removeElements([ 495 'header', 'footer', '.ad-container', 496 '[id^="google_ads_"]', '[id^="adsbygoogle"]', 497 'iframe[src*="googleads"]', 'iframe[src*="doubleclick"]', 498 '.navbar', '.nav-bar', '#navbar', '#nav-bar', '.top-bar', '#top-bar' 499 ]); 500 501 hideElements([ 502 '#toast', '.toast', '.popup', '.modal', 503 '#overlay', '.overlay', '.banner' 504 ]); 505 506 adjustBackground(); 507 } 508 509 // Initial cleanup 510 cleanPage(); 511 blockMaliciousScripts(); 512 disablePopups(); 513 514 // Set up observer for dynamic content 515 try { 516 const observer = new MutationObserver(() => { 517 cleanPage(); 518 }); 519 observer.observe(document.body, { childList: true, subtree: true }); 520 } catch (e) { 521 console.warn('Error setting up mutation observer:', e); 522 } 523 524 return true; 525 })(); 526 `; 527 };