Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/chapters/route.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
47 changes: 24 additions & 23 deletions src/lib/scrapers/comix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -135,11 +136,14 @@ export class ComixScraper extends BaseScraper {
}

async search(query: string): Promise<SearchResult[]> {
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}`);
Expand All @@ -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<string>();
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(),
});
}
}
Expand Down
86 changes: 51 additions & 35 deletions src/lib/scrapers/greedscans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 {
Expand Down Expand Up @@ -111,48 +127,48 @@ export class GreedScansScraper extends BaseScraper {
}

async search(query: string): Promise<SearchResult[]> {
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;
}
}
3 changes: 3 additions & 0 deletions src/lib/scrapers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -140,6 +141,7 @@ const scrapers: BaseScraper[] = [
new HadesScansScraper(),
new ScytheScansScraper(),
new WebtoonScraper(),
new MangaDexScraper(),
];

export function getScraper(url: string): BaseScraper | null {
Expand Down Expand Up @@ -244,4 +246,5 @@ export {
HadesScansScraper,
ScytheScansScraper,
WebtoonScraper,
MangaDexScraper,
};
45 changes: 17 additions & 28 deletions src/lib/scrapers/likemanga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -120,53 +120,42 @@ export class LikeMangaScraper extends BaseScraper {
}

async search(query: string): Promise<SearchResult[]> {
const searchUrl = `https://likemanga.in/?s=${encodeURIComponent(query)}&post_type=wp-manga`;
// likemanga.in rebranded to mgread.io (UIkit theme); results are <article> 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);
}
}
Loading