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:
chris zheng 2025-06-06 22:29:13 +08:00 committed by GitHub
parent 76c572430c
commit 62ae012cc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 929 additions and 67 deletions

View File

@ -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,
},

View File

@ -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;

View File

@ -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
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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('&');
}

View File

@ -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,
})),
};
}

View File

@ -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,
};
}),
};
}

View File

@ -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.',
};

View File

@ -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,
};
}),
};
}

View File

@ -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);