feat(route/mangadex): add three new routes and modify one old route for mangadex.org (#18269)
* 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 <TonyRL@users.noreply.github.com> * 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 ---------
This commit is contained in:
parent
76c572430c
commit
62ae012cc7
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<string>} 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;
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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<Map<string, { id: string; title: string; description: string; cover?: string }>> {
|
||||
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<any>;
|
||||
|
||||
const languages = [...(typeof lang === 'string' ? [lang] : lang || []), ...(await getFilteredLanguages())].filter(Boolean);
|
||||
|
||||
const map = new Map<string, { id: string; title: string; description: string; cover?: string }>();
|
||||
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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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<string, string> | 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, any>): 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('&');
|
||||
}
|
||||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
|
||||
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<string, any>[];
|
||||
|
||||
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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, any>[];
|
||||
|
||||
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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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<FollowType, StatusType> = {
|
||||
reading: 'reading',
|
||||
'plan-to-read': 'plan_to_read',
|
||||
completed: 'completed',
|
||||
'on-hold': 'on_hold',
|
||||
're-reading': 're_reading',
|
||||
dropped: 'dropped',
|
||||
};
|
||||
|
||||
const labelMap: Record<FollowType, LabelType> = {
|
||||
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<string, string>;
|
||||
|
||||
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<string, string>, value: string): string[] =>
|
||||
Object.entries(record)
|
||||
.filter(([, v]) => v === value)
|
||||
.map(([k]) => k);
|
||||
Loading…
Reference in New Issue