From 62ae012cc703d4e5cf22146db997e9011f74cdf5 Mon Sep 17 00:00:00 2001 From: chris zheng Date: Fri, 6 Jun 2025 22:29:13 +0800 Subject: [PATCH] feat(route/mangadex): add three new routes and modify one old route for mangadex.org (#18269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(namespace): add namespace for CJLU * feat(route): Add route for graduate school of CJLU 为中国计量大学研究生院添加路由 支持将`教师通知`和`研究生通知`页面转化为 RSS 订阅源 * doc: modify discription of some params 修改参数描述 * fix(radar rule): modify radar rule 修改 radar 规则,以正确提供订阅源 * fix: add exception handling 增加异常处理 * chore: optimize conditional logic 优化判断逻辑 * Update lib/routes/cjlu/yjsy/index.ts Co-authored-by: Tony * fix(radar rule): list all source choices 在 radar 规则中列出全部的类型 修改行尾符为 LF * fix(example): correct example of the route 订正路由的案例 * chore: add missing namespace information * fix(mangadex): resolve incorrect route detection * chore: update route name and include myself as a maintainer * feat(mangadex): add token retrieval utility base on config * feat(mangadex): add utility to retrieve user configurations Added a utility function `getFilteredLanguages` to retrieve filtered languages based on the current user's configuration. * feat: add `getMangaMeta` utility to retrive metadata for a manga * refactor: add utility to retrive chapter info for a manga Original implementation by @vzz64 was embedded in modules. Extracted the common logic into a standalone function for better code reusability and maintainability. * refactor(feed util): move the fallback logic into function Add a docstring to explain its usage * doc(access util): add docstrings for key function * refactor: purify result of function Change result type from object to array by remove `total` param. * feat(feed util): add support for alternative parameter type - Add docstring for util functions - Add a new combination function - Improve code type inference * refactor: move constants to separate files for better organization * feat: modify the route for single manga - **important**: add a prefix to the route for space for other routes in namespace, not compatiable to former route - use integrated utility to retrieve details of a single manga * refactor: move `token expire time` to constant * docs(access): add Error docstring * chore: make cover became thumbnail to save bandwidth and better display * feat: add new route for user follow's list - support reading, plan to read etc. - add new optional param to utility function * fix: improve perfomance in retrieving manga's meta data - if cover is needed, use the optional param `includes` to fetch in one request * refactor: rearrange code - skip some code for branch without cover needed * docs: add description for route * docs: add a detailed param description for route - offer a default value for param * feat: add a new constant * feat: seperate util-function for better organization - add docstring for `toQueryString` * feat: add new utility function to fetch manga meta data in batch * fix: use dynamic key to cache result - otherwise it may get wrong result with diffrent types - rearrange the code * feat: add a new route for all user's followed mangas * chore: add parameters' desctiption - offer a more detailed example - use local constant to maintance limit - change some code's style * style: change style in using utility function * style: refactor query string construction for improved readability * style: change a variable's name * feat: add new route for sepecific mdlist feed * fix: use correct time for pubDate * fix: use `image` prop to display thumbnail instead of `description` * refactor(mangadex): improve access token retrieval logic and handle refresh token errors * refactor(mangadex): replace getMangaMeta with getMangaMetaByIds for improved efficiency * chore: modify route's decription * fix: remove unnecessary nullish coalescing in description assignment * fix: update access token retrieval to use client ID and secret instead of username and password * fix: correct spellings in definition * chore: remove unnecessary code * fix: dedupe ids before fetch manga metas - add `limit` param to get the full response * fix: remove wrong suffix * fix: remove unnecessary code * chore: remove unnessary code * fix: set manga and config feed's cache to not refreshable * fix: remove filteredLanguages option * fix: remove filteredLanguages option * fix: add sort operation after dedupe * style: add trailing comma * fix: remove common params * fix: remove unnecessary code * fix: remove route's unnecessary name prefix * fix: change TTL for hot caches * fix: remove docs for non-exist config * refactor: fix lint issue for better readability * fix: ensure cover image is only set if cover filename exists --------- --- lib/config.ts | 14 +++ lib/routes/mangadex/_access.ts | 111 +++++++++++++++++ lib/routes/mangadex/_constants.ts | 75 ++++++++++++ lib/routes/mangadex/_feed.ts | 184 ++++++++++++++++++++++++++++ lib/routes/mangadex/_profile.ts | 47 +++++++ lib/routes/mangadex/_utils.ts | 49 ++++++++ lib/routes/mangadex/index.ts | 83 +++---------- lib/routes/mangadex/mdlist/feed.ts | 156 +++++++++++++++++++++++ lib/routes/mangadex/namespace.ts | 2 + lib/routes/mangadex/user/feed.ts | 126 +++++++++++++++++++ lib/routes/mangadex/user/follows.ts | 149 ++++++++++++++++++++++ 11 files changed, 929 insertions(+), 67 deletions(-) create mode 100644 lib/routes/mangadex/_access.ts create mode 100644 lib/routes/mangadex/_constants.ts create mode 100644 lib/routes/mangadex/_feed.ts create mode 100644 lib/routes/mangadex/_profile.ts create mode 100644 lib/routes/mangadex/_utils.ts create mode 100644 lib/routes/mangadex/mdlist/feed.ts create mode 100644 lib/routes/mangadex/user/feed.ts create mode 100644 lib/routes/mangadex/user/follows.ts diff --git a/lib/config.ts b/lib/config.ts index 294257f8d..e6f0f2d5c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -226,6 +226,13 @@ export type Config = { password?: string; refreshToken?: string; }; + mangadex: { + username?: string; + password?: string; + clientId?: string; + clientSecret?: string; + refreshToken?: string; + }; manhuagui: { cookie?: string; }; @@ -670,6 +677,13 @@ const calculateValue = () => { password: envs.MALAYSIAKINI_PASSWORD, refreshToken: envs.MALAYSIAKINI_REFRESHTOKEN, }, + mangadex: { + username: envs.MANGADEX_USERNAME, // required when refresh-token is not set + password: envs.MANGADEX_PASSWORD, // required when refresh-token is not set + clientId: envs.MANGADEX_CLIENT_ID, + clientSecret: envs.MANGADEX_CLIENT_SECRET, + refreshToken: envs.MANGADEX_REFRESH_TOKEN, + }, manhuagui: { cookie: envs.MHGUI_COOKIE, }, diff --git a/lib/routes/mangadex/_access.ts b/lib/routes/mangadex/_access.ts new file mode 100644 index 000000000..f57f8b21e --- /dev/null +++ b/lib/routes/mangadex/_access.ts @@ -0,0 +1,111 @@ +import got from '@/utils/got'; +import cache from '@/utils/cache'; +import { config } from '@/config'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; + +import constants from './_constants'; +import { FetchError } from 'ofetch'; + +/** + * Retrieves an access token. + * + * @important Ensure the request includes a User-Agent header. + * @throws {ConfigNotFoundError} If the required configuration is missing. + * The following credentials are mandatory: + * - `client ID` and `client secret` + * - One of the following: + * - `username` and `password` + * - `refresh token` + * @throws {FetchError} If the request fails. + * - 400 Bad Request: If the `refresh token` or other credentials are invalid. + * @returns {Promise} A promise that resolves to the access token. + */ +const getToken = () => { + if (!config.mangadex.clientId || !config.mangadex.clientSecret) { + throw new ConfigNotFoundError('Cannot get access token since MangaDex client ID or secret is not set.'); + } + + return cache.tryGet( + 'mangadex:access-token', + async () => { + if (!config.mangadex.refreshToken) { + return getAccessTokenByUserCredentials(); + } + + try { + return await getAccessTokenByRefreshToken(); + } catch (error) { + if (error instanceof FetchError && error.statusCode === 400) { + // If the refresh token is invalid, try to get a new one with the user credentials + return getAccessTokenByUserCredentials(); + } + throw error; + } + }, + constants.TOKEN_EXPIRE, + false + ); +}; + +const getAccessTokenByUserCredentials = async () => { + if (!config.mangadex.clientId || !config.mangadex.clientSecret) { + throw new ConfigNotFoundError('Cannot get access token since MangaDex client ID or secret is not set.'); + } + + if (!config.mangadex.username || !config.mangadex.password) { + throw new ConfigNotFoundError('Cannot get refresh token since MangaDex username or password is not set'); + } + + const response = await got.post(constants.API.TOKEN, { + headers: { + 'User-Agent': config.trueUA, + }, + form: { + grant_type: 'password', + username: config.mangadex.username, + password: config.mangadex.password, + client_id: config.mangadex.clientId, + client_secret: config.mangadex.clientSecret, + }, + }); + + const refreshToken = response?.data?.refresh_token; + const accessToken = response?.data?.access_token; + + if (!refreshToken || !accessToken) { + throw new Error('Failed to retrieve refresh token from MangaDex API.'); + } + + config.mangadex.refreshToken = refreshToken; // cache the refresh token + return accessToken; +}; + +const getAccessTokenByRefreshToken = async () => { + if (!config.mangadex.clientId || !config.mangadex.clientSecret) { + throw new ConfigNotFoundError('Cannot get access token since MangaDex client ID or secret is not set.'); + } + + if (!config.mangadex.refreshToken) { + throw new ConfigNotFoundError('Cannot get access token since MangaDex refresh token is not set.'); + } + + const response = await got.post(constants.API.TOKEN, { + headers: { + 'User-Agent': config.trueUA, + }, + form: { + grant_type: 'refresh_token', + refresh_token: config.mangadex.refreshToken, + client_id: config.mangadex.clientId, + client_secret: config.mangadex.clientSecret, + }, + }); + + const accessToken = response?.data?.access_token; + if (!accessToken) { + throw new Error('Failed to retrieve access token from MangaDex API.'); + } + return accessToken; +}; + +export default getToken; diff --git a/lib/routes/mangadex/_constants.ts b/lib/routes/mangadex/_constants.ts new file mode 100644 index 000000000..9c48b33f5 --- /dev/null +++ b/lib/routes/mangadex/_constants.ts @@ -0,0 +1,75 @@ +/** + * Define constants for the MangaDex. + */ +export default { + API: { + /** + * Base URL for the MangaDex API. + * + */ + BASE: 'https://api.mangadex.org', + + /** + * Base URL for the fetching the manga details. + * + * @usage https://api.mangadex.org/manga/:id + * @see https://api.mangadex.org/docs/redoc.html#tag/Manga/operation/get-manga-id + * @usage https://api.mangadex.org/manga/:id/feed + * @see https://api.mangadex.org/docs/redoc.html#tag/Manga/operation/get-manga-id-feed + */ + MANGA_META: 'https://api.mangadex.org/manga/', + + /** + * Base URL for a specific chapter in MangaDex Reading page. + * + * @usage https://api.mangadex.org/chapter/:chapterId + * @see https://api.mangadex.org/docs/redoc.html#tag/Chapter/operation/get-chapter-id + */ + MANGA_CHAPTERS: 'https://mangadex.org/chapter/', + + /** + * Base URL for fetching the manga cover details. + * + * @usage https://api.mangadex.org/cover/:coverId + * @usage https://api.mangadex.org/cover/?manga[]=:mangaId + * @see https://api.mangadex.org/docs/swagger.html#/Cover/get-cover + */ + COVERS: 'https://api.mangadex.org/cover/', + + /** + * Base URL to retrieve the cover image. + * + * @usage https://uploads.mangadex.org/covers/:manga-id/:cover-filename + * @usage https://uploads.mangadex.org/covers/:manga-id/:cover-filename.{256, 512}.jpg + * @see https://api.mangadex.org/docs/03-manga/covers/ + */ + COVER_IMAGE: 'https://uploads.mangadex.org/covers/', + + /** + * Get all Manga reading status for logged User + * + * @usage https://api.mangadex.org/manga/status + * @note Requires authentication + * @see https://api.mangadex.org/docs/redoc.html#tag/Manga/operation/get-manga-status + */ + READING_STATUSES: 'https://api.mangadex.org/manga/status', + + /** + * Retrieve a token for accessing the MangaDex API. + * + * @note Need configuration + * @see https://api.mangadex.org/docs/02-authentication/personal-clients/ + */ + TOKEN: 'https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token', + + /** + * Retrieve the user settings from MangaDex API. + * + * @note Requires authentication + * @see https://api.mangadex.org/docs/redoc.html#tag/Settings/operation/get-settings + */ + SETTING: 'https://api.mangadex.org/settings', + }, + + TOKEN_EXPIRE: 15 * 60 - 10, // access token expires in 15 minutes, refresh 10 seconds earlier +}; diff --git a/lib/routes/mangadex/_feed.ts b/lib/routes/mangadex/_feed.ts new file mode 100644 index 000000000..b0b09705b --- /dev/null +++ b/lib/routes/mangadex/_feed.ts @@ -0,0 +1,184 @@ +import got from '@/utils/got'; +import { config } from '@/config'; +import cache from '@/utils/cache'; +import md5 from '@/utils/md5'; +import { getFilteredLanguages } from './_profile'; +import { toQueryString, firstMatch } from './_utils'; +import constants from './_constants'; + +/** + * Retrieves the title, description, and cover of a manga. + * + * @author chrisis58, vzz64 + * @param id manga id + * @param lang language(s), absent for default + * @param needCover whether to fetch cover + * @returns title, description, and cover of the manga + */ +const getMangaMeta = async (id: string, needCover: boolean = true, lang?: string | string[]) => { + const includes = needCover ? ['cover_art'] : []; + + const rawMangaMeta = (await cache.tryGet(`mangadex:manga-meta:${id}`, async () => { + const { data } = await got.get( + `${constants.API.MANGA_META}${id}${toQueryString({ + includes, + })}` + ); + + if (data.result === 'error') { + throw new Error(data.errors[0].detail); + } + return data.data; + })) as any; + + const relationships = (rawMangaMeta.relationships || []) as Array<{ type: string; id: string; attributes: any }>; + + const languages = [ + ...(typeof lang === 'string' ? [lang] : lang || []), + ...(await getFilteredLanguages()), + rawMangaMeta.attributes.originalLanguage, // fallback to original language + ].filter(Boolean); + + // combine title and altTitles + const titles = { + ...rawMangaMeta.attributes.title, + ...Object.fromEntries(rawMangaMeta.attributes.altTitles.flatMap((element) => Object.entries(element))), + }; + + const title = firstMatch(titles, languages) as string; + + const description = firstMatch(rawMangaMeta.attributes.description, languages) as string; + + if (!needCover) { + return { title, description }; + } + + const coverFilename = relationships.find((relationship) => relationship.type === 'cover_art')?.attributes.fileName + '.512.jpg'; + const cover = `${constants.API.COVER_IMAGE}${id}/${coverFilename}`; + + return { title, description, cover }; +}; + +/** + * Retrieves the title, description, and cover of multiple manga. + * TODO: Retrieve page by page to avoid meeting the length limit of URL. + * + * @param ids manga ids + * @param needCover whether to fetch cover + * @param lang language(s), absent for default + * @returns a map of manga id to title, description, and cover + * @usage const mangaMetaMap = await getMangaMetaByIds(['f98660a1-d2e2-461c-960d-7bd13df8b76d']); + */ +export async function getMangaMetaByIds(ids: string[], needCover: boolean = true, lang?: string | string[]): Promise> { + const deDuplidatedIds = [...new Set(ids)].sort(); + const includes = needCover ? ['cover_art'] : []; + + const rawMangaMetas = (await cache.tryGet( + `mangadex:manga-meta:${md5(deDuplidatedIds.join(''))}`, // shorten the key + async () => { + const { data } = await got.get( + constants.API.MANGA_META.slice(0, -1) + + toQueryString({ + ids: deDuplidatedIds, + includes, + limit: deDuplidatedIds.length, + }) + ); + + if (data.result === 'error') { + throw new Error('Failed to retrieve manga meta from MangaDex API.'); + } + return data.data; + } + )) as Array; + + const languages = [...(typeof lang === 'string' ? [lang] : lang || []), ...(await getFilteredLanguages())].filter(Boolean); + + const map = new Map(); + for (const rawMangaMeta of rawMangaMetas) { + const id = rawMangaMeta.id; + + const titles = { + ...rawMangaMeta.attributes.title, + ...Object.fromEntries(rawMangaMeta.attributes.altTitles.flatMap((element) => Object.entries(element))), + }; + + const title = firstMatch(titles, [...languages, rawMangaMeta.attributes.originalLanguage]) as string; + + const description = firstMatch(rawMangaMeta.attributes.description, languages) as string; + + let cover: string | undefined; + let manga = { id, title, description, cover }; + + if (needCover) { + const coverFilename = rawMangaMeta.relationships.find((relationship) => relationship.type === 'cover_art')?.attributes.fileName; + if (coverFilename) { + cover = `${constants.API.COVER_IMAGE}${rawMangaMeta.id}/${coverFilename}.512.jpg`; + manga = { ...manga, cover }; + } + } + + map.set(id, manga); + } + return map; +} + +/** + * Retrieves the chapters of a manga. + * + * @author chrisis58, vzz64 + * @param id manga id + * @param lang language(s), absent for default + * @returns chapters of the manga + */ +const getMangaChapters = async (id: string, lang?: string | string[], limit?: number) => { + const languages = new Set([...(typeof lang === 'string' ? [lang] : lang || []), ...(await getFilteredLanguages())].filter(Boolean)); + + const url = `${constants.API.MANGA_META}${id}/feed${toQueryString({ + order: { + publishAt: 'desc', + }, + limit: limit || 100, + translatedLanguage: languages, + })}`; + + const chapters = (await cache.tryGet( + `mangadex:manga-chapters:${id}`, + async () => { + const { data } = await got.get(url); + + if (data.result === 'error') { + throw new Error(data.errors[0].detail); + } + + return data.data; + }, + config.cache.routeExpire, + false + )) as any; + + if (!chapters) { + return []; + } + + return chapters.map((chapter) => ({ + title: [chapter.attributes.volume ? `Vol. ${chapter.attributes.volume}` : null, chapter.attributes.chapter ? `Ch. ${chapter.attributes.chapter}` : null, chapter.attributes.title].filter(Boolean).join(' '), + link: `${constants.API.MANGA_CHAPTERS}${chapter.id}`, + pubDate: new Date(chapter.attributes.publishAt), + })) as Array<{ title: string; link: string; pubDate: Date }>; +}; + +/** + * Retrieves the title, description, cover, and chapters of a manga. + * Cominbation of getMangaMeta and getMangaChapters. + * + * @param id manga id + * @param lang language, absent for default + * @returns title, description, cover, and chapters of the manga + */ +const getMangaDetails = async (id: string, needCover: boolean = true, lang?: string | string[]) => { + const [meta, chapters] = await Promise.all([getMangaMeta(id, needCover, lang), getMangaChapters(id, lang)]); + return { ...meta, chapters }; +}; + +export { getMangaMeta, getMangaChapters, getMangaDetails }; diff --git a/lib/routes/mangadex/_profile.ts b/lib/routes/mangadex/_profile.ts new file mode 100644 index 000000000..b18434702 --- /dev/null +++ b/lib/routes/mangadex/_profile.ts @@ -0,0 +1,47 @@ +import got from '@/utils/got'; +import { config } from '@/config'; +import cache from '@/utils/cache'; +import ConfigNotFoundError from '@/errors/types/config-not-found'; +import getToken from './_access'; + +import constants from './_constants'; + +const getSetting = async () => { + const accessToken = await getToken(); + + return cache.tryGet( + 'mangadex:settings', + async () => { + const response = await got.get(constants.API.SETTING, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': config.trueUA, + }, + }); + + const setting = response?.data?.settings; + if (!setting) { + throw new Error('Failed to retrieve user settings from MangaDex API.'); + } + + return setting; + }, + config.cache.contentExpire, + false, + ); +}; + +const getFilteredLanguages = async (ingoreConfigNotFountError: boolean = true) => { + try { + const settings = (await getSetting()) as any; + return settings.userPreferences.filteredLanguages as string[]; + } catch (error) { + if (ingoreConfigNotFountError && error instanceof ConfigNotFoundError) { + return []; + } + throw error; + } +}; + +export default getSetting; +export { getFilteredLanguages }; diff --git a/lib/routes/mangadex/_utils.ts b/lib/routes/mangadex/_utils.ts new file mode 100644 index 000000000..f131fbb9c --- /dev/null +++ b/lib/routes/mangadex/_utils.ts @@ -0,0 +1,49 @@ +/** + * Get the first value that matches the keys in the source object + * + * @param source the source object + * @param keys the keys to search + * @returns the first match value, or the first value as fallback + */ +export const firstMatch = (source: Map | object, keys: string[]) => { + for (const key of keys) { + const value = source instanceof Map ? source.get(key) : source[key]; + if (value) { + return value; + } + } + return Object.values(source)[0]; +}; + +/** + * Convert parameters to query string + * + * @param params parameters to be converted to query string + * @returns the query string + * @usage toQueryString({ a: 1, b: '2', c: [3, 4], d: {5: 'five', 6: 'six'} }) + * >> '?a=1&b=2&c[]=3&c[]=4&d[5]=five&d[6]=six' + */ +export function toQueryString(params: Record): string { + const queryParts: string[] = []; + + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Set)) { + for (const [subKey, subValue] of Object.entries(value)) { + if (typeof subValue === 'string' || typeof subValue === 'number' || typeof subValue === 'boolean') { + queryParts.push(`${encodeURIComponent(key)}[${encodeURIComponent(subKey)}]=${encodeURIComponent(subValue)}`); + } + } + } else if (Array.isArray(value) || value instanceof Set) { + for (const item of value) { + queryParts.push(`${encodeURIComponent(key)}[]=${encodeURIComponent(item)}`); + } + } else { + queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + } + } + + if (queryParts.length === 0) { + return ''; + } + return '?' + queryParts.join('&'); +} diff --git a/lib/routes/mangadex/index.ts b/lib/routes/mangadex/index.ts index 3f7bd25ad..6c1871973 100644 --- a/lib/routes/mangadex/index.ts +++ b/lib/routes/mangadex/index.ts @@ -1,84 +1,33 @@ import { Route } from '@/types'; -import got from '@/utils/got'; -import { parseDate } from '@/utils/parse-date'; +import { getMangaDetails } from './_feed'; export const route: Route = { - path: '/:id/:lang?', + path: '/manga/:id/:lang?', radar: [ { - source: ['mangadex.org/title/:id/*', 'mangadex.org/title/:id'], - target: '/:id', + source: ['mangadex.org/title/:id/:suffix', 'mangadex.org/title/:id'], + target: '/manga/:id', }, ], - name: 'Unknown', - maintainers: ['vzz64'], + name: 'Single Manga Feed', + maintainers: ['vzz64', 'chrisis58'], + example: '/mangadex/manga/f98660a1-d2e2-461c-960d-7bd13df8b76d/en', handler, }; async function handler(ctx) { const { id, lang } = ctx.req.param(); - let { data } = await got.get(`https://api.mangadex.org/manga/${id}`); - if (data.result === 'error') { - throw new Error(data.errors[0].detail); - } - data = data.data; - let title; - if (lang) { - title = data.attributes.title[lang]; - if (!title) { - title = data.attributes.altTitles.find((altTitle) => altTitle[lang])?.[lang]; - } - } - if (!title) { - title = data.attributes.title[data.attributes.originalLanguage]; - if (!title) { - title = data.attributes.altTitles.find((altTitle) => altTitle[data.attributes.originalLanguage])?.[data.attributes.originalLanguage]; - if (!title) { - title = Object.values(data.attributes.title)[0]; - } - } - } - let description; - if (lang) { - description = data.attributes.description[lang]; - } - if (!description) { - description = data.attributes.description[data.attributes.originalLanguage]; - if (!description) { - description = Object.values(data.attributes.description)[0]; - } - } + const mangaDetail = await getMangaDetails(id, lang); - let url = `https://api.mangadex.org/manga/${id}/feed?order[publishAt]=desc`; - if (lang) { - url += `&translatedLanguage[]=${lang}`; - } - data = (await got.get(url)).data; - if (data.result === 'error') { - throw new Error(data.errors[0].detail); - } return { - title: `${title} - MangaDex`, + title: mangaDetail.title, link: `https://mangadex.org/title/${id}`, - description, - allowEmpty: true, - item: data.data.map((chapter) => { - const title = []; - if (chapter.attributes.volume) { - title.push(`Vol. ${chapter.attributes.volume}`); - } - if (chapter.attributes.chapter) { - title.push(`Ch. ${chapter.attributes.chapter}`); - } - if (chapter.attributes.title) { - title.push(chapter.attributes.title); - } - return { - title: title.join(' '), - link: `https://mangadex.org/chapter/${chapter.id}`, - pubDate: parseDate(chapter.attributes.publishAt), - }; - }), - language: lang, + description: mangaDetail.description, + item: mangaDetail.chapters.map((chapter) => ({ + title: chapter.title, + link: chapter.link, + pubDate: chapter.pubDate, + image: mangaDetail.cover, + })), }; } diff --git a/lib/routes/mangadex/mdlist/feed.ts b/lib/routes/mangadex/mdlist/feed.ts new file mode 100644 index 000000000..a6aa7e877 --- /dev/null +++ b/lib/routes/mangadex/mdlist/feed.ts @@ -0,0 +1,156 @@ +import { Route } from '@/types'; +import got from '@/utils/got'; +import getToken from '../_access'; +import cache from '@/utils/cache'; +import { config } from '@/config'; +import constants from '../_constants'; +import { getFilteredLanguages } from '../_profile'; +import { getMangaMetaByIds } from '../_feed'; +import { toQueryString } from '../_utils'; + +const DEFAULT_LIMIT = 25; + +export const route: Route = { + name: 'MDList Feed', + path: '/mdlist/:id/:lang?', + radar: [ + { + source: ['mangadex.org/list/:id/:suffix'], + target: '/mdlist/:id', + }, + ], + description: 'Sepcific MangaDex MDList Feed', + example: '/mangadex/mdlist/10cca803-8dc9-4f0e-86a8-6659a3ce5188?limit=10&private=true', + maintainers: ['chrisis58'], + categories: ['anime'], + parameters: { + id: { + description: 'The list id of the manga list', + }, + private: { + description: '(Query Param) Needed to access private lists, any value will be treated as true', + }, + }, + features: { + requireConfig: [ + { + name: 'MANGADEX_USERNAME', + description: 'MangaDex Username, required when refresh-token is not set and the list is private', + optional: true, + }, + { + name: 'MANGADEX_PASSWORD', + description: 'MangaDex Password, required when refresh-token is not set and the list is private', + optional: true, + }, + { + name: 'MANGADEX_CLIENT_ID', + description: 'MangaDex Client ID, required when the list is private', + optional: true, + }, + { + name: 'MANGADEX_CLIENT_SECRET', + description: 'MangaDex Client Secret, required when the list is private', + optional: true, + }, + { + name: 'MANGADEX_REFRESH_TOKEN', + description: 'MangaDex Refresh Token, required when username and password are not set and the list is private', + optional: true, + }, + ], + }, + handler, +}; + +async function handler(ctx) { + const { id, lang } = ctx.req.param(); + + const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : DEFAULT_LIMIT; + const isPrivate = !!ctx.req.query('private'); + + const accessToken = isPrivate ? await getToken() : undefined; + + const languagesQuery = new Set([...(typeof lang === 'string' ? [lang] : lang || []), ...(await getFilteredLanguages())].filter(Boolean)); + + const { listName, listAuthor } = (await cache.tryGet( + `mangadex:mdlist-info-${id}`, + async () => { + const response = await got.get( + `${constants.API.BASE}/list/${id}${toQueryString({ + includes: ['user'], + })}`, + { + headers: { + Authorization: String(isPrivate ? `Bearer ${accessToken}` : ''), + 'User-Agent': config.trueUA, + }, + } + ); + + const mdlistInfo = response?.data?.data; + if (!mdlistInfo) { + throw new Error('Failed to retrieve user follows from MangaDex API.'); + } + + const listName = mdlistInfo.attributes.name; + const listAuthor = mdlistInfo.relationships.find((relationship) => relationship.type === 'user')?.attributes.username; + + return { listName, listAuthor }; + }, + config.cache.contentExpire + )) as Record; + + const feed = (await cache.tryGet( + `mangadex:mdlist-feed-${id}`, + async () => { + const response = await got.get( + `${constants.API.BASE}/list/${id}/feed${toQueryString({ + limit, + translatedLanguage: languagesQuery, + order: { + publishAt: 'desc', + }, + })}`, + { + headers: { + Authorization: String(isPrivate ? `Bearer ${accessToken}` : ''), + 'User-Agent': config.trueUA, + }, + } + ); + + const feed = response?.data?.data; + if (!feed) { + throw new Error('Failed to retrieve user follows from MangaDex API.'); + } + + return feed; + }, + config.cache.routeExpire, + false + )) as Record[]; + + const mangaIds = feed.map((chapter) => chapter?.relationships.find((relationship) => relationship.type === 'manga')?.id); + + const mangaMetas = await getMangaMetaByIds(mangaIds); + + return { + title: `MDList - ${listName} by ${listAuthor}`, + link: `https://mangadex.org/list/${id}?tab=feed`, + description: 'The latest updates of all the manga in a sepcific list', + item: feed.map((chapter) => { + const mangaId = chapter.relationships.find((relationship) => relationship.type === 'manga')?.id; + const mangaMeta = mangaMetas.get(mangaId); + const chapterTitile = [chapter.attributes.volume ? `Vol. ${chapter.attributes.volume}` : null, chapter.attributes.chapter ? `Ch. ${chapter.attributes.chapter}` : null, chapter.attributes.title].filter(Boolean).join(' '); + + return { + title: mangaMeta?.title || 'Unknown', + link: `${constants.API.MANGA_CHAPTERS}${chapter.id}`, + pubDate: new Date(chapter.attributes.publishAt), + description: chapterTitile, + image: mangaMeta?.cover, + }; + }), + }; +} diff --git a/lib/routes/mangadex/namespace.ts b/lib/routes/mangadex/namespace.ts index 319b70cc8..fafb88c98 100644 --- a/lib/routes/mangadex/namespace.ts +++ b/lib/routes/mangadex/namespace.ts @@ -4,4 +4,6 @@ export const namespace: Namespace = { name: 'MangaDex', url: 'mangadex.org', lang: 'en', + categories: ['anime'], + description: 'MangaDex is an non-profit and ad-free manga reader offering high-quality images.', }; diff --git a/lib/routes/mangadex/user/feed.ts b/lib/routes/mangadex/user/feed.ts new file mode 100644 index 000000000..c1bd3ed07 --- /dev/null +++ b/lib/routes/mangadex/user/feed.ts @@ -0,0 +1,126 @@ +import { Route } from '@/types'; +import got from '@/utils/got'; +import getToken from '../_access'; +import cache from '@/utils/cache'; +import { config } from '@/config'; +import constants from '../_constants'; +import { getMangaMetaByIds } from '../_feed'; +import { getFilteredLanguages } from '../_profile'; +import { toQueryString } from '../_utils'; + +const DEFAULT_LIMIT = 25; + +export const route: Route = { + path: '/user/feed/follow/:lang?', + name: ' Follows Feed', + maintainers: ['chrisis58'], + description: 'Get the latest updates of all the manga you follow on MangaDex.', + example: '/mangadex/user/feed/follow/zh?limit=10', + radar: [ + { + source: ['mangadex.org/titles/feed'], + target: '/user/feed/follow', + }, + ], + categories: ['anime'], + parameters: { + lang: { + description: 'The language of the followed manga', + }, + }, + features: { + requireConfig: [ + { + name: 'MANGADEX_USERNAME', + description: 'MangaDex Username, required when refresh-token is not set', + optional: true, + }, + { + name: 'MANGADEX_PASSWORD', + description: 'MangaDex Password, required when refresh-token is not set', + optional: true, + }, + { + name: 'MANGADEX_CLIENT_ID', + description: 'MangaDex Client ID', + optional: false, + }, + { + name: 'MANGADEX_CLIENT_SECRET', + description: 'MangaDex Client Secret', + optional: false, + }, + { + name: 'MANGADEX_REFRESH_TOKEN', + description: 'MangaDex Refresh Token, required when username and password are not set', + optional: true, + }, + ], + }, + handler, +}; + +async function handler(ctx) { + const userFollowUrl = `${constants.API.BASE}/user/follows/manga/feed`; + + const { lang } = ctx.req.param(); + + const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit')) : DEFAULT_LIMIT; + + const accessToken = await getToken(); + + const languagesQuery = new Set([...(typeof lang === 'string' ? [lang] : lang || []), ...(await getFilteredLanguages())].filter(Boolean)); + + const feed = (await cache.tryGet( + 'mangadex:user-follows', + async () => { + const response = await got.get( + `${userFollowUrl}${toQueryString({ + translatedLanguage: languagesQuery, + order: { + publishAt: 'desc', + }, + limit, + })}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': config.trueUA, + }, + } + ); + + const followedChapterFeed = response?.data?.data; + if (!followedChapterFeed) { + throw new Error('Failed to retrieve user follows from MangaDex API.'); + } + + return followedChapterFeed; + }, + config.cache.routeExpire, + false + )) as Record[]; + + const mangaIds = feed.map((chapter) => chapter?.relationships.find((relationship) => relationship.type === 'manga')?.id); + + const mangaMetas = await getMangaMetaByIds(mangaIds); + + return { + title: 'User Follows', + link: 'https://mangadex.org/titles/feed', + description: 'The latest updates of all the manga you follow on MangaDex.', + item: feed.map((chapter) => { + const mangaId = chapter.relationships.find((relationship) => relationship.type === 'manga')?.id; + const mangaMeta = mangaMetas.get(mangaId); + const chapterTitile = [chapter.attributes.volume ? `Vol. ${chapter.attributes.volume}` : null, chapter.attributes.chapter ? `Ch. ${chapter.attributes.chapter}` : null, chapter.attributes.title].filter(Boolean).join(' '); + + return { + title: mangaMeta?.title || 'Unknown', + link: `${constants.API.MANGA_CHAPTERS}${chapter.id}`, + pubDate: new Date(chapter.attributes.publishAt), + description: chapterTitile, + image: mangaMeta?.cover, + }; + }), + }; +} diff --git a/lib/routes/mangadex/user/follows.ts b/lib/routes/mangadex/user/follows.ts new file mode 100644 index 000000000..d877fbe40 --- /dev/null +++ b/lib/routes/mangadex/user/follows.ts @@ -0,0 +1,149 @@ +import { Route } from '@/types'; +import got from '@/utils/got'; +import getToken from '../_access'; +import cache from '@/utils/cache'; +import { config } from '@/config'; +import { getMangaChapters, getMangaMetaByIds } from '../_feed'; + +type FollowType = 'reading' | 'plan-to-read' | 'completed' | 'on-hold' | 're-reading' | 'dropped'; +type StatusType = 'reading' | 'plan_to_read' | 'completed' | 'on_hold' | 're_reading' | 'dropped'; +type LabelType = 'Reading' | 'Plan to Read' | 'Completed' | 'On Hold' | 'Re-reading' | 'Dropped'; + +const statusMap: Record = { + reading: 'reading', + 'plan-to-read': 'plan_to_read', + completed: 'completed', + 'on-hold': 'on_hold', + 're-reading': 're_reading', + dropped: 'dropped', +}; + +const labelMap: Record = { + reading: 'Reading', + 'plan-to-read': 'Plan to Read', + completed: 'Completed', + 'on-hold': 'On Hold', + 're-reading': 'Re-reading', + dropped: 'Dropped', +}; + +export const route: Route = { + path: '/user/follow/:type?', + name: "Logged User's Followed Mangas Feed", + maintainers: ['chrisis58'], + example: '/mangadex/user/follow/reading', + description: `Fetches the feed of mangas that you follow on MangaDex whick are in the specified status. +CAUTION: With big amount of follows, it may take a long time to load or even fail. +It's recommended to use the \`/mangadex/mdlist/:listId?\` route instead for better performance, though it requires manual configuration.`, + categories: ['anime'], + parameters: { + type: { + description: 'The type of follows to fetch', + default: 'reading', + options: [ + { value: 'reading', label: 'Reading' }, + { value: 'plan-to-read', label: 'Plan to Read' }, + { value: 'completed', label: 'Completed' }, + { value: 'on-hold', label: 'On Hold' }, + { value: 're-reading', label: 'Re-reading' }, + { value: 'dropped', label: 'Dropped' }, + ], + }, + }, + radar: [ + { + source: ['mangadex.org/titles/follows'], + target: '/user/follow/reading', + }, + ], + features: { + requireConfig: [ + { + name: 'MANGADEX_USERNAME', + description: 'MangaDex Username, required when refresh-token is not set', + optional: true, + }, + { + name: 'MANGADEX_PASSWORD', + description: 'MangaDex Password, required when refresh-token is not set', + optional: true, + }, + { + name: 'MANGADEX_CLIENT_ID', + description: 'MangaDex Client ID', + optional: false, + }, + { + name: 'MANGADEX_CLIENT_SECRET', + description: 'MangaDex Client Secret', + optional: false, + }, + { + name: 'MANGADEX_REFRESH_TOKEN', + description: 'MangaDex Refresh Token, required when username and password are not set', + optional: true, + }, + ], + }, + handler, +}; + +async function handler(ctx) { + const userFollowUrl = 'https://api.mangadex.org/manga/status'; + + const { type } = ctx.req.param(); + + const followType = (type || 'reading') as FollowType; + + const accessToken = await getToken(); + + const statuses = (await cache.tryGet( + `mangadex:user-follow-${followType}`, + async () => { + const response = await got.get(userFollowUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': config.trueUA, + }, + }); + + const statuses = response?.data?.statuses; + if (!statuses) { + throw new Error('Failed to retrieve user follows from MangaDex API.'); + } + + return statuses; + }, + config.cache.routeExpire, + false + )) as Record; + + const mangaIds = filterByValue(statuses, statusMap[followType]); + + const mangaMetaMap = await getMangaMetaByIds(mangaIds); + + const mangaChapters = await Promise.all(mangaIds.map((id) => getMangaChapters(id, undefined, 10))); + + const mangas = mangaChapters.flatMap((chapters, index) => { + const mangaMeta = mangaMetaMap.get(mangaIds[index]); + return chapters.map((chapter) => ({ + title: mangaMeta?.title ?? 'Unknown', + link: chapter.link, + pubDate: chapter.pubDate, + description: chapter.title ?? '', + image: mangaMeta?.cover ?? '', + })); + }); + + return { + title: `User Follows - ${labelMap[followType]} Mangas`, + link: `https://mangadex.org/titles/follows?tab=${followType}`, + description: 'Followed Mangas', + item: mangas, + }; +} + +const filterByValue = (record: Record, value: string): string[] => + Object.entries(record) + .filter(([, v]) => v === value) + .map(([k]) => k);