diff --git a/.gitignore b/.gitignore index fd3dbb5..00ba26d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ # testing /coverage +/scripts/ +!/scripts/update-readme-health.ts # next.js /.next/ @@ -33,4 +35,4 @@ yarn-error.log* # typescript *.tsbuildinfo -next-env.d.ts +next-env.d.ts \ No newline at end of file diff --git a/README.md b/README.md index da830f7..1803a57 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ npm run dev Runs on http://localhost:3000 **Production:** + ```bash npm run build npm start @@ -135,6 +136,17 @@ Get chapter list for a manga. } ``` +### POST /api/pages + +Get chapter page images + +```json +{ + "url": "https://kaliscan.me/manga/62786-lying-puppies-get-eaten/chapter-1", + "source": "mangapark" // optional, auto-detected +} +``` + ### GET /api/health Check source status. Cached for 5 minutes. Returns `cached` and `cacheAge` fields. @@ -183,6 +195,7 @@ Fetch frontpage section data from a source. ``` **Available sections for Comix:** + - `trending` - Most Recent Popular (supports time filter) - `most_followed` - Most Followed New Comics (supports time filter) - `latest_hot` - Latest Updates (Hot) @@ -194,7 +207,7 @@ Fetch frontpage section data from a source. Proxy requests to AsuraScans and WeebCentral (CORS workaround). -``` +```txt GET /api/proxy/html?url=https://asuracomic.net/... ``` diff --git a/package-lock.json b/package-lock.json index fb4ad62..32bffea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1250,6 +1250,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1265,6 +1266,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "dev": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1322,6 +1324,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -1878,6 +1881,7 @@ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-1.6.1.tgz", "integrity": "sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==", "dev": true, + "peer": true, "dependencies": { "@vitest/utils": "1.6.1", "fast-glob": "^3.3.2", @@ -1914,6 +1918,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3101,6 +3106,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3263,6 +3269,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4657,6 +4664,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5495,6 +5503,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5565,6 +5574,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5787,6 +5797,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5798,6 +5809,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6814,6 +6826,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -7373,6 +7386,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7554,6 +7568,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", diff --git a/src/app/api/pages/route.ts b/src/app/api/pages/route.ts new file mode 100644 index 0000000..c735c64 --- /dev/null +++ b/src/app/api/pages/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getScraper, getScraperByName } from "@/lib/scrapers"; + +export const runtime = "edge"; + +export async function POST(request: NextRequest) { + try { + const { url, source } = await request.json(); + + if (!url) { + return NextResponse.json({ error: "URL is required" }, { status: 400 }); + } + + let scraper; + + if (source) { + scraper = getScraperByName(source); + } + + if (!scraper) { + scraper = getScraper(url); + } + + if (!scraper) { + return NextResponse.json( + { + error: + "No scraper found for this URL. Please provide a valid chapter URL or source name.", + }, + { status: 400 }, + ); + } + + if (!scraper.supportsChapterImages()) { + return NextResponse.json( + { + error: `${scraper.getName()} does not support fetching chapter images`, + }, + { status: 400 }, + ); + } + + const images = await scraper.getChapterImages(url); + + return NextResponse.json({ + images, + source: scraper.getName(), + totalPages: images.length, + }); + } catch (error: unknown) { + console.error("Chapter images error:", error); + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to fetch chapter images", + }, + { status: 500 }, + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d6543dd..0e6b1f0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -102,6 +102,25 @@ const endpoints: EndpointProps[] = [ ], "source": "MangaPark", "totalChapters": 179 +}`, + }, + { + method: "POST", + path: "/api/pages", + description: "Get chapter page images", + request: `{ + "url": "https://kaliscan.com/manga/12345-some-manga/chapter-1", + "source": "kaliscan" // optional +}`, + response: `{ + "images": [ + { + "url": "https://...", + "page": 1 + } + ], + "source": "KaliScan", + "totalPages": 20 }`, }, { diff --git a/src/lib/scrapers/arvencomics.ts b/src/lib/scrapers/arvencomics.ts index ae14f0d..6d22e9f 100644 --- a/src/lib/scrapers/arvencomics.ts +++ b/src/lib/scrapers/arvencomics.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class ArvenComicsScraper extends BaseScraper { private readonly BASE_URL = "https://arvencomics.com"; @@ -104,6 +104,25 @@ export class ArvenComicsScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber( chapterUrl: string, chapterText?: string, diff --git a/src/lib/scrapers/arya-scans.ts b/src/lib/scrapers/arya-scans.ts index 6f18283..0c0b239 100644 --- a/src/lib/scrapers/arya-scans.ts +++ b/src/lib/scrapers/arya-scans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class AryaScansScraper extends BaseScraper { private readonly BASE_URL = "https://brainrotcomics.com"; @@ -108,6 +108,25 @@ export class AryaScansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/asmotoon.ts b/src/lib/scrapers/asmotoon.ts index 2b2df45..c5b5656 100644 --- a/src/lib/scrapers/asmotoon.ts +++ b/src/lib/scrapers/asmotoon.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class AsmotoonScraper extends BaseScraper { private readonly BASE_URL = "https://asmotoon.com"; @@ -107,6 +107,25 @@ export class AsmotoonScraper extends BaseScraper { return -1; } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $("#pages img[uid]").each((_, el) => { + const uid = $(el).attr("uid")?.trim(); + if (uid) { + images.push({ url: `https://cdn.meowing.org/uploads/${uid}`, page: images.length + 1 }); + } + }); + + return images; + } + async search(query: string): Promise { try { const searchUrl = `${this.BASE_URL}/series?q=${encodeURIComponent(query)}`; diff --git a/src/lib/scrapers/asurascan.ts b/src/lib/scrapers/asurascan.ts index 0acf551..507e58d 100644 --- a/src/lib/scrapers/asurascan.ts +++ b/src/lib/scrapers/asurascan.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class AsuraScanScraper extends BaseScraper { private readonly BASE_URL = "https://asuracomic.net"; @@ -187,6 +187,25 @@ export class AsuraScanScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const imagePattern = /\{"order"\s*:\s*(\d+)\s*,\s*"url"\s*:\s*"([^"]+)"\}/g; + const images: ChapterImage[] = []; + let match; + + while ((match = imagePattern.exec(html)) !== null) { + images.push({ url: match[2], page: parseInt(match[1]) + 1 }); + } + + images.sort((a, b) => a.page - b.page); + // Re-number pages sequentially + return images.map((img, index) => ({ ...img, page: index + 1 })); + } + protected override extractChapterNumber(chapterUrl: string, chapterText?: string): number { if (chapterText) { // Match concatenated chapters like "Chapter 5 + 6" or "Chapter 5 - 6" diff --git a/src/lib/scrapers/athreascans.ts b/src/lib/scrapers/athreascans.ts index 3ee73f4..53e637a 100644 --- a/src/lib/scrapers/athreascans.ts +++ b/src/lib/scrapers/athreascans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class AthreaScansScraper extends BaseScraper { private readonly BASE_URL = "https://athreascans.com"; @@ -90,6 +90,24 @@ export class AthreaScansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /-chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/base.ts b/src/lib/scrapers/base.ts index 6531c53..fdad7bf 100644 --- a/src/lib/scrapers/base.ts +++ b/src/lib/scrapers/base.ts @@ -1,4 +1,4 @@ -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; interface ScraperConfig { retryAttempts: number; @@ -28,6 +28,16 @@ export abstract class BaseScraper { abstract getChapterList(mangaUrl: string): Promise; abstract search(query: string): Promise; + async getChapterImages(chapterUrl: string): Promise { + throw new Error( + `${this.getName()} does not support fetching chapter images`, + ); + } + + supportsChapterImages(): boolean { + return false; + } + protected async fetchWithRetry( url: string, retries = this.config.retryAttempts, diff --git a/src/lib/scrapers/comix.ts b/src/lib/scrapers/comix.ts index d54050f..169eb28 100644 --- a/src/lib/scrapers/comix.ts +++ b/src/lib/scrapers/comix.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { BaseScraper } from './base'; -import { ScrapedChapter, SearchResult, SourceType } from '@/types'; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from '@/types'; export class ComixScraper extends BaseScraper { private readonly baseUrl = 'https://comix.to'; @@ -134,6 +134,26 @@ export class ComixScraper extends BaseScraper { return 0; } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const match = chapterUrl.match(/\/(\d+)-chapter-/) || chapterUrl.match(/\/title\/[^/]+\/([^-]+)/); + if (!match) return []; + + const chapterId = match[1]; + const response = await fetch(`${this.apiBase}/chapters/${chapterId}`, { + headers: { 'User-Agent': this.config.userAgent }, + }); + + if (!response.ok) return []; + const data = await response.json(); + const images: { url: string }[] = data.images || data.chapter?.images || []; + + return images.map((img, index) => ({ url: img.url, page: index + 1 })); + } + async search(query: string): Promise { const searchUrl = `${this.apiBase}/manga?order[relevance]=desc&keyword=${encodeURIComponent(query)}&limit=5`; const results: SearchResult[] = []; diff --git a/src/lib/scrapers/elftoon.ts b/src/lib/scrapers/elftoon.ts index e88da7b..157b995 100644 --- a/src/lib/scrapers/elftoon.ts +++ b/src/lib/scrapers/elftoon.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class ElfToonScraper extends BaseScraper { private readonly BASE_URL = "https://elftoon.com"; @@ -94,6 +94,24 @@ export class ElfToonScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /-chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/evascans.ts b/src/lib/scrapers/evascans.ts index 3c32f7e..b168e00 100644 --- a/src/lib/scrapers/evascans.ts +++ b/src/lib/scrapers/evascans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class EvaScansScraper extends BaseScraper { private readonly BASE_URL = "https://evascans.org"; @@ -94,6 +94,24 @@ export class EvaScansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /-chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/firescans.ts b/src/lib/scrapers/firescans.ts index e5827c0..49c58cb 100644 --- a/src/lib/scrapers/firescans.ts +++ b/src/lib/scrapers/firescans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class FirescansScraper extends BaseScraper { private readonly BASE_URL = "https://firescans.xyz"; @@ -108,6 +108,25 @@ export class FirescansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/flamecomics.ts b/src/lib/scrapers/flamecomics.ts index abbc073..e1c233a 100644 --- a/src/lib/scrapers/flamecomics.ts +++ b/src/lib/scrapers/flamecomics.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class FlameComicsScraper extends BaseScraper { private readonly BASE_URL = "https://flamecomics.xyz"; @@ -129,6 +129,44 @@ export class FlameComicsScraper extends BaseScraper { } } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + // Try __NEXT_DATA__ first + const nextDataScript = $("#__NEXT_DATA__").html(); + if (nextDataScript) { + try { + const data = JSON.parse(nextDataScript); + const chapter = data.props?.pageProps?.chapter; + if (chapter?.images) { + const seriesId = data.props?.pageProps?.series?.id || ""; + const chapterHash = chapter.hash || chapter.id || ""; + const imageEntries = Object.values(chapter.images) as { name: string }[]; + return imageEntries.map((img, index) => ({ + url: `${this.CDN_URL}/uploads/images/series/${seriesId}/${chapterHash}/${img.name}`, + page: index + 1, + })); + } + } catch {} + } + + // Fallback: direct img tags + $("img[src*='cdn.flamecomics']").each((_, el) => { + const url = $(el).attr("src")?.trim(); + if (url) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + async extractMangaInfo(url: string): Promise<{ title: string; id: string }> { const html = await this.fetchWithRetry(url); const $ = cheerio.load(html); diff --git a/src/lib/scrapers/gdscans.ts b/src/lib/scrapers/gdscans.ts index 6193b51..14199e1 100644 --- a/src/lib/scrapers/gdscans.ts +++ b/src/lib/scrapers/gdscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class GDScansScraper extends BaseScraper { private readonly BASE_URL = "https://gdscans.com"; @@ -120,6 +120,25 @@ export class GDScansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+(?:[.-]\d+)?)/i, diff --git a/src/lib/scrapers/greedscans.ts b/src/lib/scrapers/greedscans.ts index 129bfec..6f47e49 100644 --- a/src/lib/scrapers/greedscans.ts +++ b/src/lib/scrapers/greedscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class GreedScansScraper extends BaseScraper { private readonly BASE_URL = "https://greedscans.com"; @@ -87,6 +87,24 @@ export class GreedScansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /\/chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/hadesscans.ts b/src/lib/scrapers/hadesscans.ts index e2e6b58..934d2f0 100644 --- a/src/lib/scrapers/hadesscans.ts +++ b/src/lib/scrapers/hadesscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class HadesScansScraper extends BaseScraper { private readonly BASE_URL = "https://hadesscans.com"; @@ -92,6 +92,24 @@ export class HadesScansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /\/chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/kaliscan.ts b/src/lib/scrapers/kaliscan.ts index d42361b..9c2fbe0 100644 --- a/src/lib/scrapers/kaliscan.ts +++ b/src/lib/scrapers/kaliscan.ts @@ -1,6 +1,6 @@ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class KaliScanScraper extends BaseScraper { private readonly BASE_URL = "https://kaliscan.com"; @@ -96,6 +96,26 @@ export class KaliScanScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/var\s+chapImages\s*=\s*"([^"]+)"/); + + if (!match) { + console.error("[KaliScan] Could not find chapImages in page HTML"); + return []; + } + + return match[1] + .split(",") + .map((u) => u.trim()) + .filter((u) => u.length > 0) + .map((url, index) => ({ url, page: index + 1 })); + } + protected override extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+(?:\.\d+)?)/i, diff --git a/src/lib/scrapers/kappabeast.ts b/src/lib/scrapers/kappabeast.ts index 45cd4b5..62c3ec7 100644 --- a/src/lib/scrapers/kappabeast.ts +++ b/src/lib/scrapers/kappabeast.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class KappaBeastScraper extends BaseScraper { private readonly BASE_URL = "https://kappabeast.com"; @@ -85,6 +85,24 @@ export class KappaBeastScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+(?:[.-]\d+)?)/i, diff --git a/src/lib/scrapers/ksgroupscans.ts b/src/lib/scrapers/ksgroupscans.ts index 7b87469..74a96b1 100644 --- a/src/lib/scrapers/ksgroupscans.ts +++ b/src/lib/scrapers/ksgroupscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class KsgroupscansScraper extends BaseScraper { private readonly BASE_URL = "https://ksgroupscans.com"; @@ -108,6 +108,25 @@ export class KsgroupscansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/lagoonscans.ts b/src/lib/scrapers/lagoonscans.ts index 6c7aa4b..ee23d6f 100644 --- a/src/lib/scrapers/lagoonscans.ts +++ b/src/lib/scrapers/lagoonscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class LagoonScansScraper extends BaseScraper { getName(): string { @@ -81,6 +81,24 @@ export class LagoonScansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /\/chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/lhtranslation.ts b/src/lib/scrapers/lhtranslation.ts index 8c36761..d96a359 100644 --- a/src/lib/scrapers/lhtranslation.ts +++ b/src/lib/scrapers/lhtranslation.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class LHTranslationScraper extends BaseScraper { private readonly BASE_URL = "https://lhtranslation.net"; @@ -108,6 +108,25 @@ export class LHTranslationScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/likemanga.ts b/src/lib/scrapers/likemanga.ts index 6fa9c40..3cfebbf 100644 --- a/src/lib/scrapers/likemanga.ts +++ b/src/lib/scrapers/likemanga.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult } from "@/types"; export class LikeMangaScraper extends BaseScraper { getName(): string { @@ -84,6 +84,25 @@ export class LikeMangaScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string, chapterText?: string): number { if (chapterText) { const concatenatedMatch = chapterText.match(/Chapter\s+(\d+)\s*[\+\-]\s*(\d+)/i); diff --git a/src/lib/scrapers/luacomic.ts b/src/lib/scrapers/luacomic.ts index 7554c94..e5ce803 100644 --- a/src/lib/scrapers/luacomic.ts +++ b/src/lib/scrapers/luacomic.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; interface LuaComicChapter { id: number; @@ -212,6 +212,33 @@ export class LuaComicScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const match = chapterUrl.match(/\/series\/[^/]+\/([^/?]+)/); + if (!match) return []; + + const chapterSlug = match[1]; + const response = await fetch(`${this.API_URL}/chapter/${chapterSlug}`, { + headers: { + Accept: "application/json, text/plain, */*", + "User-Agent": this.config.userAgent, + Referer: `${this.BASE_URL}/`, + }, + }); + + if (!response.ok) return []; + const data = await response.json(); + const images: { src: string }[] = data.data?.images || data.images || []; + + return images.map((img, index) => ({ + url: img.src, + page: index + 1, + })); + } + private extractChapterNumberFromName(chapterName: string): number { const match = chapterName.match(/Chapter\s+(\d+(?:\.\d+)?)/i); return match ? parseFloat(match[1]) : -1; diff --git a/src/lib/scrapers/madarascans.ts b/src/lib/scrapers/madarascans.ts index 3c1483c..dc918a9 100644 --- a/src/lib/scrapers/madarascans.ts +++ b/src/lib/scrapers/madarascans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class MadaraScansScraper extends BaseScraper { private readonly BASE_URL = "https://madarascans.com"; @@ -93,6 +93,24 @@ export class MadaraScansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(text: string): number { const patterns = [ /chapter\s*(\d+(?:\.\d+)?)/i, diff --git a/src/lib/scrapers/mangacloud.ts b/src/lib/scrapers/mangacloud.ts index b93889a..77af69f 100644 --- a/src/lib/scrapers/mangacloud.ts +++ b/src/lib/scrapers/mangacloud.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class MangaCloudScraper extends BaseScraper { private readonly BASE_URL = "https://mangacloud.org"; @@ -179,6 +179,35 @@ export class MangaCloudScraper extends BaseScraper { } } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const match = chapterUrl.match(/\/chapter\/([^/?]+)/) || chapterUrl.match(/[-/]([a-zA-Z0-9]+)$/); + if (!match) return []; + + const chapterId = match[1]; + const response = await fetch(`${this.API_URL}/chapter/${chapterId}`, { + headers: { + "Accept": "*/*", + "Origin": this.BASE_URL, + "Referer": `${this.BASE_URL}/`, + "User-Agent": this.config.userAgent, + }, + }); + + if (!response.ok) return []; + const data = await response.json(); + const chapter = data.chapter || data; + const images: { id: string; f: string }[] = chapter.images || chapter.md_images || []; + + return images.map((img, index) => ({ + url: `https://meo3.comick.pictures/${img.id}.${img.f || "jpg"}`, + page: index + 1, + })); + } + protected override extractChapterNumber(chapterUrl: string): number { const match = chapterUrl.match(/\/chapter\/(\d+)/); return match ? parseFloat(match[1]) : 0; diff --git a/src/lib/scrapers/mangakatana.ts b/src/lib/scrapers/mangakatana.ts index 882c6fd..af48d7a 100644 --- a/src/lib/scrapers/mangakatana.ts +++ b/src/lib/scrapers/mangakatana.ts @@ -1,6 +1,6 @@ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class MangaKatanaScraper extends BaseScraper { private readonly BASE_URL = "https://mangakatana.com"; @@ -124,6 +124,23 @@ export class MangaKatanaScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/var\s+(?:thzq|ytaw)\s*=\s*\[([^\]]+)\]/); + if (!match) return []; + + const urls = match[1] + .split(",") + .map((s) => s.trim().replace(/^['"]|['"]$/g, "")) + .filter((u) => u.startsWith("http")); + + return urls.map((url, index) => ({ url, page: index + 1 })); + } + protected override extractChapterNumber(chapterUrl: string): number { const match = chapterUrl.match(/\/c(\d+(?:\.\d+)?)/); return match ? parseFloat(match[1]) : 0; diff --git a/src/lib/scrapers/mangaloom.ts b/src/lib/scrapers/mangaloom.ts index 7542a20..3fc1662 100644 --- a/src/lib/scrapers/mangaloom.ts +++ b/src/lib/scrapers/mangaloom.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult } from "@/types"; export class MangaloomScraper extends BaseScraper { getName(): string { @@ -117,6 +117,25 @@ export class MangaloomScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /\/chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/mangaread.ts b/src/lib/scrapers/mangaread.ts index cd37556..1c6649e 100644 --- a/src/lib/scrapers/mangaread.ts +++ b/src/lib/scrapers/mangaread.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult } from "@/types"; export class MangaReadScraper extends BaseScraper { getName(): string { @@ -87,6 +87,25 @@ export class MangaReadScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string, chapterText?: string): number { if (chapterText) { const concatenatedMatch = chapterText.match(/Chapter\s+(\d+)\s*[\+\-]\s*(\d+)/i); diff --git a/src/lib/scrapers/mangasushi.ts b/src/lib/scrapers/mangasushi.ts index df13bb5..cdb0c1c 100644 --- a/src/lib/scrapers/mangasushi.ts +++ b/src/lib/scrapers/mangasushi.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class MangasushiScraper extends BaseScraper { private readonly BASE_URL = "https://mangasushi.org"; @@ -108,6 +108,25 @@ export class MangasushiScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/mangayy.ts b/src/lib/scrapers/mangayy.ts index 74f04c6..874d7d0 100644 --- a/src/lib/scrapers/mangayy.ts +++ b/src/lib/scrapers/mangayy.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult } from "@/types"; export class MangayyScraper extends BaseScraper { getName(): string { @@ -87,6 +87,25 @@ export class MangayyScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string, chapterText?: string): number { if (chapterText) { const concatenatedMatch = chapterText.match(/Chapter\s+(\d+)\s*[\+\-]\s*(\d+)/i); diff --git a/src/lib/scrapers/manhuaplus.ts b/src/lib/scrapers/manhuaplus.ts index a908419..47d317e 100644 --- a/src/lib/scrapers/manhuaplus.ts +++ b/src/lib/scrapers/manhuaplus.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class ManhuaPlusScraper extends BaseScraper { getName(): string { @@ -67,6 +67,25 @@ export class ManhuaPlusScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /\/chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/manhuaus.ts b/src/lib/scrapers/manhuaus.ts index db2fda2..f4e2d5c 100644 --- a/src/lib/scrapers/manhuaus.ts +++ b/src/lib/scrapers/manhuaus.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult } from "@/types"; export class ManhuausScraper extends BaseScraper { getName(): string { @@ -87,6 +87,25 @@ export class ManhuausScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string, chapterText?: string): number { if (chapterText) { const concatenatedMatch = chapterText.match(/Chapter\s+(\d+)\s*[\+\-]\s*(\d+)/i); diff --git a/src/lib/scrapers/mgeko.ts b/src/lib/scrapers/mgeko.ts index 3b11e3a..637242d 100644 --- a/src/lib/scrapers/mgeko.ts +++ b/src/lib/scrapers/mgeko.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult } from "@/types"; export class MgekoScraper extends BaseScraper { getName(): string { @@ -104,6 +104,25 @@ export class MgekoScraper extends BaseScraper { return -1; } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $("#chapter-reader img[id^='image-']").each((_, el) => { + const url = $(el).attr("src")?.trim(); + if (url) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + async search(query: string): Promise { const searchUrl = `https://www.mgeko.cc/search/?search=${encodeURIComponent(query)}`; const html = await this.fetchWithRetry(searchUrl); diff --git a/src/lib/scrapers/mistscans.ts b/src/lib/scrapers/mistscans.ts index 47baf0a..15f35f8 100644 --- a/src/lib/scrapers/mistscans.ts +++ b/src/lib/scrapers/mistscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class MistScansScraper extends BaseScraper { private readonly BASE_URL = "https://mistscans.com"; @@ -98,6 +98,25 @@ export class MistScansScraper extends BaseScraper { return -1; } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $("#pages img[uid]").each((_, el) => { + const uid = $(el).attr("uid")?.trim(); + if (uid) { + images.push({ url: `https://cdn.meowing.org/uploads/${uid}`, page: images.length + 1 }); + } + }); + + return images; + } + async search(query: string): Promise { const searchUrl = `${this.BASE_URL}/series?q=${encodeURIComponent(query)}`; const html = await this.fetchWithRetry(searchUrl); diff --git a/src/lib/scrapers/novelcool.ts b/src/lib/scrapers/novelcool.ts index 4cc1cee..24ce8d1 100644 --- a/src/lib/scrapers/novelcool.ts +++ b/src/lib/scrapers/novelcool.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult } from "@/types"; export class NovelCoolScraper extends BaseScraper { getName(): string { @@ -83,6 +83,42 @@ export class NovelCoolScraper extends BaseScraper { return -1; } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + // Get page count from select dropdown + const pageCount = $("select.sl-page option").length || 1; + + // Get first page image + const firstImg = $(".mangaread-img img, #manga_picid_1").first().attr("src")?.trim(); + if (firstImg) { + images.push({ url: firstImg, page: 1 }); + } + + // Fetch remaining pages + const baseUrl = chapterUrl.replace(/\.html$/, ""); + for (let i = 2; i <= pageCount; i++) { + try { + const pageHtml = await this.fetchWithRetry(`${baseUrl}-${i}.html`); + const $page = cheerio.load(pageHtml); + const imgUrl = $page(".mangaread-img img, #manga_picid_1").first().attr("src")?.trim(); + if (imgUrl) { + images.push({ url: imgUrl, page: i }); + } + } catch { + break; + } + } + + return images; + } + async search(query: string): Promise { const searchUrl = `https://www.novelcool.com/search?name=${encodeURIComponent(query)}`; const html = await this.fetchWithRetry(searchUrl); diff --git a/src/lib/scrapers/qiscans.ts b/src/lib/scrapers/qiscans.ts index 1e31ef7..bf8d927 100644 --- a/src/lib/scrapers/qiscans.ts +++ b/src/lib/scrapers/qiscans.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; interface QiScansChapter { id: number; @@ -68,6 +69,39 @@ export class QiScansScraper extends BaseScraper { return "just now"; } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + // Try to extract from img tags in the page + $("img[src*='nightsup.net'], img[src*='media.qiscans'], img[src*='uploads']").each((_, el) => { + const url = $(el).attr("src")?.trim(); + if (url && url.startsWith("http")) { + images.push({ url, page: images.length + 1 }); + } + }); + + // If no images found, try RSC payload + if (images.length === 0) { + const imgPattern = /https?:\/\/[^"'\s]+?\.(?:jpg|jpeg|png|webp|gif)(?:\?[^"'\s]*)?/gi; + const allUrls = html.match(imgPattern) || []; + const chapterImages = allUrls.filter( + (url) => + (url.includes("nightsup.net") || url.includes("media.qiscans") || url.includes("uploads/series")) && + !url.includes("cover") && !url.includes("logo") && !url.includes("avatar") + ); + const uniqueImages = Array.from(new Set(chapterImages)); + return uniqueImages.map((url, index) => ({ url, page: index + 1 })); + } + + return images; + } + async search(query: string): Promise { try { const searchUrl = `${this.API_URL}/api/query?page=1&perPage=21&searchTerm=${encodeURIComponent(query)}&orderBy=createdAt`; diff --git a/src/lib/scrapers/ragescans.ts b/src/lib/scrapers/ragescans.ts index a8a29c7..836b78a 100644 --- a/src/lib/scrapers/ragescans.ts +++ b/src/lib/scrapers/ragescans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class RageScansScraper extends BaseScraper { private readonly BASE_URL = "https://ragescans.com"; @@ -90,6 +90,24 @@ export class RageScansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /-chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/ravenscans.ts b/src/lib/scrapers/ravenscans.ts index 3234d22..3371fc9 100644 --- a/src/lib/scrapers/ravenscans.ts +++ b/src/lib/scrapers/ravenscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from 'cheerio'; import { BaseScraper } from './base'; -import { ScrapedChapter, SearchResult, SourceType } from '@/types'; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from '@/types'; export class RavenScansScraper extends BaseScraper { private readonly baseUrl = 'https://ravenscans.org'; @@ -81,6 +81,24 @@ export class RavenScansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const match = chapterUrl.match(/chapter[/-](\d+(?:\.\d+)?)/); if (match) { diff --git a/src/lib/scrapers/rdscans.ts b/src/lib/scrapers/rdscans.ts index e32e5f6..02d5bf3 100644 --- a/src/lib/scrapers/rdscans.ts +++ b/src/lib/scrapers/rdscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class RdscansScraper extends BaseScraper { private readonly BASE_URL = "https://rdscans.com"; @@ -108,6 +108,25 @@ export class RdscansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/rizzfables.ts b/src/lib/scrapers/rizzfables.ts index 0d19e0a..5c7c491 100644 --- a/src/lib/scrapers/rizzfables.ts +++ b/src/lib/scrapers/rizzfables.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from 'cheerio'; import { BaseScraper } from './base'; -import { ScrapedChapter, SearchResult, SourceType } from '@/types'; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from '@/types'; export class RizzFablesScraper extends BaseScraper { getName(): string { @@ -74,6 +74,24 @@ export class RizzFablesScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /-chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/rokaricomics.ts b/src/lib/scrapers/rokaricomics.ts index 021caa4..9c68560 100644 --- a/src/lib/scrapers/rokaricomics.ts +++ b/src/lib/scrapers/rokaricomics.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class RokariComicsScraper extends BaseScraper { private readonly BASE_URL = "https://rokaricomics.com"; @@ -90,6 +90,24 @@ export class RokariComicsScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /-chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/silentquill.ts b/src/lib/scrapers/silentquill.ts index 49accb0..1dfd815 100644 --- a/src/lib/scrapers/silentquill.ts +++ b/src/lib/scrapers/silentquill.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class SilentQuillScraper extends BaseScraper { getName(): string { @@ -73,6 +73,24 @@ export class SilentQuillScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string, chapterText?: string): number { if (chapterText) { const textMatch = chapterText.match(/Chapter\s+(\d+(?:\.\d+)?)/i); diff --git a/src/lib/scrapers/spiderscans.ts b/src/lib/scrapers/spiderscans.ts index e7c4747..b5b66e1 100644 --- a/src/lib/scrapers/spiderscans.ts +++ b/src/lib/scrapers/spiderscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class SpiderScansScraper extends BaseScraper { private readonly BASE_URL = "https://spiderscans.xyz"; @@ -118,6 +118,25 @@ export class SpiderScansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+(?:\.\d+)?)/i, diff --git a/src/lib/scrapers/stonescape.ts b/src/lib/scrapers/stonescape.ts index c7b5879..abd2e76 100644 --- a/src/lib/scrapers/stonescape.ts +++ b/src/lib/scrapers/stonescape.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from 'cheerio'; import { BaseScraper } from './base'; -import { ScrapedChapter, SearchResult, SourceType } from '@/types'; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from '@/types'; export class StonescapeScraper extends BaseScraper { getName(): string { @@ -120,6 +120,25 @@ export class StonescapeScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $('.reading-content .page-break img, .reading-content img.wp-manga-chapter-img').each((_, el) => { + const url = $(el).attr('data-src')?.trim() || $(el).attr('src')?.trim(); + if (url && !url.includes('loading') && !url.includes('placeholder')) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /\/ch[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/thunderscans.ts b/src/lib/scrapers/thunderscans.ts index d697af9..b101fcc 100644 --- a/src/lib/scrapers/thunderscans.ts +++ b/src/lib/scrapers/thunderscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class ThunderscansScraper extends BaseScraper { private readonly BASE_URL = "https://en-thunderscans.com"; @@ -90,6 +90,24 @@ export class ThunderscansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /-chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/topmanhua.ts b/src/lib/scrapers/topmanhua.ts index b86cd28..fcca2f5 100644 --- a/src/lib/scrapers/topmanhua.ts +++ b/src/lib/scrapers/topmanhua.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult } from "@/types"; export class TopManhuaScraper extends BaseScraper { getName(): string { @@ -117,6 +117,25 @@ export class TopManhuaScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /\/chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/utoon.ts b/src/lib/scrapers/utoon.ts index aa25be4..29ee8dd 100644 --- a/src/lib/scrapers/utoon.ts +++ b/src/lib/scrapers/utoon.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class UtoonScraper extends BaseScraper { private readonly BASE_URL = "https://utoon.net"; @@ -90,6 +90,25 @@ export class UtoonScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/violetscans.ts b/src/lib/scrapers/violetscans.ts index 5399a1e..b5b9e5b 100644 --- a/src/lib/scrapers/violetscans.ts +++ b/src/lib/scrapers/violetscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class VioletscansScraper extends BaseScraper { private readonly BASE_URL = "https://violetscans.org"; @@ -89,6 +89,24 @@ export class VioletscansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /-chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/webtoon.ts b/src/lib/scrapers/webtoon.ts index c2952af..7477866 100644 --- a/src/lib/scrapers/webtoon.ts +++ b/src/lib/scrapers/webtoon.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class WebtoonScraper extends BaseScraper { private readonly BASE_URL = "https://www.webtoons.com"; @@ -167,6 +167,25 @@ export class WebtoonScraper extends BaseScraper { } } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $("img._images[data-url]").each((_, el) => { + const url = $(el).attr("data-url")?.trim(); + if (url) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + async search(query: string): Promise { const searchUrl = `${this.BASE_URL}/en/search?keyword=${encodeURIComponent(query)}`; const html = await this.fetchWithRetry(searchUrl); diff --git a/src/lib/scrapers/weebcentral.ts b/src/lib/scrapers/weebcentral.ts index a102e3c..65e6108 100644 --- a/src/lib/scrapers/weebcentral.ts +++ b/src/lib/scrapers/weebcentral.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult } from "@/types"; export class WeebCentralScraper extends BaseScraper { protected override async fetchWithRetry(url: string): Promise { @@ -126,6 +126,33 @@ export class WeebCentralScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + + // Find the HTMX endpoint for images + const hxGet = $("[hx-get*='/images']").attr("hx-get"); + if (!hxGet) return []; + + const imagesUrl = hxGet.startsWith("http") ? hxGet : `https://weebcentral.com${hxGet}`; + const imagesHtml = await this.fetchWithRetry(imagesUrl); + const $images = cheerio.load(imagesHtml); + const images: ChapterImage[] = []; + + $images("img[src]").each((_, el) => { + const url = $images(el).attr("src")?.trim(); + if (url && url.startsWith("http")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + async search(query: string): Promise { const searchUrl = `https://weebcentral.com/search/data?author=&text=${encodeURIComponent(query)}&sort=Best+Match&order=Descending&official=Any&anime=Any&adult=Any&display_mode=Full+Display`; const html = await this.fetchWithRetry(searchUrl); diff --git a/src/lib/scrapers/weebdex.ts b/src/lib/scrapers/weebdex.ts index cf352bf..7c6541e 100644 --- a/src/lib/scrapers/weebdex.ts +++ b/src/lib/scrapers/weebdex.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { BaseScraper } from './base'; -import { ScrapedChapter, SearchResult, SourceType } from '@/types'; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from '@/types'; export class WeebdexScraper extends BaseScraper { private readonly baseUrl = 'https://weebdex.org'; @@ -129,6 +129,25 @@ export class WeebdexScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const match = chapterUrl.match(/\/chapter\/([^/?]+)/); + if (!match) return []; + + const chapterId = match[1]; + const data = await this.fetchApi(`${this.apiBase}/chapter/${chapterId}`); + const node = data.node; + const files: string[] = data.data_optimized || data.data || []; + + return files.map((filename, index) => ({ + url: `${node}/data/${chapterId}/${filename}`, + page: index + 1, + })); + } + private async getEnglishChapterCount(mangaId: string): Promise { try { const data = await this.fetchApi( diff --git a/src/lib/scrapers/witchscans.ts b/src/lib/scrapers/witchscans.ts index c39cec1..8593200 100644 --- a/src/lib/scrapers/witchscans.ts +++ b/src/lib/scrapers/witchscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class WitchscansScraper extends BaseScraper { private readonly BASE_URL = "https://witchscans.com"; @@ -94,6 +94,24 @@ export class WitchscansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const match = html.match(/ts_reader\.run\((\{.*?\})\)/s); + if (!match) return []; + + try { + const data = JSON.parse(match[1]); + const images: string[] = data.sources?.[0]?.images || []; + return images.map((url, index) => ({ url, page: index + 1 })); + } catch { + return []; + } + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /-chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/writerscans.ts b/src/lib/scrapers/writerscans.ts index 5a9a155..30f8fe1 100644 --- a/src/lib/scrapers/writerscans.ts +++ b/src/lib/scrapers/writerscans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class WritersScansScraper extends BaseScraper { private readonly BASE_URL = "https://writerscans.com"; @@ -98,6 +98,25 @@ export class WritersScansScraper extends BaseScraper { return -1; } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $("#pages img[uid]").each((_, el) => { + const uid = $(el).attr("uid")?.trim(); + if (uid) { + images.push({ url: `https://cdn.meowing.org/uploads/${uid}`, page: images.length + 1 }); + } + }); + + return images; + } + async search(query: string): Promise { const searchUrl = `${this.BASE_URL}/series?q=${encodeURIComponent(query)}`; const html = await this.fetchWithRetry(searchUrl); diff --git a/src/lib/scrapers/yakshacomics.ts b/src/lib/scrapers/yakshacomics.ts index dea86ff..6560c36 100644 --- a/src/lib/scrapers/yakshacomics.ts +++ b/src/lib/scrapers/yakshacomics.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class YakshacomicsScraper extends BaseScraper { private readonly BASE_URL = "https://yakshacomics.com"; @@ -108,6 +108,25 @@ export class YakshacomicsScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/lib/scrapers/yakshascans.ts b/src/lib/scrapers/yakshascans.ts index 1d1098f..d4bf2db 100644 --- a/src/lib/scrapers/yakshascans.ts +++ b/src/lib/scrapers/yakshascans.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as cheerio from "cheerio"; import { BaseScraper } from "./base"; -import { ScrapedChapter, SearchResult, SourceType } from "@/types"; +import { ChapterImage, ScrapedChapter, SearchResult, SourceType } from "@/types"; export class YakshascansScraper extends BaseScraper { private readonly BASE_URL = "https://yakshascans.com"; @@ -108,6 +108,25 @@ export class YakshascansScraper extends BaseScraper { return chapters.sort((a, b) => a.number - b.number); } + override supportsChapterImages(): boolean { + return true; + } + + async getChapterImages(chapterUrl: string): Promise { + const html = await this.fetchWithRetry(chapterUrl); + const $ = cheerio.load(html); + const images: ChapterImage[] = []; + + $(".reading-content .page-break img, .reading-content img.wp-manga-chapter-img").each((_, el) => { + const url = $(el).attr("data-src")?.trim() || $(el).attr("src")?.trim(); + if (url && !url.includes("loading") && !url.includes("placeholder")) { + images.push({ url, page: images.length + 1 }); + } + }); + + return images; + } + protected extractChapterNumber(chapterUrl: string): number { const patterns = [ /chapter[/-](\d+)(?:[.-](\d+))?/i, diff --git a/src/types/index.ts b/src/types/index.ts index 1f889f8..11b9f63 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -26,6 +26,11 @@ export interface SearchResult { followers?: string; } +export interface ChapterImage { + url: string; + page: number; +} + export type SourceType = "scanlator" | "aggregator"; export interface SourceInfo {