/ services / mangaFireService.ts
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  };