diff --git a/README.md b/README.md index 24aaecc..531977a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Next.js API for scraping manga metadata from multiple sources. | Madarascans | `madarascans` | https://madarascans.com | Active | | Magus Manga | `magus-manga` | https://magustoon.org | Active | | MangaCloud | `mangacloud` | https://mangacloud.org | Unstable | +| MangaDex | `mangadex` | https://mangadex.org | Active | | Mangago | `mangago` | https://www.mangago.zone | Active | | MangaKatana | `mangakatana` | https://mangakatana.com | Active | | Mangaloom | `mangaloom` | https://mangaloom.com | Active | diff --git a/src/app/api/chapters/route.ts b/src/app/api/chapters/route.ts index cf70f30..db4f7cd 100644 --- a/src/app/api/chapters/route.ts +++ b/src/app/api/chapters/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getScraper, getScraperByName } from "@/lib/scrapers"; -export const runtime = "edge"; +export const runtime = "nodejs"; export async function POST(request: NextRequest) { try { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 1fb6716..9ed2f9f 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getScraperByName, getAllScrapers } from "@/lib/scrapers"; import { BaseScraper } from "@/lib/scrapers/base"; -export const runtime = "edge"; +export const runtime = "nodejs"; const SCRAPER_TIMEOUT_MS = 20000; diff --git a/src/lib/scrapers/comix.ts b/src/lib/scrapers/comix.ts index d54050f..26361b0 100644 --- a/src/lib/scrapers/comix.ts +++ b/src/lib/scrapers/comix.ts @@ -4,7 +4,7 @@ import { ScrapedChapter, SearchResult, SourceType } from '@/types'; export class ComixScraper extends BaseScraper { private readonly baseUrl = 'https://comix.to'; - private readonly apiBase = 'https://comix.to/api/v2'; + private readonly apiBase = 'https://comix.to/api/v1'; getName(): string { return 'Comix'; @@ -75,7 +75,8 @@ export class ComixScraper extends BaseScraper { while (hasMorePages) { const response = await fetch( - `${this.apiBase}/manga/${hashId}/chapters?order[number]=desc&limit=100&page=${currentPage}` + `${this.apiBase}/manga/${hashId}/chapters?sort=desc&limit=100&page=${currentPage}&lang=`, + { headers: { "User-Agent": this.config.userAgent, Accept: "application/json" } } ); if (!response.ok) { @@ -135,11 +136,14 @@ export class ComixScraper extends BaseScraper { } async search(query: string): Promise { - const searchUrl = `${this.apiBase}/manga?order[relevance]=desc&keyword=${encodeURIComponent(query)}&limit=5`; + // Comix migrated its API to /api/v1; search is the manga list filtered by keyword. + const searchUrl = `${this.apiBase}/manga?keyword=${encodeURIComponent(query)}&limit=5&content_rating=suggestive`; const results: SearchResult[] = []; try { - const response = await fetch(searchUrl); + const response = await fetch(searchUrl, { + headers: { "User-Agent": this.config.userAgent, Accept: "application/json" }, + }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); @@ -148,31 +152,28 @@ export class ComixScraper extends BaseScraper { const data = await response.json(); if (data.result?.items && Array.isArray(data.result.items)) { + const seenIds = new Set(); for (const manga of data.result.items) { - let coverImage: string | undefined; - if (manga.poster?.large) { - coverImage = manga.poster.large; - } else if (manga.poster?.medium) { - coverImage = manga.poster.medium; - } + if (results.length >= 5) break; + if (!manga.hid || seenIds.has(manga.hid)) continue; + seenIds.add(manga.hid); - let lastUpdated = ''; - let lastUpdatedTimestamp: number | undefined; - if (manga.chapter_updated_at) { - lastUpdatedTimestamp = manga.chapter_updated_at * 1000; - lastUpdated = new Date(lastUpdatedTimestamp).toLocaleDateString(); - } + const coverImage = + manga.poster?.large || manga.poster?.medium || undefined; + + const url = manga.url + ? (manga.url.startsWith("http") ? manga.url : `${this.baseUrl}${manga.url}`) + : `${this.baseUrl}/title/${manga.hid}`; results.push({ - id: manga.hash_id, + id: manga.hid, title: manga.title, - url: `${this.baseUrl}/title/${manga.hash_id}-${manga.slug}`, + url, coverImage, - latestChapter: manga.latest_chapter || 0, - lastUpdated, - lastUpdatedTimestamp, - rating: manga.rated_avg, - followers: manga.follows_total?.toString() + latestChapter: manga.latestChapter || 0, + lastUpdated: manga.chapterUpdatedAtFormatted || "", + rating: manga.ratedAvg, + followers: manga.followsTotal?.toString(), }); } } diff --git a/src/lib/scrapers/greedscans.ts b/src/lib/scrapers/greedscans.ts index 129bfec..35b6761 100644 --- a/src/lib/scrapers/greedscans.ts +++ b/src/lib/scrapers/greedscans.ts @@ -3,6 +3,22 @@ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +interface GreedSearchItem { + ID: number; + post_title: string; + post_link: string; + post_image: string; + post_latest?: string; +} + +interface GreedSearchGroup { + all?: GreedSearchItem[]; +} + +interface GreedSearchResponse { + series: GreedSearchGroup[]; +} + export class GreedScansScraper extends BaseScraper { private readonly BASE_URL = "https://greedscans.com"; @@ -15,7 +31,7 @@ export class GreedScansScraper extends BaseScraper { } canHandle(url: string): boolean { - return url.includes("greedscans.com"); + return url.includes("greedscans.com") || url.includes("greedscans.org"); } getType(): SourceType { @@ -111,48 +127,48 @@ export class GreedScansScraper extends BaseScraper { } async search(query: string): Promise { - const searchUrl = `${this.BASE_URL}/?s=${encodeURIComponent(query)}`; - const html = await this.fetchWithRetry(searchUrl); - const $ = cheerio.load(html); - const results: SearchResult[] = []; - - $(".bsx").each((_, element) => { - const $item = $(element); - - const titleLink = $item.find("a").first(); - const url = titleLink.attr("href"); - const title = $item.find(".tt").text().trim(); - - if (!url) return; + // The themesia "?s=" search page renders results client-side, so the SSR + // HTML has only a "search-no-results" state. Use the theme's live + // autocomplete endpoint (admin-ajax ts_ac_do_search) which returns JSON. + const params = new URLSearchParams({ + action: "ts_ac_do_search", + ts_ac_query: query, + }); + const apiUrl = `${this.BASE_URL}/wp-admin/admin-ajax.php?${params.toString()}`; + + const response = await fetch(apiUrl, { + headers: { + "User-Agent": this.config.userAgent, + Accept: "application/json, text/plain, */*", + "X-Requested-With": "XMLHttpRequest", + Referer: `${this.BASE_URL}/`, + }, + }); - const slugMatch = url.match(/\/manga\/([^/]+)/); - const id = slugMatch ? slugMatch[1] : ""; + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } - const coverImg = $item.find("img").first(); - const coverImage = coverImg.attr("src"); + const data: GreedSearchResponse = await response.json(); + const items: GreedSearchItem[] = (data.series || []).flatMap( + (group) => group.all || [] + ); - const latestChapterText = $item.find(".epxs").text().trim(); - const chapterMatch = latestChapterText.match(/Chapter\s+([\d.]+)/i); - const latestChapter = chapterMatch ? parseFloat(chapterMatch[1]) : 0; + return items.slice(0, 5).map((item) => { + const slugMatch = item.post_link.match(/\/manga\/([^/]+)/); + const id = slugMatch ? slugMatch[1] : item.ID.toString(); - const ratingText = $item.find(".numscore").text().trim(); - const rating = ratingText ? parseFloat(ratingText) : undefined; + const latestMatch = item.post_latest?.match(/(\d+(?:\.\d+)?)/); + const latestChapter = latestMatch ? parseFloat(latestMatch[1]) : 0; - results.push({ + return { id, - title, - url, - coverImage: coverImage?.startsWith("http") - ? coverImage - : coverImage - ? `${this.BASE_URL}${coverImage}` - : undefined, + title: item.post_title, + url: item.post_link, + coverImage: item.post_image || undefined, latestChapter, lastUpdated: "", - rating, - }); + }; }); - - return results; } } diff --git a/src/lib/scrapers/index.ts b/src/lib/scrapers/index.ts index 282b69e..39004cc 100644 --- a/src/lib/scrapers/index.ts +++ b/src/lib/scrapers/index.ts @@ -68,6 +68,7 @@ import { AthreaScansScraper } from "./athreascans"; import { HadesScansScraper } from "./hadesscans"; import { ScytheScansScraper } from "./scythescans"; import { WebtoonScraper } from "./webtoon"; +import { MangaDexScraper } from "./mangadex"; import { SourceInfo } from "@/types"; const scrapers: BaseScraper[] = [ @@ -140,6 +141,7 @@ const scrapers: BaseScraper[] = [ new HadesScansScraper(), new ScytheScansScraper(), new WebtoonScraper(), + new MangaDexScraper(), ]; export function getScraper(url: string): BaseScraper | null { @@ -244,4 +246,5 @@ export { HadesScansScraper, ScytheScansScraper, WebtoonScraper, + MangaDexScraper, }; diff --git a/src/lib/scrapers/likemanga.ts b/src/lib/scrapers/likemanga.ts index 6fa9c40..ef7e0a5 100644 --- a/src/lib/scrapers/likemanga.ts +++ b/src/lib/scrapers/likemanga.ts @@ -9,11 +9,11 @@ export class LikeMangaScraper extends BaseScraper { } getBaseUrl(): string { - return "https://likemanga.in"; + return "https://mgread.io"; } canHandle(url: string): boolean { - return url.includes("likemanga.in"); + return url.includes("likemanga.in") || url.includes("mgread.io"); } async extractMangaInfo(url: string): Promise<{ title: string; id: string }> { @@ -63,7 +63,7 @@ export class LikeMangaScraper extends BaseScraper { if (href) { const fullUrl = href.startsWith("http") ? href - : `https://likemanga.in${href}`; + : `https://mgread.io${href}`; const chapterNumber = this.extractChapterNumber(fullUrl, chapterText); if (chapterNumber >= 0 && !seenChapterNumbers.has(chapterNumber)) { @@ -120,53 +120,42 @@ export class LikeMangaScraper extends BaseScraper { } async search(query: string): Promise { - const searchUrl = `https://likemanga.in/?s=${encodeURIComponent(query)}&post_type=wp-manga`; + // likemanga.in rebranded to mgread.io (UIkit theme); results are
cards. + const searchUrl = `https://mgread.io/?s=${encodeURIComponent(query)}&post_type=wp-manga`; const html = await this.fetchWithRetry(searchUrl); const $ = cheerio.load(html); const results: SearchResult[] = []; - $(".c-tabs-item__content").each((_, element) => { + $("article.uk-grid-small").each((_, element) => { const $item = $(element); - const titleLink = $item.find(".post-title a").first(); + const titleLink = $item.find("a.uk-link-heading").first(); const url = titleLink.attr("href"); const title = titleLink.text().trim(); - if (!url) return; + if (!url || !title) return; + if (!/\/manga\//.test(url)) return; const slugMatch = url.match(/\/manga\/([^/]+)/); const id = slugMatch ? slugMatch[1] : ""; - const coverImg = $item.find(".tab-thumb img").first(); + const coverImg = $item.find("img.wp-post-image").first(); const coverImage = coverImg.attr("src") || coverImg.attr("data-src"); - const latestChapterLink = $item.find(".latest-chap a").first(); - const latestChapterText = latestChapterLink.text().trim(); - const chapterMatch = latestChapterText.match(/Chapter\s+([\d.]+)/i); - const latestChapter = chapterMatch ? parseFloat(chapterMatch[1]) : 0; - - const lastUpdatedSpan = $item.find(".post-on span, .post-on").first(); - const lastUpdated = lastUpdatedSpan.text().trim(); - - const ratingSpan = $item.find(".total_votes").first(); - const rating = - ratingSpan.length > 0 - ? parseFloat(ratingSpan.text().trim()) - : undefined; - results.push({ id, title, - url, + url: url.startsWith("http") ? url : `https://mgread.io${url}`, coverImage: coverImage?.startsWith("http") ? coverImage - : `https://likemanga.in${coverImage}`, - latestChapter, - lastUpdated, - rating, + : coverImage + ? `https://mgread.io${coverImage}` + : undefined, + latestChapter: 0, + lastUpdated: "", }); }); - return results; + return results.slice(0, 5); } } diff --git a/src/lib/scrapers/mangadex.ts b/src/lib/scrapers/mangadex.ts new file mode 100644 index 0000000..7a0372f --- /dev/null +++ b/src/lib/scrapers/mangadex.ts @@ -0,0 +1,193 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { BaseScraper } from "./base"; +import { ScrapedChapter, SearchResult, SourceType } from "@/types"; + +export class MangaDexScraper extends BaseScraper { + private readonly BASE_URL = "https://mangadex.org"; + private readonly API_URL = "https://api.mangadex.org"; + private readonly UPLOADS_URL = "https://uploads.mangadex.org"; + private readonly CONTENT_RATINGS = [ + "safe", + "suggestive", + "erotica", + "pornographic", + ]; + + getName(): string { + return "MangaDex"; + } + + getBaseUrl(): string { + return this.BASE_URL; + } + + getType(): SourceType { + return "aggregator"; + } + + canHandle(url: string): boolean { + return url.includes("mangadex.org"); + } + + private pickTitle(attributes: any): string { + const title = attributes?.title || {}; + if (title.en) return title.en; + const firstKey = Object.keys(title)[0]; + if (firstKey) return title[firstKey]; + + const alts: any[] = attributes?.altTitles || []; + for (const alt of alts) { + if (alt.en) return alt.en; + } + if (alts.length > 0) { + const k = Object.keys(alts[0])[0]; + if (k) return alts[0][k]; + } + return "Unknown"; + } + + async search(query: string): Promise { + const params = new URLSearchParams(); + params.set("title", query); + params.set("limit", "5"); + params.append("includes[]", "cover_art"); + params.set("order[relevance]", "desc"); + for (const r of this.CONTENT_RATINGS) params.append("contentRating[]", r); + + const response = await fetch(`${this.API_URL}/manga?${params.toString()}`, { + headers: { "User-Agent": this.config.userAgent, Accept: "application/json" }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const items: any[] = data.data || []; + const results: SearchResult[] = []; + + for (const manga of items) { + const id = manga.id; + if (!id) continue; + + const title = this.pickTitle(manga.attributes); + + const cover = (manga.relationships || []).find( + (r: any) => r.type === "cover_art", + ); + const coverFile = cover?.attributes?.fileName; + const coverImage = coverFile + ? `${this.UPLOADS_URL}/covers/${id}/${coverFile}.256.jpg` + : undefined; + + const latestChapter = manga.attributes?.lastChapter + ? parseFloat(manga.attributes.lastChapter) || 0 + : 0; + + let lastUpdated = ""; + let lastUpdatedTimestamp: number | undefined; + if (manga.attributes?.updatedAt) { + const d = new Date(manga.attributes.updatedAt); + if (!isNaN(d.getTime())) { + lastUpdatedTimestamp = d.getTime(); + lastUpdated = d.toLocaleDateString(); + } + } + + results.push({ + id, + title, + url: `${this.BASE_URL}/title/${id}`, + coverImage, + latestChapter, + lastUpdated, + lastUpdatedTimestamp, + }); + } + + return results; + } + + async extractMangaInfo(url: string): Promise<{ title: string; id: string }> { + const idMatch = url.match(/\/title\/([0-9a-f-]{36})/i); + const id = idMatch ? idMatch[1] : ""; + if (!id) { + throw new Error("Could not extract MangaDex manga id from URL"); + } + + const response = await fetch( + `${this.API_URL}/manga/${id}`, + { headers: { "User-Agent": this.config.userAgent, Accept: "application/json" } }, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return { title: this.pickTitle(data.data?.attributes), id }; + } + + async getChapterList(mangaUrl: string): Promise { + const idMatch = mangaUrl.match(/\/title\/([0-9a-f-]{36})/i); + const id = idMatch ? idMatch[1] : ""; + if (!id) { + throw new Error("Could not extract MangaDex manga id from URL"); + } + + const chapters: ScrapedChapter[] = []; + const seenChapterNumbers = new Set(); + const limit = 500; + let offset = 0; + let total = 0; + + do { + const params = new URLSearchParams(); + params.append("translatedLanguage[]", "en"); + params.set("order[chapter]", "asc"); + params.set("limit", String(limit)); + params.set("offset", String(offset)); + params.append("includes[]", "scanlation_group"); + for (const r of this.CONTENT_RATINGS) params.append("contentRating[]", r); + + const response = await fetch( + `${this.API_URL}/manga/${id}/feed?${params.toString()}`, + { headers: { "User-Agent": this.config.userAgent, Accept: "application/json" } }, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const items: any[] = data.data || []; + total = data.total || 0; + + for (const ch of items) { + const attr = ch.attributes || {}; + if (attr.chapter == null) continue; // skip oneshots/ungrouped with no number + + const chapterNumber = parseFloat(attr.chapter); + if (isNaN(chapterNumber) || seenChapterNumbers.has(chapterNumber)) continue; + seenChapterNumbers.add(chapterNumber); + + const group = (ch.relationships || []).find( + (r: any) => r.type === "scanlation_group", + ); + + chapters.push({ + id: ch.id, + number: chapterNumber, + title: attr.title || `Chapter ${attr.chapter}`, + url: `${this.BASE_URL}/chapter/${ch.id}`, + lastUpdated: attr.publishAt || undefined, + group: group + ? { id: group.id, name: group.attributes?.name || "Unknown" } + : undefined, + }); + } + + offset += limit; + } while (offset < total); + + return chapters.sort((a, b) => a.number - b.number); + } +} diff --git a/src/lib/scrapers/mangago.ts b/src/lib/scrapers/mangago.ts index 7dad5e8..ad55e63 100644 --- a/src/lib/scrapers/mangago.ts +++ b/src/lib/scrapers/mangago.ts @@ -4,7 +4,7 @@ import { ScrapedChapter, SearchResult, SourceType } from "@/types"; export class MangagoScraper extends BaseScraper { private readonly BASE_URL = "https://www.mangago.zone"; - private readonly SEARCH_URL = "https://www.mangago.me"; + private readonly SEARCH_URL = "https://www.mangago.zone"; getName(): string { return "Mangago"; diff --git a/src/lib/scrapers/philiascans.ts b/src/lib/scrapers/philiascans.ts index e1cea23..2af594e 100644 --- a/src/lib/scrapers/philiascans.ts +++ b/src/lib/scrapers/philiascans.ts @@ -5,7 +5,6 @@ import { ScrapedChapter, SearchResult, SourceType } from "@/types"; export class PhiliascansScraper extends BaseScraper { private readonly BASE_URL = "https://philiascans.org"; - private cachedNonce: string | null = null; getName(): string { return "Philia Scans"; @@ -23,92 +22,58 @@ export class PhiliascansScraper extends BaseScraper { return url.includes("philiascans.org"); } - private async getNonce(): Promise { - if (this.cachedNonce) { - return this.cachedNonce; - } - - const html = await this.fetchWithRetry(`${this.BASE_URL}/all-mangas/`); - - const nonceMatch = html.match(/liveSearchData\s*=\s*\{[^}]*"nonce"\s*:\s*"([^"]+)"/); - if (nonceMatch) { - this.cachedNonce = nonceMatch[1]; - return this.cachedNonce; - } - - const altMatch = html.match(/security['"]\s*:\s*['"]([a-f0-9]+)['"]/); - if (altMatch) { - this.cachedNonce = altMatch[1]; - return this.cachedNonce; - } - - throw new Error("Could not extract search nonce from page"); - } - async extractMangaInfo(url: string): Promise<{ title: string; id: string }> { - const html = await this.fetchWithRetry(url); - const $ = cheerio.load(html); - - const title = - $(".post-title h1").first().text().trim() || - $("h1").first().text().trim() || - $("title").text().split(" - ")[0].trim(); + const slugMatch = url.match(/\/series\/([^/?#]+)/); + const slug = slugMatch ? slugMatch[1] : ""; - const urlMatch = url.match(/\/series\/([^/]+)/); - const id = urlMatch ? urlMatch[1] : Date.now().toString(); + try { + const res = await fetch(`${this.BASE_URL}/api/manga/${slug}`, { + headers: { "User-Agent": this.config.userAgent, Accept: "application/json" }, + }); + if (res.ok) { + const data = await res.json(); + return { title: data.title || slug, id: slug || String(data.id) }; + } + } catch { + // fall through to slug-based fallback + } - return { title, id }; + return { + title: slug.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()), + id: slug || Date.now().toString(), + }; } async getChapterList(mangaUrl: string): Promise { - const chapters: ScrapedChapter[] = []; - const seenChapterNumbers = new Set(); - - try { - const html = await this.fetchWithRetry(mangaUrl); - const $ = cheerio.load(html); - - $(".list-body-hh ul li.item").each((_: number, element: any) => { - const $chapter = $(element); - const $link = $chapter.find("a").first(); - const href = $link.attr("href"); - - if (!href || href === "#" || $chapter.hasClass("premium-block")) { - return; - } - - const chapterText = - $chapter.attr("data-chapter") || - $link.find("zebi").text().trim() || - $link.text().trim(); - - let chapterNumber: number; - const dataChapter = $chapter.attr("data-chapter"); - if (dataChapter) { - const match = dataChapter.match(/Chapter\s+(\d+(?:\.\d+)?)/i); - chapterNumber = match ? parseFloat(match[1]) : this.extractChapterNumber(href); - } else { - chapterNumber = this.extractChapterNumber(href); - } - - if (chapterNumber >= 0 && !seenChapterNumbers.has(chapterNumber)) { - seenChapterNumbers.add(chapterNumber); - - const fullUrl = href.startsWith("http") - ? href - : `${this.BASE_URL}${href}`; + const slugMatch = mangaUrl.match(/\/series\/([^/?#]+)/); + const slug = slugMatch ? slugMatch[1] : ""; + if (!slug) return []; + + // New Philia (Next.js) site exposes a JSON API for chapters. + const res = await fetch(`${this.BASE_URL}/api/manga/${slug}/chapters`, { + headers: { "User-Agent": this.config.userAgent, Accept: "application/json" }, + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } - chapters.push({ - id: `${chapterNumber}`, - number: chapterNumber, - title: chapterText || `Chapter ${chapterNumber}`, - url: fullUrl, - }); - } + const data = await res.json(); + const items: any[] = data.items || data.chapters || []; + const chapters: ScrapedChapter[] = []; + const seen = new Set(); + + for (const it of items) { + const number = parseFloat(it.number); + if (isNaN(number) || seen.has(number)) continue; + seen.add(number); + + chapters.push({ + id: String(it.id ?? number), + number, + title: it.title || `Chapter ${it.number}`, + url: `${this.BASE_URL}/series/${slug}/${it.slug}`, + lastUpdated: it.publishedAt || undefined, }); - } catch (error) { - console.error("[Philia Scans] Chapter fetch error:", error); - throw error; } return chapters.sort((a, b) => a.number - b.number); @@ -117,7 +82,7 @@ export class PhiliascansScraper extends BaseScraper { protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+)(?:[.-](\d+))?/i, - /\/(\d+)(?:[.-](\d+))?\/$/i, + /\/(\d+(?:[.-]\d+)?)\/?$/i, ]; for (const pattern of patterns) { @@ -137,132 +102,46 @@ export class PhiliascansScraper extends BaseScraper { } async search(query: string): Promise { - try { - const nonce = await this.getNonce(); - const searchUrl = `${this.BASE_URL}/wp-admin/admin-ajax.php`; - const formData = new URLSearchParams(); - formData.append("action", "live_search"); - formData.append("security", nonce); - formData.append("search_query", query); - - const response = await fetch(searchUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "User-Agent": this.config.userAgent, - Accept: "*/*", - "X-Requested-With": "XMLHttpRequest", - Referer: `${this.BASE_URL}/all-mangas/`, - }, - body: formData.toString(), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - const matchedSeries: Array<{ - id: string; - title: string; - url: string; - coverImage?: string; - rating?: number; - }> = []; - - if (data.results && Array.isArray(data.results)) { - for (const resultHtml of data.results) { - const $ = cheerio.load(resultHtml); - const link = $(".search-result-card"); - const url = link.attr("href"); - const title = - $(".search-result-title").text().trim() || - link.attr("title")?.trim() || - ""; - - if (!url) continue; + // Philia migrated to a Next.js site. Search is a server-rendered GET at + // /all-mangas?s=QUERY; results are . + const searchUrl = `${this.BASE_URL}/all-mangas?s=${encodeURIComponent(query)}`; + const html = await this.fetchWithRetry(searchUrl); + const $ = cheerio.load(html); - const slugMatch = url.match(/\/series\/([^/]+)/); - const id = slugMatch ? slugMatch[1] : ""; + const results: SearchResult[] = []; - const coverImage = $(".search-result-thumbnail img") - .first() - .attr("src"); + $("a.manga-card").each((_, element) => { + const $card = $(element); + const href = $card.attr("href"); + if (!href || !href.includes("/series/")) return; - const ratingText = $(".search-result-rating span").text().trim(); - const rating = ratingText ? parseFloat(ratingText) : undefined; + const url = href.startsWith("http") ? href : `${this.BASE_URL}${href}`; + const slugMatch = url.match(/\/series\/([^/?#]+)/); + const id = slugMatch ? slugMatch[1] : ""; - matchedSeries.push({ - id, - title, - url, - coverImage: coverImage?.startsWith("http") - ? coverImage - : coverImage - ? `${this.BASE_URL}${coverImage}` - : undefined, - rating, - }); - } + const $img = $card.find("img").first(); + let coverImage = $img.attr("src") || $img.attr("data-src") || undefined; + if (coverImage && coverImage.startsWith("/")) { + coverImage = `${this.BASE_URL}${coverImage}`; } - const limitedSeries = matchedSeries.slice(0, 5); - - const results: SearchResult[] = []; - for (const series of limitedSeries) { - try { - const seriesHtml = await this.fetchWithRetry(series.url); - const $series = cheerio.load(seriesHtml); - - let latestChapter = 0; + const title = + $card.find("[class*='title'], .manga-card-title, h3, h2").first().text().trim() || + $img.attr("alt")?.trim() || + ""; - $series(".list-body-hh ul li.item").each((_, el) => { - const $ch = $series(el); - const $link = $ch.find("a").first(); - const href = $link.attr("href"); + if (!title) return; - if (href && href !== "#" && !$ch.hasClass("premium-block")) { - const dataChapter = $ch.attr("data-chapter"); - if (dataChapter) { - const match = dataChapter.match(/Chapter\s+(\d+(?:\.\d+)?)/i); - const num = match ? parseFloat(match[1]) : 0; - if (num > latestChapter) { - latestChapter = num; - } - } - } - }); - - results.push({ - id: series.id, - title: series.title, - url: series.url, - coverImage: series.coverImage, - latestChapter, - lastUpdated: "", - rating: series.rating, - }); - } catch (error) { - console.error( - `[Philia Scans] Failed to fetch chapter list for ${series.title}:`, - error - ); - results.push({ - id: series.id, - title: series.title, - url: series.url, - coverImage: series.coverImage, - latestChapter: 0, - lastUpdated: "", - rating: series.rating, - }); - } - } + results.push({ + id, + title, + url, + coverImage, + latestChapter: 0, + lastUpdated: "", + }); + }); - return results; - } catch (error) { - console.error("[Philia Scans] Search error:", error); - throw error; - } + return results.slice(0, 5); } }