diff --git a/src/index.ts b/src/index.ts index da8c962..4c6ac51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import comick from "./routes/v1/manga/comick/ComickRoutes"; import inmanga from "./routes/v1/manga/inmanga/InmangaRoutes"; import nhentai from "./routes/v1/manga/nhentai/NhentaiRoutes" import mangareader from "./routes/v1/manga/mangareader/MangaReaderRoutes"; +import manganelo from "./routes/v1/manga/manganelo/ManganeloRoutes"; import helmet from "helmet"; import cors from 'cors' import WcoStream from "./routes/v1/anime/wcostream/wcostreamRoutes"; @@ -45,6 +46,7 @@ app.use(comick); app.use(inmanga); app.use(nhentai) app.use(mangareader); +app.use(manganelo); /*Manga*/ diff --git a/src/routes/v1/manga/manganelo/ManganeloRoutes.ts b/src/routes/v1/manga/manganelo/ManganeloRoutes.ts new file mode 100644 index 0000000..d494d99 --- /dev/null +++ b/src/routes/v1/manga/manganelo/ManganeloRoutes.ts @@ -0,0 +1,33 @@ +import { manganatoOrderByOptionsList } from "@providers/manganelo/ManganatoTypes"; +import { Router } from "express"; +import { Manganelo } from "../../../../scraper/sites/manga/manganelo/Manganelo"; + +const router = Router(); +const manganelo = new Manganelo(); + +router.get(`/manga/${manganelo.name}/title/:id`, async (req, res) => { + const result = await manganelo.GetMangaInfo( + req.params.id as unknown as string, + ); + + return res.status(200).send(result); +}); + +router.get(`/manga/${manganelo.name}/filter`, async (req, res) => { + const result = await manganelo.Filter({ + sts: req.query.status as unknown as "ongoing" | "completed", + genres: req.query.genres as unknown as string, + orby: req.query.order as unknown as typeof manganatoOrderByOptionsList[number], + page: req.query.page as unknown as number + }); + + return res.status(200).send(result); +}) + +router.get(`/manga/${manganelo.name}/chapter/:id`, async (req, res) => { + const result = await manganelo.GetMangaChapters(req.params.id as unknown as string, req.query.num as unknown as number); + + return res.status(200).send(result); +}); + +export default router; diff --git a/src/scraper/sites/manga/manganelo/ManganatoManagerUtils.ts b/src/scraper/sites/manga/manganelo/ManganatoManagerUtils.ts new file mode 100644 index 0000000..92dabde --- /dev/null +++ b/src/scraper/sites/manga/manganelo/ManganatoManagerUtils.ts @@ -0,0 +1,12 @@ +import { ManganatoAdvancedSearchURLManager } from "./managers/ManganatoURLManager"; + +export class ManganatoManagerUtils { + private static instance: ManganatoManagerUtils; + readonly url: ManganatoAdvancedSearchURLManager = new ManganatoAdvancedSearchURLManager(); + + static get Instance() { + if (!this.instance) this.instance = new ManganatoManagerUtils(); + + return this.instance; + } +} diff --git a/src/scraper/sites/manga/manganelo/ManganatoTypes.ts b/src/scraper/sites/manga/manganelo/ManganatoTypes.ts new file mode 100644 index 0000000..925d79e --- /dev/null +++ b/src/scraper/sites/manga/manganelo/ManganatoTypes.ts @@ -0,0 +1,61 @@ +export const manganatoOrderByOptionsList = ["topview", "newest", "az"] as const; +export type manganatoOrderByOptions = typeof manganatoOrderByOptionsList[number]; + +export interface IManganatoFilterParams { + /** + * Manga status + * + * Available status: "ongoing", "completed", empty string | null (for both) + */ + sts: "ongoing" | "completed"; + /** Order by */ + orby: manganatoOrderByOptions; + genres: string; + /** Results page */ + page: number; +}; + +export const manganatoGenreList = { + action: 2, + adult: 3, + adventure: 4, + comedy: 6, + cooking: 7, + doujinshi: 9, + drama: 10, + ecchi: 11, + fantasy: 12, + genderbender: 13, + harem: 14, + historical: 15, + horror: 16, + josei: 17, + martialarts: 19, + mature: 20, + mecha: 21, + medical: 22, + mystery: 24, + oneshot: 25, + psychological: 26, + romance: 27, + schoollife: 28, + scifi: 29, + seinen: 30, + shoujo: 31, + shoujoai: 32, + shounen: 33, + shounenai: 34, + sliceoflife: 35, + smut: 36, + sports: 37, + supernatural: 38, + tragedy: 39, + webtoons: 40, + yaoi: 41, + yuri: 42, + manhwa: 43, + manhua: 44, + isekai: 45, + pornographic: 47, + erotica: 48 +} as const; diff --git a/src/scraper/sites/manga/manganelo/Manganelo.ts b/src/scraper/sites/manga/manganelo/Manganelo.ts new file mode 100644 index 0000000..99c6b91 --- /dev/null +++ b/src/scraper/sites/manga/manganelo/Manganelo.ts @@ -0,0 +1,163 @@ +import { IMangaResult, Manga, MangaChapter } from "../../../../types/manga"; +import axios from "axios"; +import { load } from "cheerio"; +import { Image } from "../../../../types/image"; +import { ManganatoManagerUtils } from "./ManganatoManagerUtils"; +import { IManganatoFilterParams } from "./ManganatoTypes"; +import { ResultSearch } from "../../../../types/search"; + +export class Manganelo { + private readonly url = "https://manganelo.tv"; //chapmanganelo.com //mangakakalot.tv; + readonly name = "manganelo"; + private readonly manager = ManganatoManagerUtils.Instance; + + private GetMangaDescription(data: cheerio.Root) { + if ( + data("div#panel-story-info-description").length == 0 && + data("div#panel-story-info-description h3").length == 0 + ) + return null; + + data("div#panel-story-info-description h3").remove(); + + return data("div#panel-story-info-description").text().trim(); + } + + private GetMangaStatus(data: cheerio.Root) { + const selector = data("div.panel-story-info > div.story-info-right > table > tbody > tr:nth-child(3) > td.table-value"); + + if (selector.length == 0) + return null; + + if (selector.text().trim() == "Ongoing") + return "ongoing"; + else + return "completed"; + } + + private GetMangaAuthors(data: cheerio.Root): string[] | null { + const selector = data("div.panel-story-info > div.story-info-right > table > tbody > tr:nth-child(2) > td.table-value"); + + if (selector.length == 0 && selector.find("a.a-h").length == 0) + return null; + + return selector.find("a.a-h").map((_, element) => { + return data(element).text().trim(); + }).get(); + } + + private GetMangaGenres(data: cheerio.Root): string[] | null { + const selector = data("div.panel-story-info > div.story-info-right > table > tbody > tr:nth-child(4) > td.table-value"); + + if (selector.length == 0 && selector.find("a.a-h").length == 0) + return null; + + return selector.find("a.a-h").map((_, element) => { + return data(element).text().trim(); + }).get(); + } + + private isNsfw(genres: string[]) { + return genres.some(genre => genre === "Pornographic" || genre === "Mature" || genre === "Erotica"); + } + + private GetMangaPages(data: cheerio.Root) { + if (data("div.container-chapter-reader").length == 0 && data("div.container-chapter-reader > img").length == 0) + return null; + + return data("div.container-chapter-reader > img").map((_, element) => data(element).attr("data-src")).get(); + } + + private GetMangaSearchResults(data: cheerio.Root): IMangaResult[] | null { + const section = data("div.panel-content-genres"); + if (section.length === 0) + return null; + + return section.find("div.content-genres-item").map((_, element) => { + const mangaResultId = data(element).find("a.genres-item-img").attr("href").split("-").at(-1); + const name = data(element).find("a.genres-item-img").attr("title").trim(); + + const mangaInfoResults: IMangaResult = { + id: mangaResultId, + title: name, + url: `/manga/${this.name}/title/${mangaResultId}` + } + + return mangaInfoResults; + }).get(); + } + + async GetMangaInfo(mangaId: string) { + const { data } = await axios.get(`${this.url}/manga/manga-${mangaId}`); + const $ = load(data); + + const manga = new Manga; + + const title = $("div.panel-story-info > div.story-info-right > h1").text().trim(); + const description = this.GetMangaDescription($); + const thumbnail = this.url + $("div.panel-story-info > div.story-info-left > span.info-image > img").attr("src"); + const altTitle = $("table > tbody > tr:nth-child(1) > td.table-value > h2").text().trim(); + const status = this.GetMangaStatus($); + const authors = this.GetMangaAuthors($); + const genres = this.GetMangaGenres($); + const chapters = $("div.panel-story-chapter-list").find("ul > li.a-h").map((_, element) => { + const chapter = new MangaChapter; + const url = $(element).find("a.chapter-name").attr("href"); + + const chapterId = url.substring(url.lastIndexOf("-") + 1); + + chapter.id = Number(chapterId); + chapter.title = $(element).find("a.chapter-name").text().trim(); + chapter.url = `/manga/${this.name}/chapter/${mangaId}?num=${chapterId}`; + chapter.number = Number(chapterId); + chapter.images = null; + + return chapter; + }).get(); + + manga.id = mangaId; + manga.url = `/manga/${this.name}/title/${mangaId}`; + manga.title = title; + manga.altTitles = Array.of(altTitle); + manga.thumbnail = new Image(thumbnail); + manga.description = description; + manga.status = status; + manga.authors = authors; + manga.genres = genres; + manga.characters = null; + manga.chapters = chapters; + manga.volumes = null; + manga.isNSFW = this.isNsfw(genres); + + return manga; + } + + async Filter(params: IManganatoFilterParams) { + const url = this.manager.url.generate(params); + + const { data } = await axios.get(url); + const $ = load(data); + + const mangaResultSearch = new ResultSearch(); + mangaResultSearch.results = this.GetMangaSearchResults($); + + return mangaResultSearch; + } + + async GetMangaChapters(mangaId: string, chapterNumber: number) { + const { data } = await axios.get(`${this.url}/chapter/manga-${mangaId}/chapter-${chapterNumber}`); + const $ = load(data); + + const images = this.GetMangaPages($); + const name = $("body > div.body-site > div:nth-child(1) > div.panel-breadcrumb > a").eq(-1).attr("title") || null; + const chapter = new MangaChapter; + + chapter.id = Number(chapterNumber); + chapter.title = name; + chapter.url = `/manga/${this.name}/chapter/${mangaId}?num=${chapterNumber}`; + chapter.number = Number(chapterNumber); + chapter.images = images; + + return chapter; + } +} diff --git a/src/scraper/sites/manga/manganelo/managers/ManganatoManager.ts b/src/scraper/sites/manga/manganelo/managers/ManganatoManager.ts new file mode 100644 index 0000000..7d0863a --- /dev/null +++ b/src/scraper/sites/manga/manganelo/managers/ManganatoManager.ts @@ -0,0 +1,3 @@ +export abstract class ManganatoManager { + abstract generate(item: unknown, ...args: unknown[]): unknown; +} diff --git a/src/scraper/sites/manga/manganelo/managers/ManganatoURLManager.ts b/src/scraper/sites/manga/manganelo/managers/ManganatoURLManager.ts new file mode 100644 index 0000000..db9655c --- /dev/null +++ b/src/scraper/sites/manga/manganelo/managers/ManganatoURLManager.ts @@ -0,0 +1,55 @@ +import { URLSearchParams } from "url"; +import { IManganatoFilterParams, manganatoGenreList, manganatoOrderByOptions, manganatoOrderByOptionsList } from "../ManganatoTypes"; +import { ManganatoManager } from "./ManganatoManager"; + +export class ManganatoAdvancedSearchURLManager extends ManganatoManager { + private readonly baseURL = "https://manganato.com/advanced_search"; + private readonly separator = " "; + + private splitGenresToArray(genres: string) { + return genres.split(this.separator); + } + + private processGenres(genresArray: string[]): number[] { + let arrGenerated: number[] = []; + + for (let genre of genresArray) { + if (manganatoGenreList[genre.toLowerCase()]) + arrGenerated.push(manganatoGenreList[genre.toLowerCase()]); + else continue; + } + + return arrGenerated; + } + + private formatGenres(genresProcessedArray: number[]) { + return `_${genresProcessedArray.join("_")}_`; + } + + private processStatus(status: unknown) { + return (typeof status === "string" && (status.toLowerCase() === "ongoing" || status.toLowerCase() === "completed")) + ? status + : ""; + } + + private processOrderBy(order: unknown) { + return (typeof order === "string" && manganatoOrderByOptionsList.includes(order.toLowerCase() as manganatoOrderByOptions)) + ? order + : ""; + } + + generate(params: IManganatoFilterParams) { + const splitted = this.splitGenresToArray(params.genres); + const processed = this.processGenres(splitted); + + const urlParams = new URLSearchParams({ + s: "all", + g_i: processed.length ? this.formatGenres(processed) : "", + sts: this.processStatus(params.sts), + orby: this.processOrderBy(params.orby), + page: params.page ? params.page.toString() : "" + }); + + return `${this.baseURL}?${urlParams.toString()}`; + } +} diff --git a/src/test/Manganelo.spec.ts b/src/test/Manganelo.spec.ts new file mode 100644 index 0000000..6353a9f --- /dev/null +++ b/src/test/Manganelo.spec.ts @@ -0,0 +1,133 @@ +import { Manganelo } from '../scraper/sites/manga/manganelo/Manganelo'; +import { IManganatoFilterParams, manganatoGenreList } from '../scraper/sites/manga/manganelo/ManganatoTypes'; + +type ManganeloTestTemplate = { + id: string; + title: string; + status: "ongoing" | "completed"; + nsfw: boolean; +}; + +type ManganeloGenresOptions = keyof typeof manganatoGenreList; + +interface ManganeloFilterTestTemplate extends Omit { + genres: ManganeloGenresOptions[]; +}; + +type ManganeloChapterTestTemplate = { + id: string; + num: number; +}; + +describe('Manganelo', () => { + let manganelo: Manganelo; + + beforeEach(() => { + manganelo = new Manganelo(); + }); + + it('should get manga info successfully', async () => { + + const testsSuites: ManganeloTestTemplate[] = [ + { + id: 'md990312', + nsfw: false, + status: 'ongoing', + title: 'Your Eternal Lies' + }, + { + id: 'he984887', + nsfw: false, + status: 'ongoing', + title: 'The Peerless Sword God' + }, + { + id: 'go983949', + nsfw: false, + status: 'ongoing', + title: 'Bite Into Me' + }, + { + id: 'oj992266', + nsfw: true, + status: 'ongoing', + title: 'Dekiai Osananajimi Ha Watashi No Otto De Stalker!?' + } + ]; + + testsSuites.forEach(async (options) => { + const mangaInfo = await manganelo.GetMangaInfo(options.id); + expect(mangaInfo.title).toStrictEqual(options.title); + + if (mangaInfo.altTitles) + expect(mangaInfo.altTitles.length).toBeGreaterThanOrEqual(1); + + if (mangaInfo.thumbnail && mangaInfo.thumbnail.url) + expect(mangaInfo.thumbnail.url).toContain('.jpg'); + + expect(mangaInfo.status).toStrictEqual(options.status); + expect(mangaInfo.isNSFW).toStrictEqual(options.nsfw); + + if (mangaInfo.genres) + expect(mangaInfo.genres.length).toBeGreaterThanOrEqual(1); + + if (mangaInfo.chapters) + expect(mangaInfo.chapters.length).toBeGreaterThanOrEqual(1); + }); + }); + + it('should filter manga successfully', async () => { + const filterTestsSuites: ManganeloFilterTestTemplate[] = [ + { + genres: ['action'], + orby: 'az', + page: 3, + sts: 'completed' + }, + { + genres: ['drama', 'romance'], + orby: 'newest', + page: 1, + sts: 'ongoing' + } + ]; + + filterTestsSuites.forEach(async (options) => { + const result = await manganelo.Filter({ + genres: options.genres.join(' '), + orby: options.orby, + page: options.page, + sts: options.sts + }); + + expect(result.results.length).toBeGreaterThanOrEqual(1); + }); + }); + + it('should return manga chapters successfully', async () => { + const chapterTestsSuites: ManganeloChapterTestTemplate[] = [ + { + id: 'he984887', + num: 221 + }, + { + id: 'oj992266', + num: 1 + }, + { + id: 'md990312', + num: 79 + }, + { + id: 'go983949', + num: 2 + } + ]; + + chapterTestsSuites.forEach(async (options) => { + const result = await manganelo.GetMangaChapters(options.id, options.num); + + expect(result.images.length).toBeGreaterThanOrEqual(1); + }); + }); +});