diff --git a/anify-backend/.env.example b/anify-backend/.env.example index bd8c49c..7ad22a7 100644 --- a/anify-backend/.env.example +++ b/anify-backend/.env.example @@ -1,42 +1,48 @@ # What port to listen to. Recommended but not required. PORT=3060 -# Database URL for PostgreSQL. If not given, will default to SQLite. + +# Database URL for PostgreSQL. If not given, will default to using SQLite. DATABASE_URL="postgresql://postgres:password@localhost:5432/?connection_limit=100" + +# Censys credentials used for proxy fetching. You can visit https://search.censys.io/account/api, +# create an account, and get the ID and secret. Highly recommended if not required as some providers +# will not work without proxies. +# # Censys ID used for finding CORS proxies https://search.censys.io/account/api. Required for proxies to work properly. CENSYS_ID="" # Censys secret used for finding CORS proxies https://search.censys.io/account/api. Requirded for proxies to work properly. CENSYS_SECRET="" + # 9anime resolver URL. Private server that can be obtained via the Consumet Discord if necessary. Required for 9anime to work properly. # https://discord.gg/yMZTcVstD3 +# NINEANIME_RESOLVER="https://9anime.myresolver.com" # 9anime resolver API key. Required for 9anime to work properly. NINEANIME_KEY="9anime" -# NovelUpdates cookies for login purposes. If you have questions join the Anify Discord (https://anify.tv/discord) + +# NovelUpdates cookies for login purposes. Visit the NovelUpdates website, create an account, and login. +# Then, visit a novel info page (eg. https://novelupdates.com/novel/overgeared/), inspect element, +# go to the network tab, click on the HTML request, and find the cookie that says +# "wordpress_logged_in_..." and copy the value. If not given, the backend should work fine, but +# the NovelUpdates provider will not work. +# # Required for NovelUpdates to work properly for chapter fetching. NOVELUPDATES_LOGIN="" + +# Redis caching. Recommended for optimized performance. The backend will still work +# without Redis, but may face performance issues. +# # Redis URL. Recommended but not required. REDIS_URL="redis://localhost:6379" # Redis cache time in seconds. 18000 = 5 hours. Required for Redis to work properly. REDIS_CACHE_TIME="18000" -# Mixdrop related for uploads. Not required. +# Mixdrop related for uploads. Not required as this is mainly used for manga and novel uploading. +# Signup via https://mixdrop.ag, then view the developer page to get an API key. +# # Whether to use Mixdrop USE_MIXDROP="true" # Mixdrop Email MIXDROP_EMAIL="myemail@outlook.com" # Mixdrop API key -MIXDROP_KEY="mixdrop_key" - -# Related to subtitles and injecting custom text to all subtitles. Not required. -# Secret key for URL encryption. Allows for encrypted subtitle URLs. -SECRET_KEY="anify" -# The text to inject into all subtitles. Can be left blank. -TEXT_TO_INJECT="Provided by anify.tv" -# The distance from the injected text in seconds. 300 = 5 minutes. -DISTANCE_FROM_INJECTED_TEXT_SECONDS=300 -# The cache time for subtitle URLs in seconds. 60 * 60 * 12 = 12 hours. -SUBTITLES_CACHE_TIME="60 * 60 * 12" -# Public URL for the API. Required for subtitle spoofing to work properly. -API_URL="https://api.anify.tv" -# Whether to use subtitle spoofing. Required for subtitle spoofing to work properly. -USE_SUBTITLE_SPOOFING="true" \ No newline at end of file +MIXDROP_KEY="mixdrop_key" \ No newline at end of file diff --git a/anify-backend/.eslintrc.cjs b/anify-backend/.eslintrc.cjs deleted file mode 100644 index 9878765..0000000 --- a/anify-backend/.eslintrc.cjs +++ /dev/null @@ -1,18 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const path = require("path"); - -/** @type {import("eslint").Linter.Config} */ -const config = { - parser: "@typescript-eslint/parser", - parserOptions: { - project: path.join(__dirname, "tsconfig.json"), - }, - plugins: ["@typescript-eslint"], - extends: ["plugin:@typescript-eslint/recommended"], - rules: { - "@typescript-eslint/no-explicit-any": "off", - }, - ignorePatterns: ["dist/*", "node_modules/*", ".DS_Store"], -}; - -module.exports = config; diff --git a/anify-backend/.gitignore b/anify-backend/.gitignore index cba19bc..7790f84 100644 --- a/anify-backend/.gitignore +++ b/anify-backend/.gitignore @@ -171,4 +171,5 @@ dist /manga db.sqlite keys.json -bannedIds.json \ No newline at end of file +bannedIds.json +test.ts \ No newline at end of file diff --git a/anify-backend/eslint.config.mjs b/anify-backend/eslint.config.mjs new file mode 100644 index 0000000..8b4dd0c --- /dev/null +++ b/anify-backend/eslint.config.mjs @@ -0,0 +1,40 @@ +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + ignores: ["dist/*", "node_modules/*", "**/.DS_Store"], + }, + ...compat.extends("plugin:@typescript-eslint/recommended"), + { + plugins: { + "@typescript-eslint": typescriptEslint, + }, + + languageOptions: { + parser: tsParser, + ecmaVersion: 5, + sourceType: "script", + + parserOptions: { + project: "tsconfig.json", + }, + }, + + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, +]; diff --git a/anify-backend/package.json b/anify-backend/package.json index 1230a75..a5f9a71 100644 --- a/anify-backend/package.json +++ b/anify-backend/package.json @@ -11,6 +11,7 @@ "import": "bun run src/scripts/import.ts", "clear": "bun run src/scripts/clear.ts", "check:proxies": "bun run src/scripts/checkProxies.ts", + "check:proxies:current": "bun run src/scripts/checkCurrentProxies.ts", "scrape:proxies": "bun run src/scripts/scrapeProxies.ts", "build": "bun build ./src/index.ts --outdir ./dist --target node", "build:db": "bun run src/scripts/buildDb.ts", @@ -19,14 +20,16 @@ "lint": "bun run prettier && bun run eslint" }, "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.10.0", "@types/crypto-js": "^4.1.2", "@types/pdfkit": "^0.12.10", "@types/pg": "^8.10.7", - "@typescript-eslint/eslint-plugin": "^6.19.1", "bun-types": "latest", - "eslint": "^8.56.0", + "eslint": "^9.10.0", "prettier": "^3.0.3", - "tsc": "^2.0.4" + "tsc": "^2.0.4", + "typescript-eslint": "^8.6.0" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/anify-backend/src/content/impl/chapters.ts b/anify-backend/src/content/impl/chapters.ts index d9bb3df..93d677c 100644 --- a/anify-backend/src/content/impl/chapters.ts +++ b/anify-backend/src/content/impl/chapters.ts @@ -54,7 +54,7 @@ export const fetchChapters = async (id: string): Promise => { }); } return true; - } catch (e) { + } catch { return false; } }); diff --git a/anify-backend/src/content/impl/episodes.ts b/anify-backend/src/content/impl/episodes.ts index 7240339..f65c289 100644 --- a/anify-backend/src/content/impl/episodes.ts +++ b/anify-backend/src/content/impl/episodes.ts @@ -40,7 +40,7 @@ export const fetchEpisodes = async (id: string): Promise => { }); } return true; - } catch (e) { + } catch { return false; } }); diff --git a/anify-backend/src/content/impl/metadata.ts b/anify-backend/src/content/impl/metadata.ts index 5f54a3d..b5ffcd6 100644 --- a/anify-backend/src/content/impl/metadata.ts +++ b/anify-backend/src/content/impl/metadata.ts @@ -37,7 +37,7 @@ export const fetchMetaData = async (id: string): Promise => { }); } return true; - } catch (e) { + } catch { return false; } }); diff --git a/anify-backend/src/content/impl/pages.ts b/anify-backend/src/content/impl/pages.ts index 1c9661b..a7ff3f8 100644 --- a/anify-backend/src/content/impl/pages.ts +++ b/anify-backend/src/content/impl/pages.ts @@ -18,7 +18,7 @@ export const fetchPages = async (providerId: string, readId: string): Promise(type: T, formats: Form } }); } - } catch (e) { + } catch { // } @@ -136,7 +136,7 @@ export const recent = async (type: T, formats: Form characters: JSON.parse(anime.characters), }) as unknown as Anime, ); - } catch (e) { + } catch { continue; } } @@ -155,7 +155,7 @@ export const recent = async (type: T, formats: Form } }); } - } catch (e) { + } catch { // } @@ -202,7 +202,7 @@ export const recent = async (type: T, formats: Form characters: JSON.parse(manga.characters), }) as unknown as Manga, ); - } catch (e) { + } catch { continue; } } @@ -221,7 +221,7 @@ export const recent = async (type: T, formats: Form } }); } - } catch (e) { + } catch { // } diff --git a/anify-backend/src/database/impl/fetch/relations.ts b/anify-backend/src/database/impl/fetch/relations.ts index b6f076f..29b8772 100644 --- a/anify-backend/src/database/impl/fetch/relations.ts +++ b/anify-backend/src/database/impl/fetch/relations.ts @@ -64,7 +64,7 @@ export const relations = async (id: string, fields: string[] = []): Promise artwork: data.artwork ? JSON.parse((data as any).artwork) : null, characters: data.characters ? JSON.parse((data as any).characters) : null, }); - } catch (e) { + } catch { // } } @@ -36,7 +36,7 @@ export const create = async (data: Anime | Manga, stringify: boolean = true) => Object.assign(data, { episodes: JSON.parse((data as any).episodes), }); - } catch (e) { + } catch { // } } @@ -146,7 +146,7 @@ export const create = async (data: Anime | Manga, stringify: boolean = true) => Object.assign(data, { chapters: JSON.parse((data as any).chapters), }); - } catch (e) { + } catch { // } } diff --git a/anify-backend/src/database/impl/search/search.ts b/anify-backend/src/database/impl/search/search.ts index a8ffb62..db9b744 100644 --- a/anify-backend/src/database/impl/search/search.ts +++ b/anify-backend/src/database/impl/search/search.ts @@ -138,7 +138,7 @@ export const search = async ( return data; } - } catch (e) { + } catch { return undefined; } }); diff --git a/anify-backend/src/database/impl/search/searchAdvanced.ts b/anify-backend/src/database/impl/search/searchAdvanced.ts index 1c393cd..6de1e4e 100644 --- a/anify-backend/src/database/impl/search/searchAdvanced.ts +++ b/anify-backend/src/database/impl/search/searchAdvanced.ts @@ -190,7 +190,7 @@ export const searchAdvanced = async ( return data; } - } catch (e) { + } catch { return undefined; } }); diff --git a/anify-backend/src/env.ts b/anify-backend/src/env.ts index 8a9331f..48879a8 100644 --- a/anify-backend/src/env.ts +++ b/anify-backend/src/env.ts @@ -2,25 +2,12 @@ export const env = { PORT: Number(process.env.PORT ?? 3000), DATABASE_URL: process.env.DATABASE_URL ?? "", - NINEANIME_RESOLVER: process.env.NINEANIME_RESOLVER, - NINEANIME_KEY: process.env.NINEANIME_KEY, NOVELUPDATES_LOGIN: process.env.NOVELUPDATES_LOGIN, REDIS_URL: process.env.REDIS_URL, REDIS_CACHE_TIME: Number(process.env.REDIS_CACHE_TIME ?? 60 * 60 * 24 * 7), CENSYS_ID: process.env.CENSYS_ID, CENSYS_SECRET: process.env.CENSYS_SECRET, - SIMKL_CLIENT_ID: process.env.SIMKL_CLIENT_ID, USE_MIXDROP: process.env.USE_MIXDROP === "true" || false, MIXDROP_EMAIL: process.env.MIXDROP_EMAIL, MIXDROP_KEY: process.env.MIXDROP_KEY, - SECRET_KEY: process.env.SECRET_KEY ?? "anifydobesupercoolbrodudeawesome", // MUST BE 32 CHARACTERS - TEXT_TO_INJECT: process.env.TEXT_TO_INJECT ?? "Provided by anify.tv", - DISTANCE_FROM_INJECTED_TEXT_SECONDS: Number(process.env.DISTANCE_FROM_INJECTED_TEXT ?? 300), - DURATION_FOR_INJECTED_TEXT_SECONDS: Number(process.env.DISTANCE_FROM_INJECTED_TEXT ?? 5), - SUBTITLES_CACHE_TIME: Number(process.env.SUBTITLES_CACHE_TIME ?? 60 * 60 * 12), - API_URL: process.env.API_URL ?? "https://api.anify.tv", - VIDEO_PROXY_URL: process.env.VIDEO_PROXY_URL ?? "https://anify.anistreme.live", - USE_SUBTITLE_SPOOFING: process.env.USE_SUBTITLE_SPOOFING === "true" || false, - DISABLE_INTRO_VIDEO_SPOOFING: process.env.DISABLE_INTRO_VIDEO_SPOOFING === "true" || false, - USE_INLINE_SUBTITLE_SPOOFING: process.env.USE_INLINE_SUBTITLE_SPOOFING === "true" || false, }; diff --git a/anify-backend/src/helper/extractor.ts b/anify-backend/src/helper/extractor.ts index 9de5f9c..f3db475 100644 --- a/anify-backend/src/helper/extractor.ts +++ b/anify-backend/src/helper/extractor.ts @@ -4,7 +4,8 @@ import { Source } from "../types/types"; import { StreamingServers } from "../types/enums"; import Http from "./request"; import { animeProviders } from "../mappings"; -import { env } from "../env"; +import colors from "colors"; +import { b64decode, b64encode, rc4Cypher } from "."; /** * @description Extracts source links from the streaming servers. This class is very messy but it works. @@ -32,6 +33,10 @@ export default class Extractor { return await this.extractStreamSB(this.url, this.result); case StreamingServers.VidCloud: return await this.extractVidCloud(this.url, this.result); + case StreamingServers.Vidstream: + return await this.extractVidstream(this.url, this.result); + case StreamingServers.MegaF: + return await this.extractMegaF(this.url, this.result); case StreamingServers.VidStreaming: return await this.extractGogoCDN(this.url, this.result); case StreamingServers.StreamTape: @@ -118,7 +123,7 @@ export default class Extractor { url: subtitle.url, }); }); - } catch (e) { + } catch { // } @@ -126,8 +131,10 @@ export default class Extractor { } public async extractMyCloud(url: string, result: Source): Promise { - const proxy = env.NINEANIME_RESOLVER || "https://9anime.resolver.net"; - const proxyKey: string = env.NINEANIME_KEY || `9anime`; + //const proxy = env.NINEANIME_RESOLVER || "https://9anime.resolver.net"; + //const proxyKey: string = env.NINEANIME_KEY || `9anime`; + const proxy = "https://9anime.resolver.net"; + const proxyKey: string = `9anime`; const lolToken = await (await fetch("https://mcloud.to/futoken")).text(); @@ -188,8 +195,11 @@ export default class Extractor { } public async extractFileMoon(url: string, result: Source): Promise { - const proxy = env.NINEANIME_RESOLVER || "https://9anime.resolver.com"; - const proxyKey: string = env.NINEANIME_KEY || `9anime`; + //const proxy = env.NINEANIME_RESOLVER || "https://9anime.resolver.net"; + //const proxyKey: string = env.NINEANIME_KEY || `9anime`; + const proxy = "https://9anime.resolver.net"; + const proxyKey: string = `9anime`; + const data = await (await fetch(`https://filemoon.sx/d/${url}`)).text(); const resolver = await fetch(`${proxy}/filemoon?apikey=${proxyKey}`, { @@ -223,14 +233,185 @@ export default class Extractor { return result; } + public async extractVidstream(url: string, result: Source): Promise { + const data = (await (await fetch(url)).json()) as { + status: number; + result: { + url: string; + skip_data: string; + }; + }; + + if (data.status !== 200) return result; + + try { + const decodedURL = decodeURIComponent(rc4Cypher("ctpAbOz5u7S6OMkx", b64decode("".concat(data.result.url).replace(/_/g, "/").replace(/-/g, "+")))); + + try { + const githubReq = await (await fetch("https://github.com/Ciarands/vidsrc-keys/blob/main/keys.json")).text(); + const keys = JSON.parse(githubReq.match(/"rawLines":\["(.+?)"],"styling/)![1].replaceAll("\\", "")) as { + encrypt: string[]; + decrypt: string[]; + }; + + const videoId = decodedURL.split("/e/")[1].split("?")[0]; + const urlEnd = "?" + decodedURL.split("?").pop(); + + const encodeElement = (input: string, key: string) => { + input = encodeURIComponent(input); + const e = rc4Cypher(key, input); + const out = b64encode(e).replace(/\//g, "_").replace(/\+/g, "-"); + return out; + }; + + const encodedVideoId = encodeElement(videoId, keys.encrypt[1]); + const h = encodeElement(videoId, keys.encrypt[2]); + const videoURL = `https://vid2v11.site/mediainfo/${encodedVideoId}${urlEnd}&ads=0&h=${encodeURIComponent(h)}`; + + const encryptedSources = (await ( + await fetch(videoURL, { + method: "GET", + headers: { + Referer: videoURL, + }, + }) + ).json()) as { + status: number; + result: string; + }; + + try { + const decryptedSources = JSON.parse(decodeURIComponent(rc4Cypher(keys.decrypt[1], b64decode("".concat(encryptedSources.result).replace(/_/g, "/").replace(/-/g, "+"))))) as { + sources: { + file: string; + }[]; + tracks: { + file: string; + kind: "captions" | "thumbnails"; + label?: string; + }[]; + }; + + for (const source of decryptedSources.sources) { + result.sources.push({ + quality: "auto", + url: source.file, + }); + } + + for (const track of decryptedSources.tracks) { + result.subtitles.push({ + url: track.file, + label: track.kind, + lang: track.label ?? track.kind, + }); + } + } catch (e) { + console.error(e); + console.error(colors.red("Failed to decrypt video sources!")); + } + } catch { + console.log(colors.red("Failed to decode video data! Trying using private key extractor.")); + const keys = (await ( + await fetch("https://anithunder.vercel.app/api/keys", { + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }) + ).json()) as string[]; + + const videoId = decodedURL.split("/e/")[1].split("?")[0]; + const urlEnd = "?" + decodedURL.split("?").pop(); + + const encodeElement = (input: string, key: string) => { + input = encodeURIComponent(input); + const e = rc4Cypher(key, input); + const out = b64encode(e).replace(/\//g, "_").replace(/\+/g, "-"); + return out; + }; + + const encodedVideoId = encodeElement(videoId, keys[1]); + const h = encodeElement(videoId, keys[2]); + const videoURL = `https://vid2v11.site/mediainfo/${encodedVideoId}${urlEnd}&ads=0&h=${encodeURIComponent(h)}`; + + const encryptedSources = (await fetch(videoURL, { + method: "GET", + headers: { + Referer: videoURL, + }, + }).then((req) => req.json())) as { + status: number; + result: string; + }; + + try { + const decryptedSources = JSON.parse(decodeURIComponent(rc4Cypher(keys[2], b64decode("".concat(encryptedSources.result).replace(/_/g, "/").replace(/-/g, "+"))))) as { + sources: { + file: string; + }[]; + tracks: { + file: string; + kind: "captions" | "thumbnails"; + label?: string; + }[]; + }; + + for (const source of decryptedSources.sources) { + result.sources.push({ + quality: "auto", + url: source.file, + }); + } + + for (const track of decryptedSources.tracks) { + result.subtitles.push({ + url: track.file, + label: track.kind, + lang: track.label ?? track.kind, + }); + } + } catch (e) { + console.error(e); + console.error(colors.red("Failed to decrypt video sources!")); + } + } + } catch (e) { + console.error(e); + console.error(colors.red("Failed to decode video data!")); + } + + try { + const decodedSkipData = JSON.parse(decodeURIComponent(rc4Cypher("ctpAbOz5u7S6OMkx", b64decode("".concat(data.result.skip_data).replace(/_/g, "/").replace(/-/g, "+"))))) as { + intro: string[]; + outro: string[]; + }; + result.intro.start = parseInt(decodedSkipData.intro[0]); + result.intro.end = parseInt(decodedSkipData.intro[1]); + + result.outro.start = parseInt(decodedSkipData.outro[0]); + result.outro.end = parseInt(decodedSkipData.outro[1]); + } catch { + console.error(colors.red("Failed to decode skip data!")); + } + + return result; + } + + public async extractMegaF(url: string, result: Source): Promise { + throw new Error(`Method not implemented yet for ${url} and ${result}.`); + } + /** * @description Requires a VizStream ID. Uses NineAnime resolver. * @param vidStreamId VizStream ID * @returns Promise */ public async extractVizCloud(url: string, result: Source): Promise { - const proxy = env.NINEANIME_RESOLVER || "https://9anime.resolver.net"; - const proxyKey: string = env.NINEANIME_KEY || `9anime`; + //const proxy = env.NINEANIME_RESOLVER || "https://9anime.resolver.net"; + //const proxyKey: string = env.NINEANIME_KEY || `9anime`; + const proxy = "https://9anime.resolver.net"; + const proxyKey: string = `9anime`; const futoken = await (await Http.request("9anime", false, "https://vidplay.site/futoken")).text(); @@ -431,29 +612,31 @@ export default class Extractor { let { sources } = reqData as { sources: string }; - //const decryptKey = ((await (await fetch(env.ZORO_EXTRACTOR ? `${env.ZORO_EXTRACTOR}/key/6` : "https://zoro.anify.tv/key/6")).json()) as { key: string }).key as string; - const key = await extractKey(); - - if (key != null) { - let extractedKey = ""; - let strippedSources = sources; - let totalledOffset = 0; - key.forEach(([a, b]) => { - const start = a + totalledOffset; - const end = start + b; - extractedKey += sources.slice(start, end); - strippedSources = strippedSources.replace(sources.substring(start, end), ""); - totalledOffset += b; - }); + if (!sources.includes("m3u8")) { + //const decryptKey = ((await (await fetch(env.ZORO_EXTRACTOR ? `${env.ZORO_EXTRACTOR}/key/6` : "https://zoro.anify.tv/key/6")).json()) as { key: string }).key as string; + const key = await extractKey(); + + if (key != null) { + let extractedKey = ""; + let strippedSources = sources; + let totalledOffset = 0; + key.forEach(([a, b]) => { + const start = a + totalledOffset; + const end = start + b; + extractedKey += sources.slice(start, end); + strippedSources = strippedSources.replace(sources.substring(start, end), ""); + totalledOffset += b; + }); - sources = CryptoJS.AES.decrypt(strippedSources, extractedKey).toString(CryptoJS.enc.Utf8); - } + sources = CryptoJS.AES.decrypt(strippedSources, extractedKey).toString(CryptoJS.enc.Utf8); + } - try { - sources = JSON.parse(sources); - } catch (e) { - console.error(e); - sources = ""; + try { + sources = JSON.parse(sources); + } catch (e) { + console.error(e); + sources = ""; + } } if (!sources || sources.length === 0) { diff --git a/anify-backend/src/helper/index.ts b/anify-backend/src/helper/index.ts index a1cd821..a4f0e6f 100644 --- a/anify-backend/src/helper/index.ts +++ b/anify-backend/src/helper/index.ts @@ -47,3 +47,95 @@ export const averageMetric = (object: any) => { return validCount === 0 ? 0 : Number.parseFloat((average / validCount).toFixed(2)); }; + +export const serializeText = (t: string) => { + return "".concat(b64encode(t)).replace(/\//g, "_").replace(/\+/g, "-"); +}; + +export const rc4Cypher = (key: string, data: string) => { + let n; + const r: any[] = []; + // eslint-disable-next-line no-var + var s = 0; + let o = ""; + for (let u = 0; u < 256; u++) { + r[u] = u; + } + for (let u = 0; u < 256; u++) { + // console.log(t); + s = (s + r[u] + key.charCodeAt(u % key.length)) % 256; + n = r[u]; + r[u] = r[s]; + r[s] = n; + } + let u = 0; + // eslint-disable-next-line no-var + var s = 0; + for (let h = 0; h < data.length; h++) { + n = r[(u = (u + 1) % 256)]; + r[u] = r[(s = (s + r[u]) % 256)]; + r[s] = n; + o += String.fromCharCode(data.charCodeAt(h) ^ r[(r[u] + r[s]) % 256]); + } + return o; +}; + +export const b64encode = (data: string): string => { + data = "".concat(data); + for (let s = 0; s < data.length; s++) { + if (255 < data.charCodeAt(s)) { + return null!; + } + } + let r = ""; + for (let s = 0; s < data.length; s += 3) { + const o: any[] = [undefined, undefined, undefined, undefined]; + o[0] = data.charCodeAt(s) >> 2; + o[1] = (3 & data.charCodeAt(s)) << 4; + if (data.length > s + 1) { + o[1] |= data.charCodeAt(s + 1) >> 4; + o[2] = (15 & data.charCodeAt(s + 1)) << 2; + } + if (data.length > s + 2) { + o[2] |= data.charCodeAt(s + 2) >> 6; + o[3] = 63 & data.charCodeAt(s + 2); + } + for (let u = 0; u < o.length; u++) { + r += + "undefined" == typeof o[u] + ? "=" + : (function (t) { + if (0 <= t && t < 64) { + return "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[t]; + } + })(o[u]); + } + } + return r; +}; + +export const b64decode = (t: string) => { + if ((t = (t = (t = "".concat(t)).replace(/[\t\n\f\r]/g, "")).length % 4 == 0 ? t.replace(/==?$/, "") : t).length % 4 == 1 || /[^+/0-9A-Za-z]/.test(t)) { + return null!; + } + let r; + let s = ""; + let o = 0; + let u = 0; + for (let h = 0; h < t.length; h++) { + r = t[h]; + o = (o <<= 6) | ((r = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".indexOf(r)) < 0 ? undefined : r)!; + if (24 === (u += 6)) { + s = (s = (s += String.fromCharCode((16711680 & o) >> 16)) + String.fromCharCode((65280 & o) >> 8)) + String.fromCharCode(255 & o); + o = u = 0; + } + } + if (12 === u) { + o >>= 4; + s += String.fromCharCode(o); + } else if (18 === u) { + o >>= 2; + s = (s += String.fromCharCode((65280 & o) >> 8)) + String.fromCharCode(255 & o); + } + return s; +}; diff --git a/anify-backend/src/index.ts b/anify-backend/src/index.ts index acc2bae..bbb6b42 100644 --- a/anify-backend/src/index.ts +++ b/anify-backend/src/index.ts @@ -38,7 +38,7 @@ async function before() { }); emitter.on(Events.COMPLETED_SEASONAL_LOAD, async (data) => { - for (let i = 0; i < (data.trending ?? []).length; i++) { + for (let i = 0; i < (data?.trending ?? []).length; i++) { if (data.trending[i].status === MediaStatus.NOT_YET_RELEASED) { continue; } @@ -52,7 +52,7 @@ async function before() { } } - for (let i = 0; i < (data.popular ?? []).length; i++) { + for (let i = 0; i < (data?.popular ?? []).length; i++) { if (data.popular[i].status === MediaStatus.NOT_YET_RELEASED) { continue; } @@ -65,7 +65,7 @@ async function before() { }); } - for (let i = 0; i < (data.top ?? []).length; i++) { + for (let i = 0; i < (data?.top ?? []).length; i++) { if (data.top[i].status === MediaStatus.NOT_YET_RELEASED) { continue; } @@ -78,7 +78,7 @@ async function before() { }); } - for (let i = 0; i < (data.seasonal ?? []).length; i++) { + for (let i = 0; i < (data?.seasonal ?? []).length; i++) { if (data.seasonal[i].status === MediaStatus.NOT_YET_RELEASED) { continue; } diff --git a/anify-backend/src/lib/impl/epub.ts b/anify-backend/src/lib/impl/epub.ts index 8070c55..320c1e7 100644 --- a/anify-backend/src/lib/impl/epub.ts +++ b/anify-backend/src/lib/impl/epub.ts @@ -92,7 +92,7 @@ export const loadEpub = async (data: { id: string; providerId: string; chapters: unlinkSync(parentParentFolder); } } - } catch (e) { + } catch { // } @@ -112,7 +112,7 @@ export const loadEpub = async (data: { id: string; providerId: string; chapters: unlinkSync(parentParentFolder); } } - } catch (e) { + } catch { // } @@ -196,7 +196,7 @@ export const createNovelPDF = async (manga: Manga, providerId: string, chapters: }); for (const i in chapters) { - const html = await mangaProviders[providerId].fetchPages(chapters[i].id); + const html = await mangaProviders[providerId].fetchPages(chapters[i].id, true, chapters[i]); if (!html || typeof html != "string") continue; const $ = load(html); @@ -269,7 +269,7 @@ export const createNovelPDF = async (manga: Manga, providerId: string, chapters: if (existsSync(imgPath)) { try { unlinkSync(imgPath); - } catch (e) { + } catch { console.log(colors.red("Unable to delete file ") + colors.blue(file + ".jpg") + colors.red(".")); } } diff --git a/anify-backend/src/lib/impl/mappings.ts b/anify-backend/src/lib/impl/mappings.ts index 94f319a..a35d411 100644 --- a/anify-backend/src/lib/impl/mappings.ts +++ b/anify-backend/src/lib/impl/mappings.ts @@ -139,7 +139,7 @@ export const map = async (type: Type, formats: Format[], baseData: AnimeInfo | M } return results; - } catch (error) { + } catch { console.log(colors.red(`Error fetching from provider ${colors.blue(provider.id)}. Skipping...`)); return []; } diff --git a/anify-backend/src/lib/impl/pdf.ts b/anify-backend/src/lib/impl/pdf.ts index feff03a..18c09a1 100644 --- a/anify-backend/src/lib/impl/pdf.ts +++ b/anify-backend/src/lib/impl/pdf.ts @@ -34,7 +34,7 @@ export const loadPDF = async (data: { id: string; providerId: string; chapter: C const pdfPath = typeof data.pages === "string" ? "" : await createMangaPDF(data.providerId, data.chapter, data.pages); const file = Bun.file(pdfPath); - if (!file.exists()) return await emitter.emitAsync(Events.COMPLETED_MANGA_UPLOAD, ""); + if (!(await file.exists())) return await emitter.emitAsync(Events.COMPLETED_MANGA_UPLOAD, ""); const form = new FormData(); form.append("email", mixdropEmail); @@ -85,7 +85,7 @@ export const loadPDF = async (data: { id: string; providerId: string; chapter: C unlinkSync(parentParentFolder); } } - } catch (e) { + } catch { // } @@ -105,7 +105,7 @@ export const loadPDF = async (data: { id: string; providerId: string; chapter: C unlinkSync(parentParentFolder); } } - } catch (e) { + } catch { // } @@ -211,7 +211,7 @@ export const createMangaPDF = async (providerId: string, chapter: Chapter, pages width: width, height: height, }); - } catch (e) { + } catch { console.log(colors.red("Unable to add page ") + colors.blue(file + "") + colors.red(" to PDF.")); } } @@ -224,7 +224,7 @@ export const createMangaPDF = async (providerId: string, chapter: Chapter, pages if (existsSync(path)) { try { unlinkSync(path); - } catch (e) { + } catch { console.log(colors.red("Unable to delete file ") + colors.blue(file + ".png") + colors.red(".")); } } @@ -262,7 +262,7 @@ async function downloadFile(url: string, outputPath: string, headers?: Record { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return false; + } else { + return true; + } + } } diff --git a/anify-backend/src/mappings/impl/anime/gogoanime.ts b/anify-backend/src/mappings/impl/anime/gogoanime.ts index 3b76b34..89cd5a8 100644 --- a/anify-backend/src/mappings/impl/anime/gogoanime.ts +++ b/anify-backend/src/mappings/impl/anime/gogoanime.ts @@ -135,4 +135,13 @@ export default class GogoAnime extends AnimeProvider { return await this.fetchSources(serverURL, subType, server ?? StreamingServers.GogoCDN); } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return false; + } else { + return true; + } + } } diff --git a/anify-backend/src/mappings/impl/anime/index.ts b/anify-backend/src/mappings/impl/anime/index.ts index dc555c6..027162e 100644 --- a/anify-backend/src/mappings/impl/anime/index.ts +++ b/anify-backend/src/mappings/impl/anime/index.ts @@ -49,6 +49,10 @@ export default abstract class AnimeProvider { return Http.request(this.id, this.useGoogleTranslate, url, config, proxyRequest, 0, this.customProxy); } + async proxyCheck(): Promise { + return undefined; + } + abstract get subTypes(): SubType[]; abstract get headers(): Record | undefined; } diff --git a/anify-backend/src/mappings/impl/anime/nineanime.ts b/anify-backend/src/mappings/impl/anime/nineanime.ts index 5bdaaa5..1610490 100644 --- a/anify-backend/src/mappings/impl/anime/nineanime.ts +++ b/anify-backend/src/mappings/impl/anime/nineanime.ts @@ -1,24 +1,16 @@ -import AnimeProvider from "."; -import { Episode, Result, Server, Source } from "../../../types/types"; import { load } from "cheerio"; - -import { env } from "../../../env"; +import AnimeProvider from "."; import { Format, Formats, StreamingServers, SubType } from "../../../types/enums"; +import { Episode, Result, Server, Source } from "../../../types/types"; import Extractor from "../../../helper/extractor"; - -/** - * @description DEPRECATED - */ +import { rc4Cypher, serializeText } from "../../../helper"; export default class NineAnime extends AnimeProvider { override rateLimit = 250; override id = "9anime"; - override url = "https://aniwave.ws"; + override url = "https://aniwave.to"; override formats: Format[] = [Format.MOVIE, Format.ONA, Format.OVA, Format.SPECIAL, Format.TV, Format.TV_SHORT]; - private resolver: string | undefined = env.NINEANIME_RESOLVER; - private resolverKey: string | undefined = env.NINEANIME_KEY || `9anime`; - public needsProxy: boolean = true; public overrideProxy: boolean = true; @@ -31,9 +23,7 @@ export default class NineAnime extends AnimeProvider { } override async search(query: string): Promise { - const vrf = await this.getSearchVRF(query); - const data = (await (await this.request(`${this.url}/ajax/anime/search?keyword=${encodeURIComponent(query)}&${vrf.vrfQuery}=${encodeURIComponent(vrf.url)}`)).json()) as { result: { html: string } }; - + const data = (await (await this.request(`${this.url}/ajax/anime/search?keyword=${encodeURIComponent(query)}`)).json()) as { result: { html: string } }; const $ = load(data.result.html); const results: Result[] = $("div.items > a.item") @@ -68,9 +58,7 @@ export default class NineAnime extends AnimeProvider { const nineId = $("#watch-main").attr("data-id")!; - const vrf = await this.getVRF(nineId); - - const req = await this.request(`${this.url}/ajax/episode/list/${nineId}?${vrf.vrfQuery}=${encodeURIComponent(vrf.url)}`); + const req = await this.request(`${this.url}/ajax/episode/list/${nineId}?vrf=${this.getVrf(parseInt(nineId).toString())}`); const $$ = load(((await req.json()) as { result: string }).result); @@ -100,7 +88,7 @@ export default class NineAnime extends AnimeProvider { return episodes; } - override async fetchSources(id: string, subType: SubType = SubType.SUB, server: StreamingServers = StreamingServers.VizCloud): Promise { + override async fetchSources(id: string, subType: SubType = SubType.SUB, server: StreamingServers = StreamingServers.Vidstream): Promise { const result: Source = { sources: [], subtitles: [], @@ -122,9 +110,13 @@ export default class NineAnime extends AnimeProvider { let s = servers.find((s) => s.name === server); switch (server) { - case StreamingServers.VizCloud: - s = servers.find((s) => s.name === "vidplay")!; - if (!s) throw new Error("Vidplay server found"); + case StreamingServers.Vidstream: + s = servers.find((s) => s.name === "vidstream")!; + if (!s) throw new Error("Vidstream server found"); + break; + case StreamingServers.MegaF: + s = servers.find((s) => s.name === "megaf")!; + if (!s) throw new Error("MegaF server found"); break; case StreamingServers.StreamTape: s = servers.find((s) => s.name === "streamtape"); @@ -145,24 +137,9 @@ export default class NineAnime extends AnimeProvider { } const serverId = s.url; - const vrf = await this.getVRF(serverId); + const vrf = await this.getVrf(serverId); - let serverData; - try { - this.useGoogleTranslate = false; - const temp = await (await this.request(`${this.url}/ajax/server/${serverId}?${vrf.vrfQuery}=${vrf.url}`)).text(); - serverData = JSON.parse(temp)?.result.url; - this.useGoogleTranslate = true; - } catch (e) { - console.error(e); - this.useGoogleTranslate = true; - } - - const vidplayURL = (await this.decodeURL(serverData)).url; - - const payload = vidplayURL.split("/").pop()!; - - return await new Extractor(payload, result).extract(server ?? StreamingServers.VizCloud); + return await new Extractor(`${this.url}/ajax/server/${serverId}?vrf=${vrf}`, result).extract(server); } override async fetchServers(id: string, subType: SubType): Promise { @@ -171,12 +148,13 @@ export default class NineAnime extends AnimeProvider { try { const newId = subType === SubType.DUB ? id.split(",")[id.split(",").length - 1] : id.split(",")[1] ?? id.split(",")[0]; - const vrf = await this.getVRF(newId); - const url = `${this.url}/ajax/server/list/${newId}?${vrf.vrfQuery}=${encodeURIComponent(vrf.url)}`; + const vrf = await this.getVrf(newId); + const url = `${this.url}/ajax/server/list/${newId}?vrf=${vrf}`; const json = (await (await this.request(url)).json()) as { result: string }; const $ = load(json.result); + const sub = $("div.servers div.type").attr("data-type"); if ((sub === "softsub" || sub === "sub") && subType === SubType.SUB) { @@ -204,145 +182,15 @@ export default class NineAnime extends AnimeProvider { return servers; } - private async getVRF(query: string): Promise { - if (!this.resolver) - return { - url: query, - vrfQuery: "vrf", - }; - - return (await (await this.request(`${this.resolver}/vrf?query=${encodeURIComponent(query)}&apikey=${this.resolverKey}`, {}, false)).json()) as VRF; - } - - public async getSearchVRF(query: string): Promise { - if (!this.resolver) - return { - url: query, - vrfQuery: "vrf", - }; - - return (await (await this.request(`${this.resolver}/9anime-search?query=${encodeURIComponent(query)}&apikey=${this.resolverKey}`, {}, false)).json()) as VRF; - } - - private async decodeURL(query: string): Promise { - if (!this.resolver) - return { - url: query, - vrfQuery: "vrf", - }; - - return (await (await this.request(`${this.resolver}/decrypt?query=${encodeURIComponent(query)}&apikey=${this.resolverKey}`, {}, false)).json()) as VRF; - } + private getVrf(input: string) { + const idToVrf = (t: string) => { + t = encodeURIComponent(t); - /* - // This bypass works. However because it sends requests very quickly in a short amount of time, it causes proxies to get banned very quickly. - override async request(url: string, options: RequestInit = {}, proxyRequest = true): Promise { - if (url.includes(this.resolver ?? "")) { - return Http.request(this.id, true, url, options, false); - } - const proxy = proxyRequest ? ((this.customProxy?.length ?? 0) > 0 ? this.customProxy : Http.getRandomUnbannedProxy(this.id)) : undefined; - - const headers = { - "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1", - Referer: this.url, + return (function (t) { + return t; + })(serializeText(rc4Cypher("p01EDKu734HJP1Tm", t))); }; - const req1 = await Http.request(this.id, this.useGoogleTranslate, this.url, { headers }, proxyRequest, 0, proxy); - - const data1 = await req1.text(); - - if (!isString(data1)) { - return Http.request(this.id, this.useGoogleTranslate, url, options, proxyRequest, 0, proxy); - } - - // Extract _a and _b values - const _aMatch = data1.match(/var _a\s*=\s*'([0-9a-f]+)'/); - const _bMatch = data1.match(/_b\s*=\s*'([0-9a-f]+)'/); - const _a = _aMatch?.[1]; - const _b = _bMatch?.[1]; - if (!_a || !_b) { - return Http.request(this.id, this.useGoogleTranslate, url, options, proxyRequest, 0, proxy); - } - - // Now fetch k value - const req2 = await Http.request(this.id, this.useGoogleTranslate, `${this.url}/waf-js-run`, { headers }, proxyRequest, 0, proxy); - const data2 = await req2.text(); - - const context = { global: global, data: "" }; - vm.createContext(context); - - vm.runInContext( - ` - const location = { - href: "${this.url}/waf-js-run", - }; - - function EvalDecode(source) { - global._eval = global.eval; - - global.eval = (_code) => { - global.eval = global._eval; - return _code; - }; - - return global._eval(source); - } - - const code = EvalDecode("${data2}"); - data = code; - `, - context, - ); - - const kMatch = context.data.match(/var k='([^']+)'/); - if (!kMatch) { - console.error("Failed to extract k value"); - return Http.request(this.id, this.useGoogleTranslate, url, options, proxyRequest, 0, proxy); - } - const k = kMatch[1]; - - // Construct o value - const l = k.length; - if (l !== _a.length || l !== _b.length) { - console.error("Length of k, _a and _b do not match"); - return Http.request(this.id, this.useGoogleTranslate, url, options, proxyRequest, 0, proxy); - } - const o = Array.from(k) - .map((char, i) => char + _a[i] + _b[i]) - .join(""); - - // Update URL with __jscheck parameter - const updatedUrl = this.url.replace(/&?__jscheck=[^&]+/g, "") + (this.url.indexOf("?") < 0 ? "?" : "&") + "__jscheck=" + o; - - const req3 = await Http.request(this.id, this.useGoogleTranslate, updatedUrl, { headers, redirect: "follow" }, proxyRequest, 0, proxy); - console.log(req3.headers); - - const cookies = req3.headers.get("set-cookie"); - - console.log(await req3.text()); - - return Http.request(this.id, this.useGoogleTranslate, url, { headers: { Cookie: cookies ?? "" }, ...options }, proxyRequest, 0, proxy); - - //return Http.request(url, { headers: { Cookie: cookies?.join("; ") ?? "" }, ...options }, proxyRequest, 0, proxy); + return encodeURIComponent(idToVrf(input)); } - */ - - /* - The waf page evals this: - (function (h) { - var k = 'c419b06b4c6579b50ff05adb3b8424f1', - l = k.length, - u = 'undefined', - i, o = ''; - if (typeof _a == u || typeof _b == u) return; - if (l != _a.length || l != _b.length) return; - for (i = 0; i < l; i++) o += k[i] + _a[i] + _b[i]; - location.href = h.replace(/&?__jscheck=[^&]+/g, '') + (h.indexOf('?') < 0 ? '?' : '&') + '__jscheck=' + o; - })(location.href); - */ } - -type VRF = { - url: string; - vrfQuery: string; -}; diff --git a/anify-backend/src/mappings/impl/anime/sudatchi.ts b/anify-backend/src/mappings/impl/anime/sudatchi.ts new file mode 100644 index 0000000..53f7c3d --- /dev/null +++ b/anify-backend/src/mappings/impl/anime/sudatchi.ts @@ -0,0 +1,217 @@ +import AnimeProvider from "."; +import { load } from "cheerio"; +import { Format, SubType } from "../../../types/enums"; +import { Episode, Result, Source } from "../../../types/types"; + +export default class Sudatchi extends AnimeProvider { + override rateLimit = 250; + override id = "sudatchi"; + override url = "https://sudatchi.com"; + + public needsProxy: boolean = true; + + override formats: Format[] = [Format.MOVIE, Format.ONA, Format.OVA, Format.SPECIAL, Format.TV, Format.TV_SHORT]; + + override get subTypes(): SubType[] { + return [SubType.SUB]; + } + + override get headers(): Record | undefined { + return { Referer: "https://kwik.si" }; + } + + override async search(query: string): Promise { + const results: Result[] = []; + + const data = (await (await this.request(`${this.url}/api/directory?&title=${encodeURIComponent(query)}`)).json()) as { + animes: { + id: number; + anilistId: number; + titleRomanji: string | null; + titleEnglish: string | null; + titleJapanese: string | null; + titleSpanish: string | null; + titleFilipino: string | null; + titleHindi: string | null; + titleKorean: string | null; + synonym: string | null; // Separated by commas + synopsis: string | null; + slug: string; + statusId: number; + typeId: number; + year: number | null; + season: number | null; + totalEpisodes: number | null; + seasonNumber: number | null; + imgUrl: string | null; // Encrypted/obfuscated + imgBanner: string | null; // Encrypted/obfuscated + trailerLink: string | null; + animeCrunchyId: string | null; + crunchyrollId: string | null; + hidiveId: string | null; + seasonHidiveId: string | null; + initialAirDate: string | null; + isAdult: boolean; + prequelId: number | null; + sequelId: number | null; + Type: { + id: number; + name: string; + }; + Status: { + id: number; + name: string; + }; + }[]; + page: number; + pages: number; + }; + + for (const item of data.animes) { + const altTitles: string[] = []; + if (item.titleEnglish) altTitles.push(item.titleEnglish); + if (item.titleJapanese) altTitles.push(item.titleJapanese); + if (item.titleSpanish) altTitles.push(item.titleSpanish); + if (item.titleFilipino) altTitles.push(item.titleFilipino); + if (item.titleHindi) altTitles.push(item.titleHindi); + if (item.titleKorean) altTitles.push(item.titleKorean); + if (item.synonym) altTitles.push(...item.synonym.split(",").map((s) => s.trim())); + + results.push({ + id: item.slug, + altTitles, + format: (item.Type.name as Format) || Format.UNKNOWN, + img: item.imgUrl ? `https://ipfs.animeui.com/ipfs/${item.imgUrl}` : null, + providerId: this.id, + title: item.titleRomanji ?? item.titleEnglish ?? item.titleJapanese ?? item.titleSpanish ?? item.titleFilipino ?? item.titleHindi ?? item.titleKorean ?? "Unknown", + year: item.year ?? 0, + }); + } + + return results; + } + + override async fetchEpisodes(id: string): Promise { + const episodes: Episode[] = []; + + const data = await (await this.request(`${this.url}/anime/${id}`)).text(); + const $ = load(data); + const props = JSON.parse( + $("script#__NEXT_DATA__") + .html()! + .replace(/(\r\n|\n|\r|\t)/gm, ""), + ); + + const animeData = props.props.pageProps.animeData; + const episodeData: [ + { + id: number; + title: string; + number: number; + imgUrl: string; + animeId: number; + isProcessed: boolean; + openingStartsAt: number | null; + openingEndsAt: number | null; + _count: { + Subtitles: number; + AudioStreams: number; + }; + releaseDate: string | null; + subtitleCount: number; + audioCount: number; + }, + ] = animeData.Episodes; + + for (const episode of episodeData) { + episodes.push({ + id: `${btoa(id)}-${episode.id}-${episode.number}`, + description: null, + hasDub: episode.audioCount > 0, + img: episode.imgUrl.startsWith("/images") ? `${this.url}${episode.imgUrl}` : `https://ipfs.animeui.com/ipfs/${episode.imgUrl}`, + isFiller: false, + number: episode.number, + rating: null, + title: episode.title, + updatedAt: episode.releaseDate ? new Date(episode.releaseDate ?? 0).getTime() : undefined, + }); + } + + return episodes; + } + + override async fetchSources(id: string): Promise { + const animeId = atob(id.split("-")[0]); + const episodeId = id.split("-")[1]; + const episodeNumber = id.split("-")[2]; + + const req = await (await this.request(`${this.url}/watch/${animeId}/${episodeNumber}`)).text(); + const $ = load(req); + const props = JSON.parse( + $("script#__NEXT_DATA__") + .html()! + .replace(/(\r\n|\n|\r|\t)/gm, ""), + ); + const streamData = (await (await this.request(`${this.url}/api/streams?episodeId=${episodeId}`)).json()) as { url: string }; + + const parsedSubtitles: { + id: number; + episodeId: number; + subtitleId: number; + url: string; + SubtitlesName: { + id: number; + name: string; + language: string; + }; + }[] = JSON.parse(props.props.pageProps.episodeData.subtitlesJson); + + const subtitles = props.props.pageProps.episodeData.subtitles + .map((a: { id: number; name: string; language: string }) => { + const parsedSubtitle = parsedSubtitles.find((s) => s.subtitleId === a.id); + if (!parsedSubtitle) return null; + + return { + url: `https://ipfs.animeui.com${parsedSubtitle.url}`, + lang: a.language, + label: a.name, + }; + }) + .filter(Boolean); + + return { + intro: { + start: props.props.pageProps.episodeData.episode.openingStartsAt ?? 0, + end: props.props.pageProps.episodeData.episode.openingEndsAt ?? 0, + }, + outro: { + start: 0, + end: 0, + }, + sources: [ + { + quality: "auto", + url: `${this.url}/${streamData.url}`, + }, + ], + subtitles, + headers: props.props.pageProps.headers, + audio: props.props.pageProps.episodeData.episode.AudioStreams.map((a: { id: number; episodeId: number; languageId: number; isDefault: boolean; autoSelect: boolean; playlistUri: string }) => { + return { + url: `https://ipfs.animeui.com/ipfs/${a.playlistUri}`, + name: a.isDefault ? "Default" : `Audio ${a.languageId}`, + language: a.languageId, + }; + }), + }; + } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return false; + } else { + return true; + } + } +} diff --git a/anify-backend/src/mappings/impl/anime/zoro.ts b/anify-backend/src/mappings/impl/anime/zoro.ts index 0b01d94..ccf1e3b 100644 --- a/anify-backend/src/mappings/impl/anime/zoro.ts +++ b/anify-backend/src/mappings/impl/anime/zoro.ts @@ -53,8 +53,13 @@ export default class Zoro extends AnimeProvider { ?.filter(Boolean); const year = $$($$("div.anisc-info-wrap div.anisc-info div.item").toArray()[4]).find("span.name").text().split(" ")[1]; - jpTitle ? altTitles.push(jpTitle) : null; - synonyms ? altTitles.push(...synonyms) : null; + if (jpTitle) { + altTitles.push(jpTitle); + } + + if (synonyms) { + altTitles.push(...synonyms); + } results.push({ id: id, @@ -156,7 +161,7 @@ export default class Zoro extends AnimeProvider { case StreamingServers.VidCloud: serverId = this.retrieveServerId($, 4, subType); - if (!serverId) throw new Error("RapidCloud not found"); + if (!serverId) throw new Error("VidCloud not found"); break; case StreamingServers.VidStreaming: serverId = this.retrieveServerId($, 4, subType); @@ -192,4 +197,13 @@ export default class Zoro extends AnimeProvider { ?.attr("data-id") ?? "" ); } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return false; + } else { + return true; + } + } } diff --git a/anify-backend/src/mappings/impl/base/anilist.ts b/anify-backend/src/mappings/impl/base/anilist.ts index 6952184..7bc6ba4 100644 --- a/anify-backend/src/mappings/impl/base/anilist.ts +++ b/anify-backend/src/mappings/impl/base/anilist.ts @@ -478,7 +478,17 @@ export default class AniListBase extends BaseProvider { } override getCurrentSeason(): Season { - return Season.FALL; + const month = new Date().getMonth(); + + if ((month >= 0 && month <= 1) || month === 11) { + return Season.WINTER; + } else if (month >= 2 && month <= 4) { + return Season.SPRING; + } else if (month >= 5 && month <= 7) { + return Season.SUMMER; + } else { + return Season.FALL; + } } override async getMedia(id: string): Promise { @@ -670,7 +680,7 @@ export default class AniListBase extends BaseProvider { variables: { type: type, season: this.getCurrentSeason(), - seasonYear: 2023, + seasonYear: new Date(Date.now()).getFullYear(), format: formats, page: 0, perPage: 20, @@ -1168,6 +1178,15 @@ export default class AniListBase extends BaseProvider { }); } + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei", Type.ANIME, [Format.TV], 0, 10); + if (!searchData || searchData.length === 0) { + return false; + } else { + return true; + } + } + public query = ` id idMal diff --git a/anify-backend/src/mappings/impl/base/index.ts b/anify-backend/src/mappings/impl/base/index.ts index b180aba..5fe39a8 100644 --- a/anify-backend/src/mappings/impl/base/index.ts +++ b/anify-backend/src/mappings/impl/base/index.ts @@ -78,4 +78,8 @@ export default abstract class BaseProvider { return Http.request(this.id, this.useGoogleTranslate, url, config, proxyRequest, 0, this.customProxy); } + + async proxyCheck(): Promise { + return undefined; + } } diff --git a/anify-backend/src/mappings/impl/base/mangadex.ts b/anify-backend/src/mappings/impl/base/mangadex.ts index 6372985..4f4aa6e 100644 --- a/anify-backend/src/mappings/impl/base/mangadex.ts +++ b/anify-backend/src/mappings/impl/base/mangadex.ts @@ -316,7 +316,7 @@ export default class ManagDexBase extends BaseProvider { author: data.relationships.filter((element: any) => element.type === "author").map((element: any) => element.attributes?.name) ?? null, publisher: data.relationships.filter((element: any) => element.type === "publisher").map((element: any) => element.attributes?.name) ?? null, }; - } catch (e) { + } catch { return undefined; } } @@ -482,4 +482,13 @@ export default class ManagDexBase extends BaseProvider { publisher: manga.relationships.filter((element: any) => element.type === "publisher").map((element: any) => element.attributes?.name) ?? null, }; } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei", Type.MANGA, [Format.MANGA], 0, 10); + if (!searchData || searchData.length === 0) { + return false; + } else { + return true; + } + } } diff --git a/anify-backend/src/mappings/impl/base/novelupdates.ts b/anify-backend/src/mappings/impl/base/novelupdates.ts index 15330ef..02396e5 100644 --- a/anify-backend/src/mappings/impl/base/novelupdates.ts +++ b/anify-backend/src/mappings/impl/base/novelupdates.ts @@ -57,6 +57,7 @@ export default class NovelUpdatesBase extends BaseProvider { method: "GET", headers: { Referer: this.url, + "User-Agent": "Mozilla/5.0", }, }); @@ -98,6 +99,7 @@ export default class NovelUpdatesBase extends BaseProvider { method: "GET", headers: { Referer: this.url, + "User-Agent": "Mozilla/5.0", }, }, ); @@ -133,7 +135,14 @@ export default class NovelUpdatesBase extends BaseProvider { return undefined; } - let data = await (await this.request(`${this.url}/series/${id}`, { headers: { Referer: this.url } })).text(); + let data = await ( + await this.request(`${this.url}/series/${id}`, { + headers: { + Referer: this.url, + "User-Agent": "Mozilla/5.0", + }, + }) + ).text(); let $$ = load(data); const title = $$("title").html(); @@ -237,6 +246,7 @@ export default class NovelUpdatesBase extends BaseProvider { method: "GET", headers: { Referer: this.url, + "User-Agent": "Mozilla/5.0", }, }); @@ -271,4 +281,13 @@ export default class NovelUpdatesBase extends BaseProvider { await Promise.all(requestPromises); return results; } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei", Type.MANGA, [Format.NOVEL], 0); + if (!searchData || searchData.length === 0) { + return false; + } else { + return true; + } + } } diff --git a/anify-backend/src/mappings/impl/information/anidb.ts b/anify-backend/src/mappings/impl/information/anidb.ts index e1448a3..f772cb0 100644 --- a/anify-backend/src/mappings/impl/information/anidb.ts +++ b/anify-backend/src/mappings/impl/information/anidb.ts @@ -148,8 +148,17 @@ export default class AniDB extends InformationProvider { + const request = await this.request(this.url); + if (request.ok) { + return true; + } else { + return false; + } + } } diff --git a/anify-backend/src/mappings/impl/information/anilist.ts b/anify-backend/src/mappings/impl/information/anilist.ts index 3bfb55c..48533ab 100644 --- a/anify-backend/src/mappings/impl/information/anilist.ts +++ b/anify-backend/src/mappings/impl/information/anilist.ts @@ -54,7 +54,7 @@ export default class AniList extends InformationProvider { + const request = await this.request(this.api); + if (request.ok) { + return true; + } else { + return false; + } + } + public query = ` id idMal diff --git a/anify-backend/src/mappings/impl/information/comick.ts b/anify-backend/src/mappings/impl/information/comick.ts index 635635b..332179b 100644 --- a/anify-backend/src/mappings/impl/information/comick.ts +++ b/anify-backend/src/mappings/impl/information/comick.ts @@ -87,6 +87,15 @@ export default class ComicKInfo extends InformationProvider { + const request = await this.request(this.url); + if (request.ok) { + return true; + } else { + return false; + } + } } interface Comic { diff --git a/anify-backend/src/mappings/impl/information/index.ts b/anify-backend/src/mappings/impl/information/index.ts index 301e4b9..6f8e545 100644 --- a/anify-backend/src/mappings/impl/information/index.ts +++ b/anify-backend/src/mappings/impl/information/index.ts @@ -40,4 +40,8 @@ export default abstract class InformationProvider { + return undefined; + } } diff --git a/anify-backend/src/mappings/impl/information/kitsu.ts b/anify-backend/src/mappings/impl/information/kitsu.ts index 31540ac..3b6e552 100644 --- a/anify-backend/src/mappings/impl/information/kitsu.ts +++ b/anify-backend/src/mappings/impl/information/kitsu.ts @@ -82,10 +82,19 @@ export default class Kitsu extends InformationProvider { + const request = await this.request(this.url); + if (request.ok) { + return true; + } else { + return false; + } + } } type KitsuResponse = { diff --git a/anify-backend/src/mappings/impl/information/mal.ts b/anify-backend/src/mappings/impl/information/mal.ts index ea1bfce..364661c 100644 --- a/anify-backend/src/mappings/impl/information/mal.ts +++ b/anify-backend/src/mappings/impl/information/mal.ts @@ -1,13 +1,12 @@ +import { load } from "cheerio"; import InformationProvider from "."; -import { Format, Genres, MediaStatus, Season } from "../../../types/enums"; -import { Anime, AnimeInfo, Artwork, Manga, MangaInfo, MediaInfoKeys } from "../../../types/types"; +import { Format, Genres, MediaStatus, Season, Type } from "../../../types/enums"; +import { Anime, AnimeInfo, Manga, MangaInfo, MediaInfoKeys, Relations } from "../../../types/types"; export default class MAL extends InformationProvider { override id = "mal"; override url = "https://myanimelist.net"; - private api = "https://api.jikan.moe/v4"; - public needsProxy: boolean = true; override get priorityArea(): MediaInfoKeys[] { @@ -25,115 +24,817 @@ export default class MAL extends InformationProvider { + const data = await (await this.request(`${this.url}/anime/${id}`)).text(); + + const $ = load(data); + + const alternativeTitlesDiv = $("h2:contains('Alternative Titles')").nextUntil("h2:contains('Information')").first(); + const additionalTitles = alternativeTitlesDiv + .find("div.spaceit_pad") + .map((_, item) => { + return $(item).text().trim(); + }) + .get(); + + const title = { + main: $("meta[property='og:title']").attr("content") || "", + english: $("span:contains('English:')").length > 0 ? $("span:contains('English:')").parent().text().replace($("span:contains('English:')").text(), "").replace(/\s+/g, " ").trim() : null, + synonyms: $("span:contains('Synonyms:')").length > 0 ? $("span:contains('Synonyms:')").parent().text().replace($("span:contains('Synonyms:')").text(), "").replace(/\s+/g, " ").trim().split(", ") : [], + japanese: $("span:contains('Japanese:')").length > 0 ? $("span:contains('Japanese:')").parent().text().replace($("span:contains('Japanese:')").text(), "").replace(/\s+/g, " ").trim() : null, + alternatives: additionalTitles, + }; + + const imageURL = $("meta[property='og:image']").attr("content") || ""; + + let synopsis: string | null = ($("p[itemprop='description']").html() || "").replace(/\s+/g, " ").trim(); + if (synopsis.startsWith("No synopsis information has been added to this title.")) { + synopsis = null; + } + + const format = $("span:contains('Type:')").length > 0 ? $("span:contains('Type:')").parents().first().text().replace($("span:contains('Type:')").first().text(), "").trim().replace(/\s+/g, " ").trim() : null; + const episodes = + $("span:contains('Episodes:')").length > 0 + ? $("span:contains('Episodes:')").parents().first().text().replace($("span:contains('Episodes:')").first().text(), "").trim() === "Unknown" + ? null + : parseInt($("span:contains('Episodes:')").parents().first().text().replace($("span:contains('Episodes:')").first().text(), "").trim(), 10) + : null; + const status = $("span:contains('Status:')").length > 0 ? $("span:contains('Status:')").parents().first().text().replace($("span:contains('Status:')").first().text(), "").replace(/\s+/g, " ").trim() : null; + const genres = + $("span:contains('Genres:')").length > 0 && $("span:contains('Genres:')").parents().first().text().indexOf("No genres have been added yet") === -1 + ? $("span:contains('Genres:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim() as Genres) + .get() + : $("span:contains('Genre:')").length > 0 && $("span:contains('Genre:')").parents().first().text().indexOf("No genres have been added yet") === -1 + ? $("span:contains('Genre:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim() as Genres) + .get() + : []; + const explicitGenres = + $("span:contains('Explicit Genres:')").length > 0 && $("span:contains('Explicit Genres:')").parents().first().text().indexOf("No genres have been added yet") === -1 + ? $("span:contains('Explicit Genres:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim() as Genres) + .get() + : $("span:contains('Explicit Genre:')").length > 0 && $("span:contains('Explicit Genre:')").parents().first().text().indexOf("No genres have been added yet") === -1 + ? $("span:contains('Explicit Genre:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim() as Genres) + .get() + : []; + const score = + $("span[itemprop='ratingValue']").length > 0 + ? (() => { + const cleanedScore = $("span[itemprop='ratingValue']").text().trim(); + return cleanedScore === "N/A" ? null : parseFloat(cleanedScore); + })() + : null; + const popularity = $("span:contains('Popularity:')").length > 0 ? $("span:contains('Popularity:')").parents().first().text().replace($("span:contains('Popularity:')").text(), "").replace("#", "").trim() : null; + const premiered = + $("span:contains('Premiered:')").length > 0 + ? $("span:contains('Premiered:')").parents().first().text().replace($("span:contains('Premiered:')").first().text(), "").replace(/\s+/g, " ").trim() === "?" + ? null + : $("span:contains('Premiered:')").parents().first().text().replace($("span:contains('Premiered:')").first().text(), "").replace(/\s+/g, " ").trim() + : null; + const duration = $("span:contains('Duration:')").length > 0 ? $("span:contains('Duration:')").parents().first().text().replace($("span:contains('Duration:')").text(), "").replace(".", "").trim() : null; + const preview = $("div.video-promotion a").length > 0 ? $("div.video-promotion a").attr("href") || null : null; + const themes = + $("span:contains('Themes:')").length > 0 && $("span:contains('Themes:')").parents().first().text().indexOf("No themes have been added yet") === -1 + ? $("span:contains('Themes:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim()) + .get() + : $("span:contains('Themes:')").length > 0 && $("span:contains('Themes:')").parents().first().text().indexOf("No themes have been added yet") === -1 + ? $("span:contains('Themes:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim()) + .get() + : []; + + const seasonString = premiered ? premiered.split(" ")[0] : null; + let season: Season = Season.UNKNOWN; + + switch (seasonString ?? "") { + case "Winter": + season = Season.WINTER; + case "Spring": + season = Season.SPRING; + case "Summer": + season = Season.SUMMER; + case "Fall": + season = Season.FALL; + default: + season = Season.UNKNOWN; + } + + const relations: Relations[] = []; + const promises: Promise[] = []; + + $("div.related-entries div.entries-tile div.entry").each((i, el) => { + const relationElement = $(el).find("div.content div.relation"); + if (!relationElement.length) return; + + const relation = relationElement + .text() + .replace(/\s\(.*\)/, "") + .trim(); + const links = $(el).find("div.content div.title a"); + + links.each((_, link) => { + if (!$(link).text().trim()) { + $(link).remove(); + } + }); - if (!req.ok) return undefined; - const jikanResponse = (await req.json()) as { data: JikanResponse }; + for (const link of links) { + promises.push( + new Promise(async (resolve) => { + const url = $(link).attr("href"); - const data: JikanResponse = jikanResponse.data; + const data = await (await this.request(url ?? "", {}, false)).text(); + const $$ = load(data); - if (!data) return undefined; + const id = $$("meta[property='og:url']").attr("content")?.split("/")[4] ?? ""; + const type = $$("meta[property='og:url']").attr("content")?.split("/")[3] === "manga" ? Type.MANGA : Type.ANIME; + const format = $$("span:contains('Type:')").length > 0 ? $$("span:contains('Type:')").parents().first().text().replace($$("span:contains('Type:')").first().text(), "").trim().replace(/\s+/g, " ").trim() : null; + const alternativeTitlesDiv = $$("h2:contains('Alternative Titles')").nextUntil("h2:contains('Information')").first(); + const additionalTitles = alternativeTitlesDiv + .find("div.spaceit_pad") + .map((_, item) => { + return $$(item).text().trim(); + }) + .get(); - const artwork: Artwork[] = []; + const title = { + main: $$("meta[property='og:title']").attr("content") || "", + english: $$("span:contains('English:')").length > 0 ? $$("span:contains('English:')").parent().text().replace($$("span:contains('English:')").text(), "").replace(/\s+/g, " ").trim() : null, + synonyms: $$("span:contains('Synonyms:')").length > 0 ? $$("span:contains('Synonyms:')").parent().text().replace($$("span:contains('Synonyms:')").text(), "").replace(/\s+/g, " ").trim().split(", ") : [], + japanese: $$("span:contains('Japanese:')").length > 0 ? $$("span:contains('Japanese:')").parent().text().replace($$("span:contains('Japanese:')").text(), "").replace(/\s+/g, " ").trim() : null, + alternatives: additionalTitles, + }; - if (data.images?.jpg?.image_url) - artwork.push({ - type: "poster", - img: data.images.jpg.image_url, - providerId: this.id, + relations.push({ + id, + format: + format === "Music" + ? Format.MUSIC + : format === "TV" + ? Format.TV + : format === "Movie" + ? Format.MOVIE + : format === "TV Short" + ? Format.TV_SHORT + : format === "OVA" + ? Format.OVA + : format === "ONA" + ? Format.ONA + : format === "Manga" + ? Format.MANGA + : format === "One-shot" + ? Format.ONE_SHOT + : format === "Doujinshi" + ? Format.MANGA + : format === "Light Novel" + ? Format.NOVEL + : format === "Novel" + ? Format.NOVEL + : format === "Special" + ? Format.SPECIAL + : format === "TV Special" + ? Format.TV_SHORT + : format === "Manhwa" + ? Format.MANGA + : format === "Manhua" + ? Format.MANGA + : Format.UNKNOWN, + relationType: relation, + title: { + english: title.english, + native: title.japanese, + romaji: title.main, + }, + type, + }); + + resolve(); + }), + ); + } + }); + + $("table.entries-table tr").each((i, el) => { + const relation = $(el).find("td:first-child").text().replace(":", "").trim(); + const links = $(el).find("td:nth-child(2) a"); + + links.each((_, link) => { + if (!$(link).text().trim()) { + $(link).remove(); + } }); + for (const link of links) { + promises.push( + new Promise(async (resolve) => { + const url = $(link).attr("href"); + + const data = await (await this.request(url ?? "", {}, false)).text(); + const $$ = load(data); + + const id = $$("meta[property='og:url']").attr("content")?.split("/")[4] ?? ""; + const type = $$("meta[property='og:url']").attr("content")?.split("/")[3] === "manga" ? Type.MANGA : Type.ANIME; + const format = $$("span:contains('Type:')").length > 0 ? $$("span:contains('Type:')").parents().first().text().replace($$("span:contains('Type:')").first().text(), "").trim().replace(/\s+/g, " ").trim() : null; + const alternativeTitlesDiv = $$("h2:contains('Alternative Titles')").nextUntil("h2:contains('Information')").first(); + const additionalTitles = alternativeTitlesDiv + .find("div.spaceit_pad") + .map((_, item) => { + return $$(item).text().trim(); + }) + .get(); + + const title = { + main: $$("meta[property='og:title']").attr("content") || "", + english: $$("span:contains('English:')").length > 0 ? $$("span:contains('English:')").parent().text().replace($$("span:contains('English:')").text(), "").replace(/\s+/g, " ").trim() : null, + synonyms: $$("span:contains('Synonyms:')").length > 0 ? $$("span:contains('Synonyms:')").parent().text().replace($$("span:contains('Synonyms:')").text(), "").replace(/\s+/g, " ").trim().split(", ") : [], + japanese: $$("span:contains('Japanese:')").length > 0 ? $$("span:contains('Japanese:')").parent().text().replace($$("span:contains('Japanese:')").text(), "").replace(/\s+/g, " ").trim() : null, + alternatives: additionalTitles, + }; + + relations.push({ + id, + format: + format === "Music" + ? Format.MUSIC + : format === "TV" + ? Format.TV + : format === "Movie" + ? Format.MOVIE + : format === "TV Short" + ? Format.TV_SHORT + : format === "OVA" + ? Format.OVA + : format === "ONA" + ? Format.ONA + : format === "Manga" + ? Format.MANGA + : format === "One-shot" + ? Format.ONE_SHOT + : format === "Doujinshi" + ? Format.MANGA + : format === "Light Novel" + ? Format.NOVEL + : format === "Novel" + ? Format.NOVEL + : format === "Special" + ? Format.SPECIAL + : format === "TV Special" + ? Format.TV_SHORT + : format === "Manhwa" + ? Format.MANGA + : format === "Manhua" + ? Format.MANGA + : Format.UNKNOWN, + relationType: relation, + title: { + english: title.english, + native: title.japanese, + romaji: title.main, + }, + type, + }); + + resolve(); + }), + ); + } + }); + + await Promise.all(promises); + + /* + // Unused data + const approved = $("#addtolist span").filter((_, el) => $(el).text().toLowerCase().includes("pending approval")).length === 0; + const broadcasted = $("span:contains('Broadcast:')").length > 0 ? ($("span:contains('Broadcast:')").parents().first().text().replace($("span:contains('Broadcast:')").first().text(), "")).replace(/\s+/g, " ").trim() : null; + const producers = $("span:contains('Producers:')").length > 0 && !$("span:contains('Producers:')").parents().first().text().includes("None found") ? $("span:contains('Producers:')").parents().first().find("a").map((_, el) => { return $(el).text().trim(); }).get() : []; + const licensors = $("span:contains('Licensors:')").length > 0 && !$("span:contains('Licensors:')").parents().first().text().includes("None found") ? $("span:contains('Licensors:')").parents().first().find("a").map((_, el) => { return $(el).text().trim(); }).get() : []; + const studios = $("span:contains('Studios:')").length > 0 && $("span:contains('Studios:')").parents().first().text().indexOf("None found") === -1 ? $("span:contains('Studios:')").parents().first().find('a').map((_, el) => $(el).text().trim()).get() : []; + const source = $("span:contains('Source:')").length > 0 ? $("span:contains('Source:')").parents().first().text().replace($("span:contains('Source:')").first().text(), "").trim() : null; + const demographics = $("span:contains('Demographic:')").length > 0 ? $("span:contains('Demographic:')").parents().first().find("a").map((_, el) => $(el).text().trim()).get() : $("span:contains('Demographics:')").length > 0 ? $("span:contains('Demographics:')").parents().first().find("a").map((_, el) => $(el).text().trim()).get() : []; + const themes = $("span:contains('Theme:')").length > 0 ? $("span:contains('Theme:')").parents().first().find("a").map((_, el) => $(el).text().trim()).get() : $("span:contains('Themes:')").length > 0 ? $("span:contains('Themes:')").parents().first().find("a").map((_, el) => $(el).text().trim()).get() : []; + const rating = $("span:contains('Rating:')").length > 0 ? (() => { + const cleanedRating = $("span:contains('Rating:')").parents().first().text().replace($("span:contains('Rating:')").text(), "").trim(); + return cleanedRating === "None" ? null : cleanedRating; + })() + : null; + const scoredBy = $("span[itemprop='ratingCount']").length > 0 ? (() => { + const cleanedScoredBy = $("span[itemprop='ratingCount']").text().trim(); + const numericValue = cleanedScoredBy.replace(/[, ]+(user|users)/g, ""); + return isNaN(Number(numericValue)) ? null : parseInt(numericValue, 10); + })() + : null; + const rank = $("span:contains('Ranked:')").length > 0 ? (() => { + const rankedText = $("span:contains('Ranked:')") + .parents() + .first() + .text() + .replace($("span:contains('Ranked:')").text(), "") + .trim(); + const cleanedRanked = rankedText.replace("#", ""); + return cleanedRanked === "N/A" ? null : parseInt(cleanedRanked, 10); + })() + : null; + const members = $("span:contains('Members:')").length > 0 ? parseInt($("span:contains('Members:')").parents().first().text().replace($("span:contains('Members:')").first().text(), "").replace(/,/g, "").trim()) : null; + const favorites = $("span:contains('Favorites:')").length > 0 ? parseInt($("span:contains('Favorites:')").parents().first().text().replace($("span:contains('Favorites:')").first().text(), "").replace(/,/g, "").trim()) : null; + + const background = $("p[itemprop='description']").length > 0 ? $("p[itemprop='description']").parents().first().text().replace(/\s+/g, " ").trim().replace(/No background information has been added to this title/, "") || null : null; + const openingThemes = $("div.theme-songs.js-theme-songs.opnening table tr").length > 0 ? $("div.theme-songs.js-theme-songs.opnening table tr").map((i, el) => $(el).text().replace(/\s+/g, " ").trim()).get() : []; + const endingThemes = $("div.theme-songs.js-theme-songs.ending table tr").length > 0 ? $("div.theme-songs.js-theme-songs.ending table tr").map((i, el) => $(el).text().replace(/\s+/g, " ").trim()).get() : []; + const aired = $("span:contains('Aired')").length > 0 ? $("span:contains('Aired')").parent().html()?.split('\n').map(line => line.trim())[1] || null : null; + */ + return { - id: String(data.mal_id), + id, title: { - english: data.title_english ?? null, - romaji: data.title ?? null, - native: data.title_japanese ?? null, + english: title.english, + native: title.japanese, + romaji: title.main, }, - currentEpisode: data.status === "completed" ? data.episodes : null, - trailer: data.trailer ? data.trailer.url : null, - coverImage: data.images?.jpg?.large_image_url ?? data.images?.jpg?.image_url ?? data.images?.jpg?.small_image_url ?? null, - bannerImage: null, - color: null, - totalEpisodes: data.episodes ?? 0, - status: data.status - ? (data.status as string).toLowerCase() === "not yet aired" - ? MediaStatus.NOT_YET_RELEASED - : (data.status as string).toLowerCase() === "currently airing" - ? MediaStatus.RELEASING - : (data.status as string).toLowerCase() === "finished airing" - ? MediaStatus.FINISHED - : null - : null, - popularity: data.popularity, - synonyms: data.title_synonyms?.filter((s) => s?.length) ?? [], - season: data.season ? ([(data.season as string).toUpperCase()] as unknown as Season) : Season.UNKNOWN, - genres: data.genres ? (data.genres.map((g) => g.name) as Genres[]) : [], - description: data.synopsis ?? null, - rating: data.score ?? null, - year: data.year ?? null, - duration: data.duration ? Number.parseInt(data.duration.replace("min per ep", "").trim()) : null, - format: data.type.toUpperCase() as Format, - countryOfOrigin: null, - tags: [], - relations: [], - artwork, + synonyms: title.synonyms.concat(title.alternatives), + description: synopsis, + type: Type.ANIME, + rating: score ? score : null, + popularity: popularity ? parseInt(popularity, 10) : null, + format: + format === "Music" + ? Format.MUSIC + : format === "TV" + ? Format.TV + : format === "Movie" + ? Format.MOVIE + : format === "TV Short" + ? Format.TV_SHORT + : format === "OVA" + ? Format.OVA + : format === "ONA" + ? Format.ONA + : format === "Manga" + ? Format.MANGA + : format === "One-shot" + ? Format.ONE_SHOT + : format === "Doujinshi" + ? Format.MANGA + : format === "Light Novel" + ? Format.NOVEL + : format === "Novel" + ? Format.NOVEL + : format === "Special" + ? Format.SPECIAL + : format === "TV Special" + ? Format.TV_SHORT + : format === "Manhwa" + ? Format.MANGA + : format === "Manhua" + ? Format.MANGA + : Format.UNKNOWN, + totalEpisodes: episodes ? episodes : undefined, + status: status === "Finished Airing" ? MediaStatus.FINISHED : status === "Currently Airing" ? MediaStatus.RELEASING : MediaStatus.NOT_YET_RELEASED, + coverImage: imageURL, + genres: genres.concat(explicitGenres), + relations, + year: premiered ? parseInt(premiered.split(" ")[1].split(" ")[0], 10) : null, + duration: duration ? parseInt(duration.split(" ")[0], 10) : null, + trailer: new URL(preview ?? "").searchParams.get("u") ?? null, + artwork: [], + bannerImage: "", characters: [], - totalChapters: null, - totalVolumes: null, - type: media.type, - } as AnimeInfo | MangaInfo; + color: "", + countryOfOrigin: "", + currentEpisode: null, + season, + tags: themes, + }; } -} -type JikanResponse = { - mal_id: number; - url: string; - title: string; - title_english: string | null; - title_japanese: string | null; - title_synonyms: string[]; - type: string; - status: string; - synopsis: string | null; - images: { - jpg: { - image_url: string | null; - small_image_url: string | null; - large_image_url: string | null; - }; - webp: { - image_url: string | null; - small_image_url: string | null; - large_image_url: string | null; + private async fetchManga(id: string): Promise { + const data = await (await this.request(`${this.url}/manga/${id}`)).text(); + + const $ = load(data); + + const alternativeTitlesDiv = $("h2:contains('Alternative Titles')").nextUntil("h2:contains('Information')").first(); + const additionalTitles = alternativeTitlesDiv + .find("div.spaceit_pad") + .map((_, item) => { + return $(item).text().trim(); + }) + .get(); + + const title = { + main: $("meta[property='og:title']").attr("content") || "", + english: $("span:contains('English:')").length > 0 ? $("span:contains('English:')").parent().text().replace($("span:contains('English:')").text(), "").replace(/\s+/g, " ").trim() : null, + synonyms: $("span:contains('Synonyms:')").length > 0 ? $("span:contains('Synonyms:')").parent().text().replace($("span:contains('Synonyms:')").text(), "").replace(/\s+/g, " ").trim().split(", ") : [], + japanese: $("span:contains('Japanese:')").length > 0 ? $("span:contains('Japanese:')").parent().text().replace($("span:contains('Japanese:')").text(), "").replace(/\s+/g, " ").trim() : null, + alternatives: additionalTitles, }; - }; - duration: string; - episodes: number | null; - popularity: number | null; - score: number | null; - season: string; - year: number | null; - genres: { name: string }[]; - trailer: { - youtube_id: string | null; - url: string | null; - embed_url: string | null; - images: { - image_url: string | null; - small_image_url: string | null; - medium_image_url: string | null; - large_image_url: string | null; - maximum_image_url: string | null; + + const imageURL = $("meta[property='og:image']").attr("content") || ""; + + let synopsis: string | null = ($("span[itemprop='description']").html() || "").replace(/\s+/g, " ").trim(); + if (synopsis.startsWith("No synopsis information has been added to this title.")) { + synopsis = null; + } + + const format = $("span:contains('Type:')").length > 0 ? $("span:contains('Type:')").parents().first().text().replace($("span:contains('Type:')").first().text(), "").trim().replace(/\s+/g, " ").trim() : null; + const volumes = + $("span:contains('Volumes:')").length > 0 + ? $("span:contains('Volumes:')").parents().first().text().replace($("span:contains('Volumes:')").first().text(), "").trim() === "Unknown" + ? null + : parseInt($("span:contains('Volumes:')").parents().first().text().replace($("span:contains('Volumes:')").first().text(), "").trim(), 10) + : null; + const chapters = + $("span:contains('Chapters:')").length > 0 + ? $("span:contains('Chapters:')").parents().first().text().replace($("span:contains('Chapters:')").first().text(), "").trim() === "Unknown" + ? null + : parseInt($("span:contains('Chapters:')").parents().first().text().replace($("span:contains('Chapters:')").first().text(), "").trim(), 10) + : null; + const status = $("span:contains('Status:')").length > 0 ? $("span:contains('Status:')").parents().first().text().replace($("span:contains('Status:')").first().text(), "").replace(/\s+/g, " ").trim() : null; + const genres = + $("span:contains('Genres:')").length > 0 && $("span:contains('Genres:')").parents().first().text().indexOf("No genres have been added yet") === -1 + ? $("span:contains('Genres:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim() as Genres) + .get() + : $("span:contains('Genre:')").length > 0 && $("span:contains('Genre:')").parents().first().text().indexOf("No genres have been added yet") === -1 + ? $("span:contains('Genre:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim() as Genres) + .get() + : []; + const explicitGenres = + $("span:contains('Explicit Genres:')").length > 0 && $("span:contains('Explicit Genres:')").parents().first().text().indexOf("No genres have been added yet") === -1 + ? $("span:contains('Explicit Genres:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim() as Genres) + .get() + : $("span:contains('Explicit Genre:')").length > 0 && $("span:contains('Explicit Genre:')").parents().first().text().indexOf("No genres have been added yet") === -1 + ? $("span:contains('Explicit Genre:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim() as Genres) + .get() + : []; + const score = + $("span[itemprop='ratingValue']").length > 0 + ? (() => { + const cleanedScore = $("span[itemprop='ratingValue']").text().trim(); + return cleanedScore === "N/A" ? null : parseFloat(cleanedScore); + })() + : null; + const popularity = $("span:contains('Popularity:')").length > 0 ? $("span:contains('Popularity:')").parents().first().text().replace($("span:contains('Popularity:')").text(), "").replace("#", "").trim() : null; + const published = + $("span:contains('Published:')").length > 0 + ? $("span:contains('Published:')").parents().first().text().replace($("span:contains('Published:')").first().text(), "").replace(/\s+/g, " ").trim() === "?" + ? null + : $("span:contains('Published:')").parents().first().text().replace($("span:contains('Published:')").first().text(), "").replace(/\s+/g, " ").trim() + : null; + const themes = + $("span:contains('Themes:')").length > 0 && $("span:contains('Themes:')").parents().first().text().indexOf("No themes have been added yet") === -1 + ? $("span:contains('Themes:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim()) + .get() + : $("span:contains('Themes:')").length > 0 && $("span:contains('Themes:')").parents().first().text().indexOf("No themes have been added yet") === -1 + ? $("span:contains('Themes:')") + .parents() + .first() + .find("a") + .map((_, el) => $(el).text().trim()) + .get() + : []; + + const relations: Relations[] = []; + const promises: Promise[] = []; + + $("div.related-entries div.entries-tile div.entry").each((i, el) => { + const relationElement = $(el).find("div.content div.relation"); + if (!relationElement.length) return; + + const relation = relationElement + .text() + .replace(/\s\(.*\)/, "") + .trim(); + const links = $(el).find("div.content div.title a"); + + links.each((_, link) => { + if (!$(link).text().trim()) { + $(link).remove(); + } + }); + + for (const link of links) { + promises.push( + new Promise(async (resolve) => { + const url = $(link).attr("href"); + + const data = await (await this.request(url ?? "", {}, false)).text(); + const $$ = load(data); + + const id = $$("meta[property='og:url']").attr("content")?.split("/")[4] ?? ""; + const type = $$("meta[property='og:url']").attr("content")?.split("/")[3] === "manga" ? Type.MANGA : Type.ANIME; + const format = $$("span:contains('Type:')").length > 0 ? $$("span:contains('Type:')").parents().first().text().replace($$("span:contains('Type:')").first().text(), "").trim().replace(/\s+/g, " ").trim() : null; + const alternativeTitlesDiv = $$("h2:contains('Alternative Titles')").nextUntil("h2:contains('Information')").first(); + const additionalTitles = alternativeTitlesDiv + .find("div.spaceit_pad") + .map((_, item) => { + return $$(item).text().trim(); + }) + .get(); + + const title = { + main: $$("meta[property='og:title']").attr("content") || "", + english: $$("span:contains('English:')").length > 0 ? $$("span:contains('English:')").parent().text().replace($$("span:contains('English:')").text(), "").replace(/\s+/g, " ").trim() : null, + synonyms: $$("span:contains('Synonyms:')").length > 0 ? $$("span:contains('Synonyms:')").parent().text().replace($$("span:contains('Synonyms:')").text(), "").replace(/\s+/g, " ").trim().split(", ") : [], + japanese: $$("span:contains('Japanese:')").length > 0 ? $$("span:contains('Japanese:')").parent().text().replace($$("span:contains('Japanese:')").text(), "").replace(/\s+/g, " ").trim() : null, + alternatives: additionalTitles, + }; + + relations.push({ + id, + format: + format === "Music" + ? Format.MUSIC + : format === "TV" + ? Format.TV + : format === "Movie" + ? Format.MOVIE + : format === "TV Short" + ? Format.TV_SHORT + : format === "OVA" + ? Format.OVA + : format === "ONA" + ? Format.ONA + : format === "Manga" + ? Format.MANGA + : format === "One-shot" + ? Format.ONE_SHOT + : format === "Doujinshi" + ? Format.MANGA + : format === "Light Novel" + ? Format.NOVEL + : format === "Novel" + ? Format.NOVEL + : format === "Special" + ? Format.SPECIAL + : format === "TV Special" + ? Format.TV_SHORT + : format === "Manhwa" + ? Format.MANGA + : format === "Manhua" + ? Format.MANGA + : Format.UNKNOWN, + relationType: relation, + title: { + english: title.english, + native: title.japanese, + romaji: title.main, + }, + type, + }); + + resolve(); + }), + ); + } + }); + + $("table.entries-table tr").each((i, el) => { + const relation = $(el).find("td:first-child").text().replace(":", "").trim(); + const links = $(el).find("td:nth-child(2) a"); + + links.each((_, link) => { + if (!$(link).text().trim()) { + $(link).remove(); + } + }); + + for (const link of links) { + promises.push( + new Promise(async (resolve) => { + const url = $(link).attr("href"); + + const data = await (await this.request(url ?? "", {}, false)).text(); + const $$ = load(data); + + const id = $$("meta[property='og:url']").attr("content")?.split("/")[4] ?? ""; + const type = $$("meta[property='og:url']").attr("content")?.split("/")[3] === "manga" ? Type.MANGA : Type.ANIME; + const format = $$("span:contains('Type:')").length > 0 ? $$("span:contains('Type:')").parents().first().text().replace($$("span:contains('Type:')").first().text(), "").trim().replace(/\s+/g, " ").trim() : null; + const alternativeTitlesDiv = $$("h2:contains('Alternative Titles')").nextUntil("h2:contains('Information')").first(); + const additionalTitles = alternativeTitlesDiv + .find("div.spaceit_pad") + .map((_, item) => { + return $$(item).text().trim(); + }) + .get(); + + const title = { + main: $$("meta[property='og:title']").attr("content") || "", + english: $$("span:contains('English:')").length > 0 ? $$("span:contains('English:')").parent().text().replace($$("span:contains('English:')").text(), "").replace(/\s+/g, " ").trim() : null, + synonyms: $$("span:contains('Synonyms:')").length > 0 ? $$("span:contains('Synonyms:')").parent().text().replace($$("span:contains('Synonyms:')").text(), "").replace(/\s+/g, " ").trim().split(", ") : [], + japanese: $$("span:contains('Japanese:')").length > 0 ? $$("span:contains('Japanese:')").parent().text().replace($$("span:contains('Japanese:')").text(), "").replace(/\s+/g, " ").trim() : null, + alternatives: additionalTitles, + }; + + relations.push({ + id, + format: + format === "Music" + ? Format.MUSIC + : format === "TV" + ? Format.TV + : format === "Movie" + ? Format.MOVIE + : format === "TV Short" + ? Format.TV_SHORT + : format === "OVA" + ? Format.OVA + : format === "ONA" + ? Format.ONA + : format === "Manga" + ? Format.MANGA + : format === "One-shot" + ? Format.ONE_SHOT + : format === "Doujinshi" + ? Format.MANGA + : format === "Light Novel" + ? Format.NOVEL + : format === "Novel" + ? Format.NOVEL + : format === "Special" + ? Format.SPECIAL + : format === "TV Special" + ? Format.TV_SHORT + : format === "Manhwa" + ? Format.MANGA + : format === "Manhua" + ? Format.MANGA + : Format.UNKNOWN, + relationType: relation, + title: { + english: title.english, + native: title.japanese, + romaji: title.main, + }, + type, + }); + + resolve(); + }), + ); + } + }); + + await Promise.all(promises); + + /* + // Unused data + const approved = $("#addtolist span").filter((_, el) => $(el).text().toLowerCase().includes("pending approval")).length === 0; + const broadcasted = $("span:contains('Broadcast:')").length > 0 ? ($("span:contains('Broadcast:')").parents().first().text().replace($("span:contains('Broadcast:')").first().text(), "")).replace(/\s+/g, " ").trim() : null; + const producers = $("span:contains('Producers:')").length > 0 && !$("span:contains('Producers:')").parents().first().text().includes("None found") ? $("span:contains('Producers:')").parents().first().find("a").map((_, el) => { return $(el).text().trim(); }).get() : []; + const licensors = $("span:contains('Licensors:')").length > 0 && !$("span:contains('Licensors:')").parents().first().text().includes("None found") ? $("span:contains('Licensors:')").parents().first().find("a").map((_, el) => { return $(el).text().trim(); }).get() : []; + const studios = $("span:contains('Studios:')").length > 0 && $("span:contains('Studios:')").parents().first().text().indexOf("None found") === -1 ? $("span:contains('Studios:')").parents().first().find('a').map((_, el) => $(el).text().trim()).get() : []; + const source = $("span:contains('Source:')").length > 0 ? $("span:contains('Source:')").parents().first().text().replace($("span:contains('Source:')").first().text(), "").trim() : null; + const demographics = $("span:contains('Demographic:')").length > 0 ? $("span:contains('Demographic:')").parents().first().find("a").map((_, el) => $(el).text().trim()).get() : $("span:contains('Demographics:')").length > 0 ? $("span:contains('Demographics:')").parents().first().find("a").map((_, el) => $(el).text().trim()).get() : []; + const themes = $("span:contains('Theme:')").length > 0 ? $("span:contains('Theme:')").parents().first().find("a").map((_, el) => $(el).text().trim()).get() : $("span:contains('Themes:')").length > 0 ? $("span:contains('Themes:')").parents().first().find("a").map((_, el) => $(el).text().trim()).get() : []; + const rating = $("span:contains('Rating:')").length > 0 ? (() => { + const cleanedRating = $("span:contains('Rating:')").parents().first().text().replace($("span:contains('Rating:')").text(), "").trim(); + return cleanedRating === "None" ? null : cleanedRating; + })() + : null; + const scoredBy = $("span[itemprop='ratingCount']").length > 0 ? (() => { + const cleanedScoredBy = $("span[itemprop='ratingCount']").text().trim(); + const numericValue = cleanedScoredBy.replace(/[, ]+(user|users)/g, ""); + return isNaN(Number(numericValue)) ? null : parseInt(numericValue, 10); + })() + : null; + const rank = $("span:contains('Ranked:')").length > 0 ? (() => { + const rankedText = $("span:contains('Ranked:')") + .parents() + .first() + .text() + .replace($("span:contains('Ranked:')").text(), "") + .trim(); + const cleanedRanked = rankedText.replace("#", ""); + return cleanedRanked === "N/A" ? null : parseInt(cleanedRanked, 10); + })() + : null; + const members = $("span:contains('Members:')").length > 0 ? parseInt($("span:contains('Members:')").parents().first().text().replace($("span:contains('Members:')").first().text(), "").replace(/,/g, "").trim()) : null; + const favorites = $("span:contains('Favorites:')").length > 0 ? parseInt($("span:contains('Favorites:')").parents().first().text().replace($("span:contains('Favorites:')").first().text(), "").replace(/,/g, "").trim()) : null; + + const background = $("p[itemprop='description']").length > 0 ? $("p[itemprop='description']").parents().first().text().replace(/\s+/g, " ").trim().replace(/No background information has been added to this title/, "") || null : null; + const openingThemes = $("div.theme-songs.js-theme-songs.opnening table tr").length > 0 ? $("div.theme-songs.js-theme-songs.opnening table tr").map((i, el) => $(el).text().replace(/\s+/g, " ").trim()).get() : []; + const endingThemes = $("div.theme-songs.js-theme-songs.ending table tr").length > 0 ? $("div.theme-songs.js-theme-songs.ending table tr").map((i, el) => $(el).text().replace(/\s+/g, " ").trim()).get() : []; + const aired = $("span:contains('Aired')").length > 0 ? $("span:contains('Aired')").parent().html()?.split('\n').map(line => line.trim())[1] || null : null; + */ + + return { + id, + title: { + english: title.english, + native: title.japanese, + romaji: title.main, + }, + synonyms: title.synonyms.concat(title.alternatives), + description: synopsis, + type: Type.MANGA, + rating: score ? score : null, + popularity: popularity ? parseInt(popularity, 10) : null, + format: + format === "Music" + ? Format.MUSIC + : format === "TV" + ? Format.TV + : format === "Movie" + ? Format.MOVIE + : format === "TV Short" + ? Format.TV_SHORT + : format === "OVA" + ? Format.OVA + : format === "ONA" + ? Format.ONA + : format === "Manga" + ? Format.MANGA + : format === "One-shot" + ? Format.ONE_SHOT + : format === "Doujinshi" + ? Format.MANGA + : format === "Light Novel" + ? Format.NOVEL + : format === "Novel" + ? Format.NOVEL + : format === "Special" + ? Format.SPECIAL + : format === "TV Special" + ? Format.TV_SHORT + : format === "Manhwa" + ? Format.MANGA + : format === "Manhua" + ? Format.MANGA + : Format.UNKNOWN, + totalVolumes: volumes ? volumes : null, + totalChapters: chapters ? chapters : null, + status: status === "Finished Airing" ? MediaStatus.FINISHED : status === "Currently Airing" ? MediaStatus.RELEASING : MediaStatus.NOT_YET_RELEASED, + coverImage: imageURL, + genres: genres.concat(explicitGenres), + relations, + year: published ? new Date(published.split(" to")[0]).getFullYear() : null, + artwork: [], + bannerImage: "", + characters: [], + color: "", + countryOfOrigin: "", + author: null, + publisher: null, + tags: themes, }; - }; - approved: boolean; - titles: { - type: string; - title: string; - }[]; -}; + } + + override async proxyCheck(): Promise { + const request = await this.request(this.url); + if (request.ok) { + return true; + } else { + return false; + } + } +} diff --git a/anify-backend/src/mappings/impl/information/mangadex.ts b/anify-backend/src/mappings/impl/information/mangadex.ts index 90e957e..fd7f081 100644 --- a/anify-backend/src/mappings/impl/information/mangadex.ts +++ b/anify-backend/src/mappings/impl/information/mangadex.ts @@ -88,8 +88,17 @@ export default class MangaDexInfo extends InformationProvider element.type === "author")?.attributes.name ?? null, publisher: data.relationships.find((element: any) => element.type === "publisher")?.attributes.name ?? null, } as MangaInfo; - } catch (e) { + } catch { return undefined; } } + + override async proxyCheck(): Promise { + const request = await this.request(this.url); + if (request.ok) { + return true; + } else { + return false; + } + } } diff --git a/anify-backend/src/mappings/impl/information/novelupdates.ts b/anify-backend/src/mappings/impl/information/novelupdates.ts index c47dbb1..e1c6a17 100644 --- a/anify-backend/src/mappings/impl/information/novelupdates.ts +++ b/anify-backend/src/mappings/impl/information/novelupdates.ts @@ -26,7 +26,7 @@ export default class NovelUpdatesInfo extends InformationProvider { + const request = await this.request(this.url); + if (request.ok) { + return true; + } else { + return false; + } + } } diff --git a/anify-backend/src/mappings/impl/information/tmdb.ts b/anify-backend/src/mappings/impl/information/tmdb.ts index 8675094..59869ca 100644 --- a/anify-backend/src/mappings/impl/information/tmdb.ts +++ b/anify-backend/src/mappings/impl/information/tmdb.ts @@ -70,7 +70,7 @@ export default class TMDB extends InformationProvider { + const request = await this.request(this.url); + if (request.ok) { + return true; + } else { + return false; + } + } } diff --git a/anify-backend/src/mappings/impl/information/tvdb.ts b/anify-backend/src/mappings/impl/information/tvdb.ts index 5671a75..333a8b0 100644 --- a/anify-backend/src/mappings/impl/information/tvdb.ts +++ b/anify-backend/src/mappings/impl/information/tvdb.ts @@ -260,6 +260,15 @@ export default class TVDB extends InformationProvider { + const request = await this.request(this.url); + if (request.ok) { + return true; + } else { + return false; + } + } } interface Artwork { diff --git a/anify-backend/src/mappings/impl/manga/1stkissnovel.ts b/anify-backend/src/mappings/impl/manga/1stkissnovel.ts index c543c69..5516571 100644 --- a/anify-backend/src/mappings/impl/manga/1stkissnovel.ts +++ b/anify-backend/src/mappings/impl/manga/1stkissnovel.ts @@ -104,4 +104,13 @@ export default class FirstKissNovel extends MangaProvider { const $ = load(data); return $("div.text-left").toString(); } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return false; + } else { + return true; + } + } } diff --git a/anify-backend/src/mappings/impl/manga/comick.ts b/anify-backend/src/mappings/impl/manga/comick.ts index 581014a..ff8d245 100644 --- a/anify-backend/src/mappings/impl/manga/comick.ts +++ b/anify-backend/src/mappings/impl/manga/comick.ts @@ -7,7 +7,7 @@ export default class ComicK extends MangaProvider { override id = "comick"; override url = "https://comick.cc"; - public needsProxy: boolean = true; + public needsProxy: boolean = false; override formats: Format[] = [Format.MANGA, Format.ONE_SHOT]; @@ -117,6 +117,15 @@ export default class ComicK extends MangaProvider { const data: Comic = json.comic; return data ? data.hid : null; } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return false; + } else { + return true; + } + } } interface SearchResult { diff --git a/anify-backend/src/mappings/impl/manga/index.ts b/anify-backend/src/mappings/impl/manga/index.ts index b9fbd60..3e84afa 100644 --- a/anify-backend/src/mappings/impl/manga/index.ts +++ b/anify-backend/src/mappings/impl/manga/index.ts @@ -31,7 +31,7 @@ export default abstract class MangaProvider { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - async fetchPages(id: string): Promise { + async fetchPages(id: string, proxy: boolean = false, chapter: Chapter | null = null): Promise { return undefined; } @@ -44,6 +44,10 @@ export default abstract class MangaProvider { return Http.request(this.id, this.useGoogleTranslate, url, config, proxyRequest, 0, this.customProxy); } + async proxyCheck(): Promise { + return undefined; + } + padNum(number: string, places: number): string { // Credit to https://stackoverflow.com/a/10073788 /* diff --git a/anify-backend/src/mappings/impl/manga/jnovels.ts b/anify-backend/src/mappings/impl/manga/jnovels.ts index 90fb3ab..02ae47a 100644 --- a/anify-backend/src/mappings/impl/manga/jnovels.ts +++ b/anify-backend/src/mappings/impl/manga/jnovels.ts @@ -12,12 +12,12 @@ export default class JNovels extends MangaProvider { override formats: Format[] = [Format.NOVEL]; override async search(query: string): Promise { - const lightNovels = await (await this.request(`${this.url}/11light-1novel27-pdf/`)).text(); + const lightNovels = await (await this.request(`${this.url}/light-novel-pdf-jp/`)).text(); const novelResults = await this.handleSearchResults(query, lightNovels); if (novelResults?.length > 0) return novelResults; - const webNovels = await (await this.request(`${this.url}/hwebnovels-lista14/`)).text(); + const webNovels = await (await this.request(`${this.url}/webnovel-list-jp/`)).text(); const webResults = await this.handleSearchResults(query, webNovels); return webResults; @@ -93,4 +93,13 @@ export default class JNovels extends MangaProvider { override async fetchPages(id: string): Promise { return `No content able to read! You may download the novel here.`; } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return false; + } else { + return true; + } + } } diff --git a/anify-backend/src/mappings/impl/manga/mangadex.ts b/anify-backend/src/mappings/impl/manga/mangadex.ts index c7b162d..e812cc4 100644 --- a/anify-backend/src/mappings/impl/manga/mangadex.ts +++ b/anify-backend/src/mappings/impl/manga/mangadex.ts @@ -152,7 +152,11 @@ export default class MangaDex extends MangaProvider { } }); - chapters.length > 0 ? chapterList.push(...chapters) : (run = false); + if (chapters.length > 0) { + chapterList.push(...chapters); + } else { + run = false; + } } return chapterList; @@ -185,4 +189,13 @@ export default class MangaDex extends MangaProvider { } return pages; } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return false; + } else { + return true; + } + } } diff --git a/anify-backend/src/mappings/impl/manga/mangafire.ts b/anify-backend/src/mappings/impl/manga/mangafire.ts index 301fc87..52ca481 100644 --- a/anify-backend/src/mappings/impl/manga/mangafire.ts +++ b/anify-backend/src/mappings/impl/manga/mangafire.ts @@ -60,7 +60,7 @@ export default class MangaFire extends MangaProvider { await Promise.all(requestPromises); return results; - } catch (e) { + } catch { return undefined; } } @@ -209,6 +209,15 @@ export default class MangaFire extends MangaProvider { } }); } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData!.length === 0) { + return false; + } else { + return true; + } + } } interface ImageResponse { diff --git a/anify-backend/src/mappings/impl/manga/mangakakalot.ts b/anify-backend/src/mappings/impl/manga/mangakakalot.ts index cdad924..f961727 100644 --- a/anify-backend/src/mappings/impl/manga/mangakakalot.ts +++ b/anify-backend/src/mappings/impl/manga/mangakakalot.ts @@ -8,67 +8,53 @@ export default class Mangakakalot extends MangaProvider { override id = "mangakakalot"; override url = "https://mangakakalot.com"; + private secondURL = "https://chapmanganato.com"; + override formats: Format[] = [Format.MANGA, Format.ONE_SHOT]; override async search(query: string, format?: Format): Promise { - const temp = await ( - await this.request(`${this.url}/home_json_search`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: `searchword=${query}&searchstyle=1`, - }) - ).text(); - - let data: SearchResult[] = []; - - try { - data = JSON.parse(temp); - } catch (e) { - data = JSON.parse(temp.split("")[1]); - } - const results: Result[] = []; + const data = await (await this.request(`${this.url}/search/story/${query.replace(/ /g, "_")}`)).text(); + const $ = load(data); + const promises: Promise[] = []; - for (let i = 0; i < data.length; i++) { - const result = data[i]; - - promises.push( - new Promise(async (resolve) => { - const id = result.story_link.startsWith(this.url) ? result.story_link.split(this.url)[1].slice(1) : result.story_link.split("/")[3]; - const url = id.includes("manga/") ? this.url : "https://readmanganato.com"; - - const data = await (await this.request(`${url}/${id}`)).text(); - const $ = load(data); - - const title: string = url === "https://readmanganato.com" ? $("div.panel-story-info > div.story-info-right > h1").text() : $("div.manga-info-top > ul > li:nth-child(1) > h1").text(); - const img: string = (url === "https://readmanganato.com" ? $("div.story-info-left > span.info-image > img").attr("src") : $("div.manga-info-top > div > img").attr("src")) ?? ""; - const altTitles: string[] = - url === "https://readmanganato.com" - ? $("div.story-info-right > table > tbody > tr:nth-child(1) > td.table-value > h2").text().split(";") - : $("div.manga-info-top > ul > li:nth-child(1) > h2") - .text() - .replace("Alternative :", "") - .split(";") - .map((x) => x.trim()); - - results.push({ - id: id, - title: title, - altTitles, - img, - format: format ?? Format.UNKNOWN, - year: 0, - providerId: this.id, - }); + $("div.daily-update > div > div").map((i, el) => { + const id = $(el).find("div h3 a").attr("href")?.split("/")[3]; + const title = $(el).find("div h3 a").text(); + const img = $(el).find("a img").attr("src"); + + const promise = new Promise(async (resolve) => { + const url = id?.includes("read") ? this.url : this.secondURL; + const data = await (await this.request(`${url}/${id}`)).text(); + + const $$ = load(data); + + const altTitles: string[] = + url === this.secondURL + ? $$("div.story-info-right > table > tbody > tr:nth-child(1) > td.table-value > h2").text().split(";") + : $$("div.manga-info-top > ul > li:nth-child(1) > h2") + .text() + .replace("Alternative :", "") + .split(";") + .map((x) => x.trim()); + + results.push({ + id: id ?? "", + altTitles, + format: format ?? Format.UNKNOWN, + img: img ?? "", + providerId: this.id, + title, + year: 0, + }); - resolve(); - }), - ); - } + resolve(); + }); + + promises.push(promise); + }); await Promise.all(promises); @@ -95,7 +81,7 @@ export default class Mangakakalot extends MangaProvider { }); }); } else { - const data = await (await this.request(`https://readmanganato.com/${id}`)).text(); + const data = await (await this.request(`${this.secondURL}/${id}`)).text(); const $ = load(data); $("div.container-main-left > div.panel-story-chapter-list > ul > li") @@ -103,7 +89,7 @@ export default class Mangakakalot extends MangaProvider { .reverse() .map((el, i) => { chapters.push({ - id: ($(el).find("a").attr("href")?.split(".com/")[1] ?? "") + "$$READMANGANATO", + id: `${id}/${$(el).find("a").attr("href")?.split(`${id}/`)[1] ?? ""}`, title: $(el).find("a").text(), number: i + 1, rating: null, @@ -116,7 +102,7 @@ export default class Mangakakalot extends MangaProvider { } override async fetchPages(id: string): Promise { - const url = !id.includes("$$READMANGANATO") ? `${this.url}/chapter/${id}` : `https://readmanganato.com/${id.replace("$$READMANGANATO", "")}`; + const url = !id.includes("manga") ? `${this.url}/chapter/${id}` : `${this.secondURL}/${id}`; const data = await (await this.request(url)).text(); const $ = load(data); @@ -133,14 +119,13 @@ export default class Mangakakalot extends MangaProvider { return pages; } -} -interface SearchResult { - id: string; - name: string; - nameunsigned: string; - lastchapter: string; - image: string; - author: string; - story_link: string; + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData!.length === 0) { + return false; + } else { + return true; + } + } } diff --git a/anify-backend/src/mappings/impl/manga/mangapill.ts b/anify-backend/src/mappings/impl/manga/mangapill.ts index 52734cc..5a4008e 100644 --- a/anify-backend/src/mappings/impl/manga/mangapill.ts +++ b/anify-backend/src/mappings/impl/manga/mangapill.ts @@ -69,4 +69,13 @@ export default class MangaPill extends MangaProvider { return pages; } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData!.length === 0) { + return false; + } else { + return true; + } + } } diff --git a/anify-backend/src/mappings/impl/manga/mangasee.ts b/anify-backend/src/mappings/impl/manga/mangasee.ts index 0500690..59374b4 100644 --- a/anify-backend/src/mappings/impl/manga/mangasee.ts +++ b/anify-backend/src/mappings/impl/manga/mangasee.ts @@ -135,6 +135,15 @@ export default class MangaSee extends MangaProvider { const data: [SearchResult] = (await req.json()) as [SearchResult]; return data; } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData!.length === 0) { + return false; + } else { + return true; + } + } } interface SearchResult { diff --git a/anify-backend/src/mappings/impl/manga/novelhall.ts b/anify-backend/src/mappings/impl/manga/novelhall.ts index dbdaf57..851f583 100644 --- a/anify-backend/src/mappings/impl/manga/novelhall.ts +++ b/anify-backend/src/mappings/impl/manga/novelhall.ts @@ -77,4 +77,13 @@ export default class NovelHall extends MangaProvider { const $ = load(data); return $("div#htmlContent.entry-content").toString(); } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData!.length === 0) { + return false; + } else { + return true; + } + } } diff --git a/anify-backend/src/mappings/impl/manga/novelupdates.ts b/anify-backend/src/mappings/impl/manga/novelupdates.ts index 1794bdf..95ae59e 100644 --- a/anify-backend/src/mappings/impl/manga/novelupdates.ts +++ b/anify-backend/src/mappings/impl/manga/novelupdates.ts @@ -54,21 +54,17 @@ export default class NovelUpdates extends MangaProvider { }; override async search(query: string, format?: Format, year?: number, retries = 0): Promise { - if (this.customProxy) { - // For proxy testing purposes only. - return (await this.fetchChapters(query)) as undefined; - } - - if (retries >= 5) return undefined; - const results: Result[] = []; + this.useGoogleTranslate = false; const searchData = await this.request(`${this.url}/series-finder/?sf=1&sh=${encodeURIComponent(query)}&nt=2443,26874,2444&ge=${this.genreMappings.ADULT}&sort=sread&order=desc`, { method: "GET", headers: { Referer: this.url, + "User-Agent": "Mozilla/5.0", }, }); + this.useGoogleTranslate = true; const data = await searchData.text(); @@ -103,89 +99,170 @@ export default class NovelUpdates extends MangaProvider { const chapters: Chapter[] = []; - let data = await (await this.request(`${this.url}/series/${id}`, { headers: { Referer: this.url } })).text(); - let $ = load(data); + // Might need to test if there are links or not. If the cookie is expired, then there won't be any links. + // NovelUpdates recently changed things and server-renders all their chapter links. + let hasNextPage = true; - const title = $("title").html(); - if (title === "Page not found - Novel Updates") { + for (let i = 1; hasNextPage; i++) { this.useGoogleTranslate = false; - - data = await ( - await this.request(`${this.url}/series/${id}`, { - headers: { - Referer: this.url, - Origin: this.url, + const data = await ( + await this.request( + `${this.url}/series/${id}/?pg=${i}#myTable`, + { + headers: { + Cookie: env.NOVELUPDATES_LOGIN ?? "", + "User-Agent": "Mozilla/5.0", + }, }, - }) - ).text(); + false, + ) + ).text(); // might need to change to true + this.useGoogleTranslate = true; - $ = load(data); + const $ = load(data); - this.useGoogleTranslate = true; - } - if (title === "Just a moment..." || title === "Attention Required! | Cloudflare") { - return this.fetchChapters(id, retries + 1); + if ($("div.l-submain table#myTable tr").length < 1 || !$("div.l-submain table#myTable tr")) { + hasNextPage = false; + break; + } else { + for (let l = 0; l < $("div.l-submain table#myTable tr").length; l++) { + const title = $("div.l-submain table#myTable tr").eq(l).find("td a.chp-release").attr("title"); + const id = $("div.l-submain table#myTable tr").eq(l).find("td a.chp-release").attr("href")?.split("/extnu/")[1].split("/")[0]; + + if (!title || !id) continue; + + if ((chapters.length > 0 && chapters[chapters.length - 1].id === id) || chapters.find((c) => c.id === id)) { + hasNextPage = false; + break; + } + + chapters.push({ + id: id!, + title: title!, + number: l, + rating: null, + updatedAt: new Date($("div.l-submain table#myTable tr").eq(l).find("td").first().text().trim()).getTime(), + }); + } + } } - const postId = $("input#mypostid").attr("value"); + if (chapters.length === 0) { + console.log("WARNING: Cookie seems to not work. Trying without cookie."); + // More scuffed version that doesn't seem to work anymore. I think NovelUpdates changed things + // and now their admin-ajax will return randomized chapters to prevent scrapers. GG + + const $ = load( + await ( + await this.request(`${this.url}/series/${id}/`, { + headers: { + Referer: this.url, + "User-Agent": "Mozilla/5.0", + }, + }) + ).text(), + ); - this.useGoogleTranslate = false; - const chapterData = ( - await ( - await this.request(`${this.url}/wp-admin/admin-ajax.php`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - Cookie: env.NOVELUPDATES_LOGIN ?? "", - Origin: this.url, - }, - body: `action=nd_getchapters&mypostid=${postId}&mypostid2=0`, - }) - ).text() - ).substring(1); + const postId = $("input#mypostid").attr("value"); - this.useGoogleTranslate = true; + this.useGoogleTranslate = false; + const chapterData = ( + await ( + await this.request(`${this.url}/wp-admin/admin-ajax.php`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + Cookie: env.NOVELUPDATES_LOGIN ?? "", + "User-Agent": "Mozilla/5.0", + }, + body: `action=nd_getchapters&mypostid=${postId}&mygrr=0`, + }) + ).text() + ).substring(1); - const $$ = load(chapterData); + this.useGoogleTranslate = true; - if (chapterData.includes("not whitelisted by the operator of this proxy") || $$("title").html() === "Just a moment...") return this.fetchChapters(id, retries + 1); + const $$ = load(chapterData); - const uniqueTitles = new Set(); - $$("li.sp_li_chp a[data-id]").each((index, el) => { - const id = $$(el).attr("data-id"); - const title = $$(el).find("span").text(); + if (chapterData.includes("not whitelisted by the operator of this proxy") || $$("title").html() === "Just a moment...") return this.fetchChapters(id, retries + 1); - if (!uniqueTitles.has(title)) { - uniqueTitles.add(title); + const uniqueTitles = new Set(); + $$("li.sp_li_chp a[data-id]").each((index, el) => { + const id = $$(el).attr("data-id"); + const title = $$(el).find("span").text(); - chapters.push({ - id: id!, - title: title!, - number: index + 1, - rating: null, - }); - } - }); + if (!uniqueTitles.has(title)) { + uniqueTitles.add(title); + + chapters.push({ + id: id!, + title: title!, + number: index + 1, + rating: null, + }); + } + }); + + return chapters.reverse(); + } return chapters.reverse(); } - override async fetchPages(id: string): Promise { - const req = await this.request(`${this.url}/extnu/${id}/`, { - method: "GET", - headers: { - Cookie: "_ga=;", + override async fetchPages(id: string, proxy: boolean = true, chapter: Chapter | null = null): Promise { + const req = await this.request( + `${this.url}/extnu/${id}/`, + { + method: "GET", + headers: { + Cookie: "_ga=;", + "User-Agent": "Mozilla/5.0", + }, + redirect: "follow", }, - redirect: "follow", - }); + proxy, + ); - if (!req.ok) return undefined; + if (req.status === 500 || req.statusText === "Timeout" || (req.status === 400 && req.statusText === "Bad Request")) return await this.fetchPages(id, false, chapter); const data = await req.text(); const $ = load(data); const baseURL = $("base").attr("href")?.replace("http://", "https://") ?? this.url; - return await this.extractChapter(baseURL); + return await this.extractChapter(baseURL, chapter); + } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData!.length === 0) { + // Testing + console.log("Search failed"); + + return false; + } else { + const extractionTest = await new Promise(async (resolve) => { + const storySeedling = await this.request("https://storyseedling.com/"); + const neosekai = await this.request("https://www.neosekaitranslations.com/"); + const zetro = await this.request("https://zetrotranslation.com/"); + + // Testing + if (!storySeedling.ok) console.log("StorySeedling failed"); + if (!neosekai.ok) console.log("Neosekai failed"); + if (!zetro.ok) console.log("Zetro failed"); + + if (storySeedling.ok && neosekai.ok && zetro.ok) { + return resolve(true); + } else { + return resolve(false); + } + }); + + if (!extractionTest) { + return false; + } else { + return true; + } + } } /** @@ -193,7 +270,7 @@ export default class NovelUpdates extends MangaProvider { * @param url string * @returns Promise */ - private async extractChapter(url: string): Promise { + private async extractChapter(url: string, chapter: Chapter | null = null): Promise { if (url.includes("storyseedling") && url.includes("rss")) { const $ = load(await (await this.request(url)).text()); const forwardTimer = $("div[@click=\"$dispatch('tools')\"] > div").attr("x-data"); @@ -215,23 +292,288 @@ export default class NovelUpdates extends MangaProvider { const $ = load(await (await this.request(url)).text()); return $("div.reader-content").html() ?? ""; } + } else if (url.includes("vampiramtl")) { + const $ = load(await (await this.request(url)).text()); + if (url.includes("tgs")) { + return $("div.entry-content").html() ?? ""; + } else { + const url = $("div.entry-content a").attr("href"); + try { + this.useGoogleTranslate = false; + const $$ = load(await (await this.request(url ?? "")).text()); + this.useGoogleTranslate = true; + + return $$("div.entry-content").html() ?? ""; + } catch { + console.log("Error fetching chapter content for VampiraMTL."); + this.useGoogleTranslate = true; + return undefined; + } + } + } else if (url.includes("neosekaitranslations")) { + const $ = load(await (await this.request(url)).text()); + if (($("div.entry-content div.reading-content")?.html() ?? []).length === 0 && chapter && chapter?.title.length > 0) { + const mangaId = $("div#manga-chapters-holder").attr("data-id"); + + this.useGoogleTranslate = false; + const data = await ( + await this.request("https://www.neosekaitranslations.com/wp-admin/admin-ajax.php", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Requested-With": "XMLHttpRequest", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.118 Safari/537.36", + Referer: url, + }, + body: `action=manga_get_chapters&manga=${mangaId}`, + }) + ).text(); + this.useGoogleTranslate = true; + + const $$ = load(data); + + const novelVolume = (chapter?.title.startsWith("v") ? chapter?.title.split("v")[1]?.split(" ")[0] : "").split("c")[0]; + const novelChapter = (chapter?.title.startsWith("v") ? chapter?.title.split("v")[1]?.split(" ")[0]?.split("c")[1] : chapter?.title.startsWith("c") ? chapter?.title.split("c")[1].split(" ")[0] : "") ?? ""; + const novelPart = chapter?.title.split(" ")[1]?.replace(/[^\d .-]/gi, "") ?? ""; + const novelPrologue = chapter?.title.split(" ").length > 1 ? chapter?.title.split(" ")[1]?.includes("prologue") : chapter?.title.includes("prologue"); + const novelIllustrations = chapter?.title.split(" ").length > 1 ? chapter?.title.split(" ")[1]?.includes("illustrations") : chapter?.title.includes("illustrations"); + + for (let i = 0; i < $$("ul.version-chap li.wp-manga-chapter").toArray().length; i++) { + const el = $$("ul.version-chap li.wp-manga-chapter")[i]; + + const title = $$(el).find("a").text().trim(); + + const volume = title.toLowerCase().split("v")[1]?.split(" ")[0] ?? ""; + const chapter = title.toLowerCase().split("chapter ")[1]?.split(" ")[0] ?? title.toLowerCase().split("ch-")[1]?.split(" ")[0] ?? title.toLowerCase().split("chp ")[1]?.split(" ")[0] ?? title.toLowerCase().split("episode ")[1]?.split(" ")[0] ?? ""; + const part = + title + .toLowerCase() + .split("part ")[1] + ?.split(" ")[0] + ?.replace(/[^\d .-]/gi, "") ?? ""; + + const isPrologue = (chapter.length < 1 || chapter === "0" || chapter === "0.5") && title.toLowerCase().includes("prologue"); + const isIllustrations = (chapter.length < 1 || chapter === "0" || chapter === "0.5") && title.toLowerCase().includes("illustrations"); + + if ((volume?.length > 0 && novelVolume?.length > 0 ? novelVolume === volume : true) && isPrologue && novelPrologue) { + const newURL = $$(el).find("a").attr("href"); + const $$$ = load(await (await this.request(newURL ?? "", {}, false)).text()); + + return $$$("div.entry-content div.reading-content").html() ?? ""; + } + + if ((volume?.length > 0 && novelVolume?.length > 0 ? novelVolume === volume : true) && isIllustrations && novelIllustrations) { + const newURL = $$(el).find("a").attr("href"); + const $$$ = load(await (await this.request(newURL ?? "", {}, false)).text()); + + return $$$("div.entry-content div.reading-content").html() ?? ""; + } + + if ((volume?.length > 0 && novelVolume?.length > 0 ? novelVolume === volume : true) && novelChapter === chapter && (novelPart.length > 0 ? novelPart === part : true) && !isPrologue && !isIllustrations) { + const newURL = $$(el).find("a").attr("href"); + const $$$ = load(await (await this.request(newURL ?? "", {}, false)).text()); + + return $$$("div.entry-content div.reading-content").html() ?? ""; + } + } + + console.log(`ERROR. Could not parse chapters for Neosekai. Edge case needs to be coded in.`); + console.log({ + novelVolume, + novelChapter, + novelPart, + novelPrologue, + novelIllustrations, + }); + } else { + const $ = load(await (await this.request(url)).text()); + return $("div.entry-content div.reading-content").html() ?? ""; + } } else if (url.includes("plebianfinetranslation")) { const $ = load(await (await this.request(url)).text()); return $("div.entry-content").html() ?? ""; } else if (url.includes("zetrotranslation")) { const $ = load(await (await this.request(url)).text()); - return $("div.entry-content_wrap").html() ?? ""; + if (($("div.entry-content_wrap div.reading-content")?.html() ?? []).length === 0 && chapter && chapter?.title.length > 0) { + const mangaId = $("div#manga-chapters-holder").attr("data-id"); + const data = await ( + await this.request( + "https://zetrotranslation.com/wp-admin/admin-ajax.php", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Requested-With": "XMLHttpRequest", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.118 Safari/537.36", + Referer: url, + }, + body: `action=manga_get_chapters&manga=${mangaId}`, + }, + false, + ) + ).text(); + + const $$ = load(data); + + const novelVolume = (chapter?.title.startsWith("v") ? chapter?.title.split("v")[1]?.split(" ")[0] : "").split("c")[0]; + const novelChapter = (chapter?.title.startsWith("v") ? chapter?.title.split("v")[1]?.split(" ")[0]?.split("c")[1] : chapter?.title.startsWith("c") ? chapter?.title.split("c")[1].split(" ")[0] : "") ?? ""; + const novelPrologue = chapter?.title.split(" ").length > 1 ? chapter?.title.split(" ")[1]?.includes("prologue") : chapter?.title.includes("prologue"); + const novelIllustrations = chapter?.title.split(" ").length > 1 ? chapter?.title.split(" ")[1]?.includes("illustrations") : chapter?.title.includes("illustrations"); + + const zetroData: { + volume: string; + chapter: string; + part: string; + prologue: boolean; + illustrations: boolean; + data?: string; + }[] = []; + + for (let i = 0; i < ($$("ul.sub-chap-list li.wp-manga-chapter").toArray().length > 0 ? $$("ul.sub-chap-list li.wp-manga-chapter").toArray().length : $$("li.wp-manga-chapter").length); i++) { + const el = ($$("ul.sub-chap-list li.wp-manga-chapter").toArray().length > 0 ? $$("ul.sub-chap-list li.wp-manga-chapter").toArray() : $$("li.wp-manga-chapter"))[i]; + + const title = $$(el).find("a").text().trim(); + const volume = ($$(el).parent().parent().parent().parent().find("a.has-child").text() ?? null)?.split(" ")[0] ?? ""; + + const isPrologue = title.toLowerCase().includes("prologue"); + const isIllustrations = title.toLowerCase().includes("illustrations"); + + const chapter = title.toLowerCase().split("chapter ")[1]?.split(" ")[0] ?? title.toLowerCase().split("ch-")[1]?.split(" ")[0] ?? title.toLowerCase().split("chp ")[1]?.split(" ")[0] ?? title.toLowerCase().split("episode ")[1]?.split(" ")[0] ?? ""; + + if ((volume?.length > 0 && novelVolume?.length > 0 ? novelVolume === volume : true) && isPrologue && novelPrologue) { + const newURL = $$(el).find("a").attr("href"); + const $$$ = load(await (await this.request(newURL ?? "", {}, false)).text()); + + const currentData = zetroData.find((d) => d.volume === volume && d.prologue); + if (currentData) { + currentData.data = $$$("div.entry-content_wrap div.reading-content").html() ?? ""; + } else { + zetroData.push({ + volume, + chapter, + part: "", + prologue: isPrologue, + illustrations: isIllustrations, + data: $$$("div.entry-content_wrap").html() ?? "", + }); + } + } + + if ((volume?.length > 0 && novelVolume?.length > 0 ? novelVolume === volume : true) && isIllustrations && novelIllustrations) { + const newURL = $$(el).find("a").attr("href"); + const $$$ = load(await (await this.request(newURL ?? "", {}, false)).text()); + + const currentData = zetroData.find((d) => d.volume === volume && d.illustrations); + if (currentData) { + currentData.data = $$$("div.entry-content_wrap div.reading-content").html() ?? ""; + } else { + zetroData.push({ + volume, + chapter, + part: "", + prologue: isPrologue, + illustrations: isIllustrations, + data: $$$("div.entry-content_wrap").html() ?? "", + }); + } + } + + if ((volume?.length > 0 && novelVolume?.length > 0 ? novelVolume === volume : true) && novelChapter === chapter && !isPrologue && !isIllustrations) { + const newURL = $$(el).find("a").attr("href"); + const $$$ = load(await (await this.request(newURL ?? "", {}, false)).text()); + + const currentData = zetroData.find((d) => d.volume === volume && d.chapter === chapter); + if (currentData) { + currentData.data = $$$("div.entry-content_wrap div.reading-content").html() ?? ""; + } else { + zetroData.push({ + volume, + chapter, + part: "", + prologue: isPrologue, + illustrations: isIllustrations, + data: $$$("div.entry-content_wrap div.reading-content").html() ?? "", + }); + } + } + } + + const item = zetroData.find((d) => (novelVolume.length > 0 ? d.volume === novelVolume : true && novelChapter.length > 0 ? d.chapter === novelChapter : true && d.illustrations === novelIllustrations && d.prologue === novelPrologue)); + return item?.data ?? ""; + } else { + return $("div.entry-content_wrap").html() ?? ""; + } + } else if (url.includes("machineslicedbread")) { + const $ = load(await (await this.request(url)).text()); + const newURL = $("div.entry-content a").first().attr("href"); + + if (!newURL) return undefined; + + const data = await this.request(newURL ?? "", {}, false); + + const $$ = load(await data.text()); + return $$("div.entry-content").html() ?? ""; + } else if (url.includes("gakuseitranslations")) { + const $ = load(await (await this.request(url)).text()); + if (url.split("/").length > 4) { + const newURL = $("div.entry-content a") + .toArray() + .map((el) => { + if ($(el).attr("href")?.includes("gakuseitranslations") && !$(el).attr("href")?.includes("patreon")) { + console.log($(el).attr("href")); + return $(el).attr("href"); + } + })[0]; + + if (!newURL) return undefined; + + const data = await this.request(newURL ?? "", {}, false); + const $$ = load(await data.text()); + return $$("div.entry-content").html() ?? ""; + } else { + return $("div.entry-content").html() ?? ""; + } + } else if (url.includes("one-fourthassed")) { + const $ = load(await (await this.request(url)).text()); + const newURL = $("div#maia-main a.maia-button-primary").attr("href"); + + if (!newURL) return undefined; + + const data = await this.request(newURL ?? "", {}, false); + const $$ = load(await data.text()); + return $$("div.entry-content").html() ?? ""; + } else if (url.includes("kusomtl")) { + if (url.split("/").length > 4) { + const $ = load(await (await this.request(url)).text()); + const newURL = $("div.entry-content a").first().attr("href"); + + if (!newURL) return undefined; + + const data = await this.request(newURL ?? "", {}, false); + const $$ = load(await data.text()); + return $$("div.entry-content").html() ?? ""; + } else { + const $ = load(await (await this.request(url)).text()); + return $("div.entry-content").html() ?? ""; + } + } else if (url.includes("dasuitl")) { + const $ = load(await (await this.request(url)).text()); + return $("article div.entry-content").html() ?? ""; } else { - const article = await extract( - url, - {}, - { - headers: { - Cookie: "_ga=;", + try { + const article = await extract( + url, + {}, + { + headers: { + Cookie: "_ga=;", + }, }, - }, - ); - return article?.content; + ); + return article?.content; + } catch { + return "Error extracting chapter content for " + url + "."; + } } } } diff --git a/anify-backend/src/mappings/impl/meta/anidb.ts b/anify-backend/src/mappings/impl/meta/anidb.ts index c82a1d9..1aad6a5 100644 --- a/anify-backend/src/mappings/impl/meta/anidb.ts +++ b/anify-backend/src/mappings/impl/meta/anidb.ts @@ -68,4 +68,13 @@ export default class AniDBMeta extends MetaProvider { return results; } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return true; + } else { + return false; + } + } } diff --git a/anify-backend/src/mappings/impl/meta/anilist.ts b/anify-backend/src/mappings/impl/meta/anilist.ts index 215acd2..2b4cde3 100644 --- a/anify-backend/src/mappings/impl/meta/anilist.ts +++ b/anify-backend/src/mappings/impl/meta/anilist.ts @@ -70,6 +70,15 @@ export default class AniListMeta extends MetaProvider { return results; } + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return true; + } else { + return false; + } + } + public query = ` id idMal diff --git a/anify-backend/src/mappings/impl/meta/index.ts b/anify-backend/src/mappings/impl/meta/index.ts index eb74ef9..30a1c88 100644 --- a/anify-backend/src/mappings/impl/meta/index.ts +++ b/anify-backend/src/mappings/impl/meta/index.ts @@ -29,4 +29,8 @@ export default abstract class MetaProvider { return Http.request(this.id, this.useGoogleTranslate, url, config, proxyRequest, 0, this.customProxy); } + + async proxyCheck(): Promise { + return undefined; + } } diff --git a/anify-backend/src/mappings/impl/meta/kitsu.ts b/anify-backend/src/mappings/impl/meta/kitsu.ts index d96bd4a..cd68057 100644 --- a/anify-backend/src/mappings/impl/meta/kitsu.ts +++ b/anify-backend/src/mappings/impl/meta/kitsu.ts @@ -53,7 +53,7 @@ export default class KitsuMeta extends MetaProvider { }); }); } - } catch (e) { + } catch { // } @@ -95,12 +95,21 @@ export default class KitsuMeta extends MetaProvider { }); }); } - } catch (e) { + } catch { // } return results; } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return true; + } else { + return false; + } + } } type KitsuResult = { diff --git a/anify-backend/src/mappings/impl/meta/mal.ts b/anify-backend/src/mappings/impl/meta/mal.ts index 612435f..7bc741b 100644 --- a/anify-backend/src/mappings/impl/meta/mal.ts +++ b/anify-backend/src/mappings/impl/meta/mal.ts @@ -1,13 +1,12 @@ +import { load } from "cheerio"; import MetaProvider from "."; -import { Format } from "../../../types/enums"; +import { Format, Type } from "../../../types/enums"; import { Result } from "../../../types/types"; export default class MALMeta extends MetaProvider { override id = "mal"; override url = "https://myanimelist.net"; - private api = "https://api.jikan.moe/v4"; - public needsProxy: boolean = true; override rateLimit = 500; @@ -16,91 +15,153 @@ export default class MALMeta extends MetaProvider { override async search(query: string): Promise { const results: Result[] = []; - try { - const anime = (await (await this.request(`${this.api}/anime?q=${encodeURIComponent(query)}&sfw`)).json()) as { data: any[] }; - const manga = (await (await this.request(`${this.api}/manga?q=${encodeURIComponent(query)}&sfw`)).json()) as { data: any[] }; - - for (const data of anime.data) { - results.push({ - id: String(data.mal_id), - altTitles: data.title_synonyms?.filter((s: string) => s?.length) ?? [], - format: - data.type?.toLowerCase() === "tv" - ? Format.TV - : data.type?.toLowerCase() === "movie" - ? Format.MOVIE - : data.type?.toLowerCase() === "ova" - ? Format.OVA - : data.type?.toLowerCase() === "special" - ? Format.SPECIAL - : data.type?.toLowerCase() === "ona" - ? Format.ONA - : data.type?.toLowerCase() === "music" - ? Format.MUSIC - : data.type?.toLowerCase() === "manga" - ? Format.MANGA - : data.type?.toLowerCase() === "novel" - ? Format.NOVEL - : data.type?.toLowerCase() === "lightnovel" - ? Format.NOVEL - : data.type?.toLowerCase() === "oneshot" - ? Format.ONE_SHOT - : data.type?.toLowerCase() === "doujin" - ? Format.MANGA - : data.type?.toLowerCase() === "manhwa" - ? Format.MANGA - : data.type?.toLowerCase() === "manhua" - ? Format.MANGA - : Format.UNKNOWN, - img: data.images?.jpg?.large_image_url ?? data.images?.jpg?.image_url ?? data.images?.jpg?.small_image_url ?? null, - providerId: this.id, - title: data.title ?? data.title_english ?? data.title_japanese ?? "", - year: data.year ? data.year : data.published ? (data.published.from ? new Date(data.published).getFullYear() : data.published.prop ? data.published.prop.from?.year : null) : null, - }); - } - - for (const data of manga.data) { - results.push({ - id: String(data.mal_id), - altTitles: data.title_synonyms?.filter((s: string) => s?.length) ?? [], - format: - data.type?.toLowerCase() === "tv" - ? Format.TV - : data.type?.toLowerCase() === "movie" - ? Format.MOVIE - : data.type?.toLowerCase() === "ova" - ? Format.OVA - : data.type?.toLowerCase() === "special" - ? Format.SPECIAL - : data.type?.toLowerCase() === "ona" - ? Format.ONA - : data.type?.toLowerCase() === "music" - ? Format.MUSIC - : data.type?.toLowerCase() === "manga" - ? Format.MANGA - : data.type?.toLowerCase() === "novel" - ? Format.NOVEL - : data.type?.toLowerCase() === "lightnovel" - ? Format.NOVEL - : data.type?.toLowerCase() === "oneshot" - ? Format.ONE_SHOT - : data.type?.toLowerCase() === "doujin" - ? Format.MANGA - : data.type?.toLowerCase() === "manhwa" - ? Format.MANGA - : data.type?.toLowerCase() === "manhua" - ? Format.MANGA - : Format.UNKNOWN, - img: data.images?.jpg?.large_image_url ?? data.images?.jpg?.image_url ?? data.images?.jpg?.small_image_url ?? null, - providerId: this.id, - title: data.title ?? data.title_english ?? data.title_japanese ?? "", - year: data.year ? data.year : data.published ? (data.published.from ? new Date(data.published).getFullYear() : data.published.prop ? data.published.prop.from?.year : null) : null, - }); - } - - return results; - } catch (e) { + const anime = await this.fetchResults(query, Type.ANIME); + const manga = await this.fetchResults(query, Type.MANGA); + + if (anime) { + results.push(...anime); + } + + if (manga) { + results.push(...manga); + } + + return results; + } + + private async fetchResults(query: string, type: Type): Promise { + const results: Result[] = []; + + const corsMirror = "https://corsmirror.com"; + const url = `${this.url}/${type === Type.ANIME ? "anime" : "manga"}.php?q=${query}&c[]=a&c[]=b&c[]=c&c[]=f&c[]=d&c[]=e&c[]=g`; + const data = await (await this.request(`${corsMirror}/v1?url=${encodeURIComponent(url)}`)).text(); + const $ = load(data); + + const searchResults = $("div.js-categories-seasonal table tr").first(); + + if (!searchResults.length) { return undefined; } + + const promises: Promise[] = []; + + searchResults.nextAll().map((_, el) => { + const id = $("td:nth-child(1) div a", el).attr("id")?.split("sarea")[1] ?? ""; + const title = $("td:nth-child(2) a strong", el).text(); + const img = $("td:nth-child(1) div a img", el).attr("data-src") ?? ""; + const format = $("td:nth-child(3)", el).text()?.trim() ?? ""; + + const date = $("td:nth-child(6)", el).text()?.trim(); + + promises.push( + new Promise(async (resolve) => { + const data = await (await this.request(`${this.url}/${type === Type.ANIME ? "anime" : "manga"}/${id}`)).text(); + const $$ = load(data); + + const published = + $$("span:contains('Published:')").length > 0 + ? $$("span:contains('Published:')").parents().first().text().replace($$("span:contains('Published:')").first().text(), "").replace(/\s+/g, " ").trim() === "?" + ? null + : $$("span:contains('Published:')").parents().first().text().replace($$("span:contains('Published:')").first().text(), "").replace(/\s+/g, " ").trim() + : null; + const premiered = + $$("span:contains('Premiered:')").length > 0 + ? $$("span:contains('Premiered:')").parents().first().text().replace($$("span:contains('Premiered:')").first().text(), "").replace(/\s+/g, " ").trim() === "?" + ? null + : $$("span:contains('Premiered:')").parents().first().text().replace($$("span:contains('Premiered:')").first().text(), "").replace(/\s+/g, " ").trim() + : null; + + const year = + type === Type.ANIME + ? Number.isNaN(date === "-" ? 0 : new Date(date).getFullYear()) + ? premiered + ? parseInt(premiered.split(" ")[1]?.split(" ")[0], 10) + : null + : date === "-" + ? 0 + : new Date(date).getFullYear() + : Number.isNaN(date === "-" ? 0 : new Date(date).getFullYear()) + ? published + ? new Date(published.split(" to")[0]).getFullYear() + : null + : date === "-" + ? 0 + : new Date(date).getFullYear(); + + const alternativeTitlesDiv = $$("h2:contains('Alternative Titles')").nextUntil("h2:contains('Information')").first(); + const additionalTitles = alternativeTitlesDiv + .find("div.spaceit_pad") + .map((_, item) => { + return $$(item).text().trim(); + }) + .get(); + const titles = { + main: $$("meta[property='og:title']").attr("content") || "", + english: $$("span:contains('English:')").length > 0 ? $$("span:contains('English:')").parent().text().replace($$("span:contains('English:')").text(), "").replace(/\s+/g, " ").trim() : null, + synonyms: $$("span:contains('Synonyms:')").length > 0 ? $$("span:contains('Synonyms:')").parent().text().replace($$("span:contains('Synonyms:')").text(), "").replace(/\s+/g, " ").trim().split(", ") : [], + japanese: $$("span:contains('Japanese:')").length > 0 ? $$("span:contains('Japanese:')").parent().text().replace($$("span:contains('Japanese:')").text(), "").replace(/\s+/g, " ").trim() : null, + alternatives: additionalTitles, + }; + + const altTitles = [titles.main, titles.english, titles.japanese, ...titles.synonyms, ...titles.alternatives].filter((x) => x !== null && x !== undefined && x !== ""); + + results.push({ + id, + title, + altTitles: altTitles as string[], + year: year ?? 0, + format: + format === "Music" + ? Format.MUSIC + : format === "TV" + ? Format.TV + : format === "Movie" + ? Format.MOVIE + : format === "TV Short" + ? Format.TV_SHORT + : format === "OVA" + ? Format.OVA + : format === "ONA" + ? Format.ONA + : format === "Manga" + ? Format.MANGA + : format === "One-shot" + ? Format.ONE_SHOT + : format === "Doujinshi" + ? Format.MANGA + : format === "Light Novel" + ? Format.NOVEL + : format === "Novel" + ? Format.NOVEL + : format === "Special" + ? Format.SPECIAL + : format === "TV Special" + ? Format.TV_SHORT + : format === "Manhwa" + ? Format.MANGA + : format === "Manhua" + ? Format.MANGA + : Format.UNKNOWN, + img, + providerId: this.id, + }); + + resolve(); + }), + ); + }); + + await Promise.all(promises); + + return results; + } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return true; + } else { + return false; + } } } diff --git a/anify-backend/src/mappings/impl/meta/tmdb.ts b/anify-backend/src/mappings/impl/meta/tmdb.ts index d291a31..53e165e 100644 --- a/anify-backend/src/mappings/impl/meta/tmdb.ts +++ b/anify-backend/src/mappings/impl/meta/tmdb.ts @@ -50,6 +50,15 @@ export default class TheMovieDB extends MetaProvider { return results; } } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return true; + } else { + return false; + } + } } type TMDBResult = { diff --git a/anify-backend/src/mappings/impl/meta/tvdb.ts b/anify-backend/src/mappings/impl/meta/tvdb.ts index 6ee0c03..28b2735 100644 --- a/anify-backend/src/mappings/impl/meta/tvdb.ts +++ b/anify-backend/src/mappings/impl/meta/tvdb.ts @@ -73,6 +73,15 @@ export default class TheTVDB extends MetaProvider { return undefined; } + + override async proxyCheck(): Promise { + const searchData = await this.search("Mushoku Tensei"); + if (!searchData || searchData.length === 0) { + return true; + } else { + return false; + } + } } /* Search Types */ diff --git a/anify-backend/src/mappings/index.ts b/anify-backend/src/mappings/index.ts index d056e4a..671853d 100644 --- a/anify-backend/src/mappings/index.ts +++ b/anify-backend/src/mappings/index.ts @@ -36,8 +36,9 @@ import KitsuMeta from "./impl/meta/kitsu"; import MALMeta from "./impl/meta/mal"; import TheMovieDB from "./impl/meta/tmdb"; import TheTVDB from "./impl/meta/tvdb"; +import Sudatchi from "./impl/anime/sudatchi"; -const ANIME_PROVIDERS: AnimeProvider[] = [new NineAnime(), new AnimePahe(), new GogoAnime(), new Zoro()]; +const ANIME_PROVIDERS: AnimeProvider[] = [new NineAnime(), new AnimePahe(), new GogoAnime(), new Zoro(), new Sudatchi()]; const animeProviders: Record = ANIME_PROVIDERS.reduce( (acc, provider) => { acc[provider.id] = provider; diff --git a/anify-backend/src/proxies/impl/checkProxies.ts b/anify-backend/src/proxies/impl/checkProxies.ts index 553fb45..6c8984c 100644 --- a/anify-backend/src/proxies/impl/checkProxies.ts +++ b/anify-backend/src/proxies/impl/checkProxies.ts @@ -1,14 +1,202 @@ import colors from "colors"; import { ANIME_PROVIDERS, BASE_PROVIDERS, MANGA_PROVIDERS, META_PROVIDERS } from "../../mappings"; -import { Format, ProviderType, Type } from "../../types/enums"; import { ANIME_PROXIES, BASE_PROXIES, MANGA_PROXIES, META_PROXIES } from ".."; +import BaseProvider from "../../mappings/impl/base"; +import MetaProvider from "../../mappings/impl/meta"; import AnimeProvider from "../../mappings/impl/anime"; import MangaProvider from "../../mappings/impl/manga"; -import MetaProvider from "../../mappings/impl/meta"; -import BaseProvider from "../../mappings/impl/base"; +import InformationProvider from "../../mappings/impl/information"; const toCheck: string[] = []; +export async function checkCurrentProxies(startIndex: number = 0): Promise<{ + base: { providerId: string; ip: string }[]; + anime: { providerId: string; ip: string }[]; + manga: { providerId: string; ip: string }[]; + meta: { providerId: string; ip: string }[]; +}> { + const baseIps: { providerId: string; ip: string }[] = []; + const animeIps: { providerId: string; ip: string }[] = []; + const mangaIps: { providerId: string; ip: string }[] = []; + const metaIps: { providerId: string; ip: string }[] = []; + + const badProxies: { + base: string[]; + anime: string[]; + manga: string[]; + meta: string[]; + } = { + base: [], + anime: [], + manga: [], + meta: [], + }; + + console.log(colors.yellow("Importing proxies... Please note that reading the proxies file may take a while.")); + + const baseProxies = Bun.file("./baseProxies.json"); + if (await baseProxies.exists()) { + // Check proxies.json + const proxies = await baseProxies.json(); + for (let i = 0; i < proxies.length; i++) { + baseIps.push({ providerId: proxies[i].providerId, ip: proxies[i].ip }); + } + console.log(colors.green(`Finished importing ${colors.yellow(proxies.length + "")} base proxies.`)); + } + + const animeProxies = Bun.file("./animeProxies.json"); + if (await animeProxies.exists()) { + // Check proxies.json + const proxies = await animeProxies.json(); + for (let i = 0; i < proxies.length; i++) { + animeIps.push({ providerId: proxies[i].providerId, ip: proxies[i].ip }); + } + console.log(colors.green(`Finished importing ${colors.yellow(proxies.length + "")} anime proxies.`)); + } + + const mangaProxies = Bun.file("./mangaProxies.json"); + if (await mangaProxies.exists()) { + // Check proxies.json + const proxies = await mangaProxies.json(); + for (let i = 0; i < proxies.length; i++) { + mangaIps.push({ providerId: proxies[i].providerId, ip: proxies[i].ip }); + } + console.log(colors.green(`Finished importing ${colors.yellow(proxies.length + "")} manga proxies.`)); + } + + const metaProxies = Bun.file("./metaProxies.json"); + if (await metaProxies.exists()) { + // Check proxies.json + const proxies = await metaProxies.json(); + for (let i = 0; i < proxies.length; i++) { + metaIps.push({ providerId: proxies[i].providerId, ip: proxies[i].ip }); + } + console.log(colors.green(`Finished importing ${colors.yellow(proxies.length + "")} meta proxies.`)); + } + + console.log("========================================="); + + /* + console.log(colors.yellow("Checking base proxies...")); + for (let i = startIndex; i < baseIps.length; i++) { + console.log(colors.green("Iteration ") + (i + 1) + colors.green(" of ") + baseIps.length + colors.green(".") + colors.gray(" (Timeout: 5 seconds)")); + + const promises = []; + + promises.push( + new Promise(async (resolve) => { + const ip: IP = { + ip: baseIps[i].ip.split(":")[0], + port: Number(baseIps[i].ip.split(":")[1]), + }; + const base = await makeProviderRequest(ip, BASE_PROVIDERS.find((provider) => provider.id === baseIps[i].providerId)!); + if (!base) { + badProxies.base.push(baseIps[i].ip); + baseIps.splice(i, 1); + } + + // Write to file + Bun.write("./baseProxies.json", JSON.stringify(baseIps, null, 4)); + + resolve(true); + }), + ); + + console.log(colors.gray("Waiting for promises to resolve...")); + await Promise.all(promises); + + console.log(colors.gray("Finished iteration ") + (i + 1) + colors.gray(" of ") + baseIps.length + colors.gray(".")); + } + + console.log(colors.yellow("Checking anime proxies...")); + for (let i = startIndex; i < animeIps.length; i++) { + console.log(colors.green("Iteration ") + (i + 1) + colors.green(" of ") + animeIps.length + colors.green(".") + colors.gray(" (Timeout: 5 seconds)")); + + const promises = []; + + promises.push( + new Promise(async (resolve) => { + const ip: IP = { + ip: animeIps[i].ip.split(":")[0], + port: Number(animeIps[i].ip.split(":")[1]), + }; + const base = await makeProviderRequest(ip, ANIME_PROVIDERS.find((provider) => provider.id === animeIps[i].providerId)!); + if (!base) { + badProxies.anime.push(animeIps[i].ip); + animeIps.splice(i, 1); + } + + // Write to file + Bun.write("./animeProxies.json", JSON.stringify(animeIps, null, 4)); + + resolve(true); + }), + ); + + console.log(colors.gray("Waiting for promises to resolve...")); + await Promise.all(promises); + + console.log(colors.gray("Finished iteration ") + (i + 1) + colors.gray(" of ") + animeIps.length + colors.gray(".")); + } + */ + + console.log(colors.yellow("Checking manga proxies...")); + for (let i = startIndex; i < mangaIps.length; i++) { + console.log(colors.green("Iteration ") + (i + 1) + colors.green(" of ") + mangaIps.length + colors.green(".") + colors.gray(" (Timeout: 5 seconds)")); + + const promises = []; + + promises.push( + new Promise(async (resolve) => { + const ip: IP = { + ip: mangaIps[i].ip.split(":")[0], + port: Number(mangaIps[i].ip.split(":")[1]), + }; + const base = await makeProviderRequest(ip, MANGA_PROVIDERS.find((provider) => provider.id === mangaIps[i].providerId)!); + if (!base) { + badProxies.manga.push(mangaIps[i].ip); + mangaIps.splice(i, 1); + } + + // Write to file + Bun.write("./mangaProxies.json", JSON.stringify(mangaIps, null, 4)); + + resolve(true); + }), + ); + + console.log(colors.gray("Waiting for promises to resolve...")); + await Promise.all(promises); + + console.log(colors.gray("Finished iteration ") + (i + 1) + colors.gray(" of ") + mangaIps.length + colors.gray(".")); + } + + BASE_PROXIES.length = 0; + ANIME_PROXIES.length = 0; + MANGA_PROXIES.length = 0; + META_PROXIES.length = 0; + toCheck.length = 0; + + BASE_PROXIES.push(...baseIps); + ANIME_PROXIES.push(...animeIps); + MANGA_PROXIES.push(...mangaIps); + META_PROXIES.push(...metaIps); + + console.log(colors.gray("Bad Proxies:")); + console.log(colors.red("Base: ") + badProxies.base.length); + console.log(colors.red("Anime: ") + badProxies.anime.length); + console.log(colors.red("Manga: ") + badProxies.manga.length); + console.log(colors.red("Meta: ") + badProxies.meta.length); + + console.log(colors.gray("Finished checking proxies.")); + return { + base: baseIps, + anime: animeIps, + manga: mangaIps, + meta: metaIps, + }; +} + export async function checkCorsProxies( importProxies: boolean = false, startIndex: number = 0, @@ -99,7 +287,7 @@ export async function checkCorsProxies( if (metaIps.find((obj) => obj.ip === `${url.hostname}:${url.port}`)) return { ip: "", port: 8080 }; return { ip: url.hostname, port: Number(url.port) }; - } catch (e) { + } catch { return { ip: "", port: 8080 }; } }) @@ -267,20 +455,7 @@ async function makeRequest(ip: IP, type: "BASE" | "ANIME" | "MANGA" | "META"): P const original = provider.useGoogleTranslate; provider.useGoogleTranslate = false; - let providerResponse; - if (provider.providerType === ProviderType.ANIME || provider.providerType === ProviderType.MANGA || provider.providerType === ProviderType.META) { - providerResponse = await (provider as AnimeProvider | MangaProvider | MetaProvider).search("Mushoku Tensei", Format.TV).catch(() => { - return undefined; - }); - } else if (provider.providerType === ProviderType.BASE) { - providerResponse = await (provider as BaseProvider) - .search("Mushoku Tensei", provider.formats.includes(Format.TV) ? Type.ANIME : provider.formats.includes(Format.MANGA) || provider.formats.includes(Format.NOVEL) ? Type.MANGA : Type.ANIME, provider.formats, 0, 10) - .catch(() => { - return undefined; - }); - } else { - providerResponse = undefined; - } + const providerResponse = await provider.proxyCheck(); provider.useGoogleTranslate = original; @@ -298,7 +473,66 @@ async function makeRequest(ip: IP, type: "BASE" | "ANIME" | "MANGA" | "META"): P } else { return resolve(undefined); } - } catch (error) { + } catch { + return resolve(undefined); + } + }); +} + +async function makeProviderRequest(ip: IP, provider: BaseProvider | MetaProvider | AnimeProvider | MangaProvider | InformationProvider): Promise { + console.log(colors.yellow(colors.bold(`Testing ${provider?.id}.`))); + return new Promise(async (resolve) => { + const controller = new AbortController(); + + console.log(colors.gray("Checking ") + `${ip.ip}:${ip.port}` + colors.gray(".")); + + setTimeout(() => { + controller.abort(); + }, 5000); + + controller.signal.addEventListener("abort", () => { + return resolve(undefined); + }); + + try { + const response = await fetch(`http://${ip.ip}:${ip.port}/iscorsneeded`, { + signal: controller.signal, + }).catch( + (err) => + ({ + ok: false, + status: 500, + statusText: "Timeout", + json: () => Promise.resolve({ error: err }), + }) as Response, + ); + + if (response.status === 200 && (await response.text()) === "no") { + const data = []; + + provider.customProxy = `http://${ip.ip}:${ip.port}`; + + const original = provider.useGoogleTranslate; + provider.useGoogleTranslate = false; + + const providerResponse = await provider?.proxyCheck(); + + provider.useGoogleTranslate = original; + + provider.customProxy = undefined; + + if (!providerResponse) { + return resolve(undefined); + } + + console.log(colors.green(`${provider.id} passed.`)); + data.push(provider.id); + + return resolve(data); + } else { + return resolve(undefined); + } + } catch { return resolve(undefined); } }); diff --git a/anify-backend/src/scripts/checkCurrentProxies.ts b/anify-backend/src/scripts/checkCurrentProxies.ts new file mode 100644 index 0000000..c0448eb --- /dev/null +++ b/anify-backend/src/scripts/checkCurrentProxies.ts @@ -0,0 +1,12 @@ +import { checkCurrentProxies } from "../proxies/impl/checkProxies"; + +const startIndex = Number(process.argv.slice(2)?.toString()?.toLowerCase() ?? "0"); + +console.log("Starting index: " + startIndex); +checkCurrentProxies(startIndex).then((data) => { + // Hang infinitely + console.log(data); + console.log("Successfully checked CORS proxies!"); + + setInterval(() => {}, 1000); +}); diff --git a/anify-backend/src/scripts/import.ts b/anify-backend/src/scripts/import.ts index e90b712..b29deae 100644 --- a/anify-backend/src/scripts/import.ts +++ b/anify-backend/src/scripts/import.ts @@ -45,7 +45,7 @@ export const importData = async () => { if (isString(media.averagePopularity)) { try { media.averagePopularity = JSON.parse(media.averagePopularity); - } catch (e) { + } catch { failedCount.anime++; } } @@ -75,7 +75,7 @@ export const importData = async () => { if (isString(media.averagePopularity)) { try { media.averagePopularity = JSON.parse(media.averagePopularity); - } catch (e) { + } catch { failedCount.manga++; } } diff --git a/anify-backend/src/scripts/novel-downloader.ts b/anify-backend/src/scripts/novel-downloader.ts index b1479c9..1d67c3d 100644 --- a/anify-backend/src/scripts/novel-downloader.ts +++ b/anify-backend/src/scripts/novel-downloader.ts @@ -36,9 +36,11 @@ before().then(async () => { if (!chapters || chapters.length === 0) { return console.log("No chapters found :( Bruh"); } + console.log(chapters); console.log(`Fetched ${chapters.length} chapters for ${id}. Creating PDF...`); + //await createNovelPDF(media as Manga, providerId, chapters); await createNovelPDF(media as Manga, providerId, chapters); console.log("Created novel PDF"); }); diff --git a/anify-backend/src/server/impl/sources.ts b/anify-backend/src/server/impl/sources.ts index 5ad8c87..9c1f01e 100644 --- a/anify-backend/src/server/impl/sources.ts +++ b/anify-backend/src/server/impl/sources.ts @@ -1,11 +1,8 @@ import { cacheTime, redis } from ".."; import content from "../../content"; -import { env } from "../../env"; import { isBanned } from "../../helper/banned"; import { StreamingServers, SubType } from "../../types/enums"; -import { Source } from "../../types/types"; import queues from "../../worker"; -import { AES } from "../lib/Aes"; import { createResponse } from "../lib/response"; export const handler = async (req: Request): Promise => { @@ -52,21 +49,6 @@ export const handler = async (req: Request): Promise => { const cached = await redis.get(`sources:${id}:${episodeNumber}:${providerId}:${watchId}:${subType}:${server}`); if (cached) { - const cachedData = JSON.parse(cached) as Source; - if (env.USE_SUBTITLE_SPOOFING) { - cachedData?.subtitles?.forEach((sub) => { - if (sub.lang != "Thumbnails" && sub.url.endsWith(".vtt") && !sub.url.startsWith(env.API_URL)) sub.url = env.API_URL + "/subtitles/" + AES.Encrypt(sub.url, env.SECRET_KEY) + ".vtt"; - }); - } - if (cachedData?.subtitles?.length == 0) { - if (!env.DISABLE_INTRO_VIDEO_SPOOFING) { - const headers = encodeURIComponent(JSON.stringify(cachedData?.headers ?? {})); - cachedData?.sources?.forEach((source) => { - if (source.url.endsWith(".m3u8") && !source.url.startsWith(env.VIDEO_PROXY_URL)) source.url = env.VIDEO_PROXY_URL + "/video/" + encrypt(source.url) + "/" + headers + "/.m3u8"; - }); - } - } - return createResponse(cached); } @@ -74,21 +56,6 @@ export const handler = async (req: Request): Promise => { if (banned) return createResponse(JSON.stringify({ error: "This item is banned." }), 403); const data = await content.fetchSources(providerId, watchId, subType as SubType, server as StreamingServers); - if (env.USE_SUBTITLE_SPOOFING) { - data?.subtitles?.forEach((sub) => { - if (sub.lang != "Thumbnails" && sub.url.endsWith(".vtt")) sub.url = env.API_URL + "/subtitles/" + AES.Encrypt(sub.url, env.SECRET_KEY) + ".vtt"; - }); - } - if (data?.subtitles?.length == 0) { - if (!env.DISABLE_INTRO_VIDEO_SPOOFING) { - const headers = encodeURIComponent(JSON.stringify(data?.headers ?? {})); - - data?.sources?.forEach((source) => { - if (source.url.endsWith(".m3u8")) source.url = env.VIDEO_PROXY_URL + "/video/" + encrypt(source.url) + "/" + headers + "/.m3u8"; - }); - } - } - if (!data) return createResponse(JSON.stringify({ error: "Sources not found." }), 404); if (data) queues.skipTimes.add({ id, episode: episodeNumber, toInsert: data }); @@ -118,6 +85,3 @@ type Body = { }; export default route; -function encrypt(data: string): string { - return encodeURIComponent(AES.Encrypt(data, env.SECRET_KEY)); -} diff --git a/anify-backend/src/server/impl/subtitles.ts b/anify-backend/src/server/impl/subtitles.ts deleted file mode 100644 index 6a8b12c..0000000 --- a/anify-backend/src/server/impl/subtitles.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { env } from "../../env"; -import { createResponse } from "../lib/response"; -import { parse } from "@plussub/srt-vtt-parser"; -import { Entry, ParsedResult } from "@plussub/srt-vtt-parser/dist/src/types"; -import NodeCache from "node-cache"; -import { AES } from "../lib/Aes"; - -const subtitleCache = new NodeCache({ stdTTL: env.SUBTITLES_CACHE_TIME }); -export const handler = async (req: Request): Promise => { - try { - const url = new URL(req.url); - const paths = url.pathname.split("/"); - paths.shift(); - - let encryptedUrl = paths[1] ?? null; - - if (!encryptedUrl) { - return createResponse(JSON.stringify({ error: "No url provided." }), 400); - } - if (!encryptedUrl.endsWith(".vtt")) { - return createResponse(JSON.stringify({ error: "Invalid url provided." }), 400); - } - - encryptedUrl = encryptedUrl.replace(".vtt", ""); - const decodedUrl = AES.Decrypt(encryptedUrl, env.SECRET_KEY); - - if (!decodedUrl) { - return createResponse(JSON.stringify({ error: "Invalid url provided." }), 400); - } - const cached = subtitleCache.get(decodedUrl); - if (cached) { - return new Response(cached, { - headers: { - "Content-Type": "text/vtt", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Max-Age": "2592000", - "Access-Control-Allow-Headers": "*", - }, - }); - } - - const reqeust = await fetch(decodedUrl); - if (!reqeust.ok || !decodedUrl.endsWith(".vtt")) { - return new Response(null, { - status: 302, - headers: { - Location: decodedUrl, - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Max-Age": "2592000", - "Access-Control-Allow-Headers": "*", - }, - }); - } - - let vttData = await reqeust.text(); - - vttData = env.USE_INLINE_SUBTITLE_SPOOFING ? parseVttInline(vttData) : parseVtt(vttData); - subtitleCache.set(decodedUrl, vttData); - return new Response(vttData, { - headers: { - "Content-Type": "text/vtt", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Max-Age": "2592000", - "Access-Control-Allow-Headers": "*", - }, - }); - } catch (e) { - console.error(e); - return createResponse(JSON.stringify({ error: "An error occurred." }), 500); - } -}; - -const route = { - method: "GET", - path: "/subtitles", - handler, - rateLimit: 60, -}; - -export default route; - -/* -function decodeUrl(url: string) { - try { - const decipher = crypto.createDecipher("aes-256-cbc", env.SECRET_KEY); - let decrypted = decipher.update(url, "hex", "utf-8"); - decrypted += decipher.final("utf-8"); - return decrypted; - } catch (e) { - return null; - } -} -*/ - -function buildWebVTT(parsedResult: ParsedResult) { - let webVTTContent = "WEBVTT\n\n"; - - for (const entry of parsedResult.entries) { - const startTime = formatTime(entry.from); - const endTime = formatTime(entry.to); - - webVTTContent += `${startTime} --> ${endTime}\n${entry.text}\n\n`; - } - - return webVTTContent; -} - -function formatTime(milliseconds: number) { - const date = new Date(0); - date.setUTCMilliseconds(milliseconds); - - const hours = date.getUTCHours().toString().padStart(2, "0"); - const minutes = date.getUTCMinutes().toString().padStart(2, "0"); - const seconds = date.getUTCSeconds().toString().padStart(2, "0"); - const millisecondsStr = date.getUTCMilliseconds().toString().padStart(3, "0"); - - return `${hours}:${minutes}:${seconds}.${millisecondsStr}`; -} -function parseVttInline(vttData: string): string { - const parsed = parse(vttData); - const textToInject = env.TEXT_TO_INJECT + "\n"; - const distanceToNextInjectedText = 1000 * env.DISTANCE_FROM_INJECTED_TEXT_SECONDS; - let nextModifyTimeMs = 0; - let hasSetFirstEntry = false; - parsed.entries.forEach((entry) => { - if (!hasSetFirstEntry) { - entry.text = textToInject + entry.text; - nextModifyTimeMs = entry.to + distanceToNextInjectedText; - hasSetFirstEntry = true; - } - - if (entry.to > nextModifyTimeMs) { - entry.text = textToInject + entry.text; - nextModifyTimeMs = entry.to + distanceToNextInjectedText; - } - }); - return buildWebVTT(parsed); -} - -function parseVtt(vttData: string): string { - const parsed = parse(vttData); - - const timeBetweenAds = 1000 * env.DISTANCE_FROM_INJECTED_TEXT_SECONDS; //eg 5 minutes - const displayDuration = 1000 * env.DURATION_FOR_INJECTED_TEXT_SECONDS; //eg 5 seconds - - const lastEntry = parsed.entries[parsed.entries.length - 1]; - const firstStartTime = 0; - const lastEndTime = lastEntry.to; - const totalDuration = lastEndTime - firstStartTime; - - const numberOfAds = Math.floor(totalDuration / timeBetweenAds); - //inject ads into entries - const adEntries: Entry[] = []; - for (let i = 0; i < numberOfAds; i++) { - const adEntry: Entry = { - id: "ad", - from: firstStartTime + i * timeBetweenAds, - to: firstStartTime + i * timeBetweenAds + displayDuration, - text: env.TEXT_TO_INJECT, - }; - adEntries.push(adEntry); - } - //combine entries - parsed.entries = parsed.entries.concat(adEntries); - //sort entries - parsed.entries.sort((a, b) => { - return a.from - b.from; - }); - //build vtt - return buildWebVTT(parsed); -} diff --git a/anify-backend/src/server/index.ts b/anify-backend/src/server/index.ts index 40a2ad6..f1d6e1f 100644 --- a/anify-backend/src/server/index.ts +++ b/anify-backend/src/server/index.ts @@ -38,7 +38,6 @@ export const start = async () => { } = {}; const routeFiles = [ await import("./impl/chapters.ts"), - await import("./impl/subtitles.ts"), await import("./impl/contentData.ts"), await import("./impl/episodes.ts"), await import("./impl/map.ts"), diff --git a/anify-backend/src/server/lib/Aes.ts b/anify-backend/src/server/lib/Aes.ts deleted file mode 100644 index a2fcb9f..0000000 --- a/anify-backend/src/server/lib/Aes.ts +++ /dev/null @@ -1,25 +0,0 @@ -import crypto from "crypto"; -const IV_SIZE = 16; -export class AES { - static Encrypt(plainText: string, keyString: string) { - const iv = crypto.randomBytes(IV_SIZE); - const cipher = crypto.createCipheriv("aes-256-cbc", keyString, iv); - let cipherText = cipher.update(Buffer.from(plainText, "utf8")); - cipherText = Buffer.concat([cipherText, cipher.final()]); - const combinedData = Buffer.concat([iv, cipherText]); - const combinedString = combinedData.toString("base64"); - return encodeURIComponent(combinedString); - } - - static Decrypt(combinedString: string, keyString: string) { - const combinedData = Buffer.from(decodeURIComponent(combinedString), "base64"); - const iv = Buffer.alloc(IV_SIZE); - const cipherText = Buffer.alloc(combinedData.length - iv.length); - combinedData.copy(iv, 0, 0, iv.length); - combinedData.copy(cipherText, 0, iv.length); - const decipher = crypto.createDecipheriv("aes-256-cbc", keyString, iv); - let plainText = decipher.update(cipherText); - plainText = Buffer.concat([plainText, decipher.final()]); - return plainText.toString("utf8"); - } -} diff --git a/anify-backend/src/server/lib/keys.ts b/anify-backend/src/server/lib/keys.ts index 1c473ba..85456e8 100644 --- a/anify-backend/src/server/lib/keys.ts +++ b/anify-backend/src/server/lib/keys.ts @@ -49,8 +49,8 @@ export const rateLimitApiKeyMiddleware = async (req: Request): Promise //await redis.del(`apikey:${key}`); return false; } - } catch (e) { - // + } catch { + return false; } } diff --git a/anify-backend/src/server/lib/rateLimit.ts b/anify-backend/src/server/lib/rateLimit.ts index 78f98a9..cd1c9f6 100644 --- a/anify-backend/src/server/lib/rateLimit.ts +++ b/anify-backend/src/server/lib/rateLimit.ts @@ -21,18 +21,10 @@ export const rateLimitMiddleware = async (req: Request, pathName: string): Promi if (requests.timestamp < new Date(Date.now()).getTime() - 60000) { await redis.del(`rate-limit:${ip}:${pathName}`); } - } catch (e) { - //console.error(e); + } catch { + return; } }, 60000); - // To clear all keys - /* - await redis.keys("rate-limit:*").then(async (keys) => { - console.log(keys); - await redis.del(keys); - }); - */ - return { ip, timestamp: now, requests: requests.requests++ }; }; diff --git a/anify-backend/src/types/enums.ts b/anify-backend/src/types/enums.ts index 3e6908a..622a0ab 100644 --- a/anify-backend/src/types/enums.ts +++ b/anify-backend/src/types/enums.ts @@ -137,6 +137,8 @@ export const enum StreamingServers { VidCloud = "vidcloud", StreamTape = "streamtape", VizCloud = "vidplay", + Vidstream = "vidstream", + MegaF = "megaF", MyCloud = "mycloud", Filemoon = "filemoon", VidStreaming = "vidstreaming", diff --git a/anify-frontend/src/components/cssTabs.tsx b/anify-frontend/src/components/cssTabs.tsx index 0d8cf3a..8bb7e35 100644 --- a/anify-frontend/src/components/cssTabs.tsx +++ b/anify-frontend/src/components/cssTabs.tsx @@ -68,7 +68,16 @@ function CSSTabs({ tabs, selectedTabIndex, setSelectedTab }: Props) { {tabs.map((item, i) => { return ( -