717 lines
20 KiB
TypeScript
717 lines
20 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
|
import { createHash } from 'crypto';
|
|
|
|
import { getConfig } from './config';
|
|
import {
|
|
MangaChapter,
|
|
MangaDetail,
|
|
MangaRecommendResult,
|
|
MangaSearchFailure,
|
|
MangaRecommendType,
|
|
MangaSearchItem,
|
|
MangaSearchResult,
|
|
MangaSource,
|
|
} from './manga.types';
|
|
|
|
interface GraphQLResponse<T> {
|
|
data?: T;
|
|
errors?: Array<{ message?: string }>;
|
|
}
|
|
|
|
interface SuwayomiClientOptions {
|
|
serverUrl?: string;
|
|
authMode?: 'none' | 'basic_auth' | 'simple_login';
|
|
username?: string;
|
|
password?: string;
|
|
}
|
|
|
|
interface ResolvedSuwayomiConfig {
|
|
serverBaseUrl: string;
|
|
serverUrl: string;
|
|
authMode: 'none' | 'basic_auth' | 'simple_login';
|
|
username?: string;
|
|
password?: string;
|
|
defaultLang: string;
|
|
sourceIds: string[];
|
|
maxSources: number;
|
|
}
|
|
|
|
interface SuwayomiSessionCacheEntry {
|
|
cookieHeader: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
const SUWAYOMI_SESSION_TTL_MS = 25 * 60 * 1000;
|
|
const DEFAULT_SUWAYOMI_TIMEOUT_MS = Number(process.env.SUWAYOMI_TIMEOUT_MS || 20000);
|
|
const suwayomiSessionCache = new Map<string, SuwayomiSessionCacheEntry>();
|
|
|
|
function normalizeSuwayomiAuthMode(value?: string | null): 'none' | 'basic_auth' | 'simple_login' {
|
|
if (value === 'basic_auth' || value === 'simple_login') {
|
|
return value;
|
|
}
|
|
return 'none';
|
|
}
|
|
|
|
function buildBasicAuthHeader(username: string, password: string): string {
|
|
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
}
|
|
|
|
function hashSimpleLoginPassword(password?: string): string {
|
|
return createHash('sha256').update(password || '').digest('hex');
|
|
}
|
|
|
|
function getSimpleLoginCacheKey(config: ResolvedSuwayomiConfig): string {
|
|
return `${config.serverBaseUrl}|${config.username || ''}|${hashSimpleLoginPassword(config.password)}`;
|
|
}
|
|
|
|
function getResponseSetCookieHeaders(response: Response): string[] {
|
|
const headers = response.headers as Headers & { getSetCookie?: () => string[] };
|
|
if (typeof headers.getSetCookie === 'function') {
|
|
return headers.getSetCookie();
|
|
}
|
|
|
|
const setCookie = response.headers.get('set-cookie');
|
|
return setCookie ? [setCookie] : [];
|
|
}
|
|
|
|
function extractCookieHeader(response: Response): string | null {
|
|
const cookies = getResponseSetCookieHeaders(response)
|
|
.map((item) => item.split(';', 1)[0]?.trim())
|
|
.filter(Boolean) as string[];
|
|
|
|
return cookies.length > 0 ? cookies.join('; ') : null;
|
|
}
|
|
|
|
export async function loginWithSimpleAuth(
|
|
config: ResolvedSuwayomiConfig,
|
|
forceRefresh = false
|
|
): Promise<string> {
|
|
if (!config.username || !config.password) {
|
|
throw new Error('Suwayomi simple_login 缺少用户名或密码');
|
|
}
|
|
|
|
const cacheKey = getSimpleLoginCacheKey(config);
|
|
const cached = suwayomiSessionCache.get(cacheKey);
|
|
if (!forceRefresh && cached && cached.expiresAt > Date.now()) {
|
|
return cached.cookieHeader;
|
|
}
|
|
|
|
const response = await fetch(
|
|
`${config.serverBaseUrl}/login.html?redirect=${encodeURIComponent('/api/graphql')}`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: new URLSearchParams({
|
|
user: config.username,
|
|
pass: config.password,
|
|
}).toString(),
|
|
redirect: 'manual',
|
|
cache: 'no-store',
|
|
}
|
|
);
|
|
|
|
const cookieHeader = extractCookieHeader(response);
|
|
if (!cookieHeader) {
|
|
throw new Error(`Suwayomi simple_login 登录失败: ${response.status}`);
|
|
}
|
|
|
|
suwayomiSessionCache.set(cacheKey, {
|
|
cookieHeader,
|
|
expiresAt: Date.now() + SUWAYOMI_SESSION_TTL_MS,
|
|
});
|
|
|
|
return cookieHeader;
|
|
}
|
|
|
|
async function resolveSuwayomiConfig(options: SuwayomiClientOptions = {}): Promise<ResolvedSuwayomiConfig> {
|
|
let serverUrl = process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '';
|
|
let authMode = normalizeSuwayomiAuthMode(process.env.SUWAYOMI_AUTH_MODE);
|
|
let username = process.env.SUWAYOMI_USERNAME || '';
|
|
let password = process.env.SUWAYOMI_PASSWORD || '';
|
|
let defaultLang = process.env.SUWAYOMI_DEFAULT_LANG || 'zh';
|
|
let sourceIds: string[] = [];
|
|
let maxSources = Number(process.env.SUWAYOMI_MAX_SOURCES || 10);
|
|
|
|
try {
|
|
const config = await getConfig();
|
|
if (config.SuwayomiConfig?.Enabled) {
|
|
serverUrl = config.SuwayomiConfig.ServerURL || serverUrl;
|
|
authMode = normalizeSuwayomiAuthMode(config.SuwayomiConfig.AuthMode || authMode);
|
|
username = config.SuwayomiConfig.Username || username;
|
|
password = config.SuwayomiConfig.Password || password;
|
|
defaultLang = config.SuwayomiConfig.DefaultLang || defaultLang;
|
|
sourceIds = config.SuwayomiConfig.SourceIds || sourceIds;
|
|
maxSources = config.SuwayomiConfig.MaxSources || maxSources;
|
|
}
|
|
} catch {
|
|
// 配置读取失败时回退到环境变量
|
|
}
|
|
|
|
if (options.serverUrl !== undefined) {
|
|
serverUrl = options.serverUrl;
|
|
}
|
|
if (options.authMode !== undefined) {
|
|
authMode = normalizeSuwayomiAuthMode(options.authMode);
|
|
}
|
|
if (options.username !== undefined) {
|
|
username = options.username;
|
|
}
|
|
if (options.password !== undefined) {
|
|
password = options.password;
|
|
}
|
|
|
|
if (!serverUrl) {
|
|
throw new Error('Suwayomi 未配置,请先在管理面板或环境变量中设置服务地址');
|
|
}
|
|
|
|
const normalizedBaseUrl = serverUrl.replace(/\/$/, '');
|
|
|
|
return {
|
|
serverBaseUrl: normalizedBaseUrl,
|
|
serverUrl: normalizedBaseUrl + '/api/graphql',
|
|
authMode,
|
|
username: username || undefined,
|
|
password: password || undefined,
|
|
defaultLang,
|
|
sourceIds,
|
|
maxSources,
|
|
};
|
|
}
|
|
|
|
export async function getSuwayomiConfig(options: SuwayomiClientOptions = {}): Promise<ResolvedSuwayomiConfig> {
|
|
return resolveSuwayomiConfig(options);
|
|
}
|
|
|
|
export function buildSuwayomiImageProxyUrl(pathOrUrl: string): string {
|
|
if (!pathOrUrl) return '';
|
|
if (pathOrUrl.startsWith('/api/manga/image?')) return pathOrUrl;
|
|
return `/api/manga/image?path=${encodeURIComponent(pathOrUrl)}`;
|
|
}
|
|
|
|
async function getSuwayomiRequestHeaders(
|
|
resolved: ResolvedSuwayomiConfig,
|
|
forceSimpleLoginRefresh = false
|
|
): Promise<HeadersInit | undefined> {
|
|
if (resolved.authMode === 'basic_auth') {
|
|
if (!resolved.username || !resolved.password) {
|
|
throw new Error('Suwayomi basic_auth 缺少用户名或密码');
|
|
}
|
|
|
|
return {
|
|
Authorization: buildBasicAuthHeader(resolved.username, resolved.password),
|
|
};
|
|
}
|
|
|
|
if (resolved.authMode === 'simple_login') {
|
|
return {
|
|
Cookie: await loginWithSimpleAuth(resolved, forceSimpleLoginRefresh),
|
|
};
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async function suwayomiFetch(
|
|
resolved: ResolvedSuwayomiConfig,
|
|
input: string,
|
|
init: RequestInit = {}
|
|
): Promise<Response> {
|
|
const timeoutMs = DEFAULT_SUWAYOMI_TIMEOUT_MS;
|
|
|
|
const execute = async (forceSimpleLoginRefresh: boolean) => {
|
|
const authHeaders = await getSuwayomiRequestHeaders(resolved, forceSimpleLoginRefresh);
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(new Error(`Suwayomi 请求超时(${timeoutMs}ms)`)), timeoutMs);
|
|
|
|
try {
|
|
return await fetch(input, {
|
|
...init,
|
|
headers: {
|
|
...(authHeaders || {}),
|
|
...(init.headers || {}),
|
|
},
|
|
cache: 'no-store',
|
|
signal: controller.signal,
|
|
});
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
|
|
let response = await execute(false);
|
|
if (response.status === 401 && resolved.authMode === 'simple_login') {
|
|
response = await execute(true);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
function normalizeMangaStatus(status?: string): string | undefined {
|
|
if (!status) return undefined;
|
|
|
|
const normalized = status.trim().toUpperCase();
|
|
switch (normalized) {
|
|
case 'ONGOING':
|
|
return '连载中';
|
|
case 'COMPLETED':
|
|
return '已完结';
|
|
case 'LICENSED':
|
|
return '已授权';
|
|
case 'PUBLISHING_FINISHED':
|
|
return '已完结';
|
|
case 'CANCELLED':
|
|
return '已取消';
|
|
case 'ON_HIATUS':
|
|
return '休刊中';
|
|
case 'UNKNOWN':
|
|
case 'UNRECOGNIZED':
|
|
return undefined;
|
|
default:
|
|
return status;
|
|
}
|
|
}
|
|
|
|
export class SuwayomiClient {
|
|
private options: SuwayomiClientOptions;
|
|
|
|
constructor(options: SuwayomiClientOptions = {}) {
|
|
this.options = options;
|
|
}
|
|
|
|
private async graphqlRequest<T>(query: string, variables?: Record<string, any>, operationName?: string): Promise<T> {
|
|
const resolved = await resolveSuwayomiConfig(this.options);
|
|
const response = await suwayomiFetch(resolved, resolved.serverUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ query, variables, operationName }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Suwayomi 请求失败: ${response.status}`);
|
|
}
|
|
|
|
const data = (await response.json()) as GraphQLResponse<T>;
|
|
if (data.errors?.length) {
|
|
throw new Error(data.errors.map((item) => item.message || 'Unknown error').join('; '));
|
|
}
|
|
if (!data.data) {
|
|
throw new Error('Suwayomi 返回空数据');
|
|
}
|
|
return data.data;
|
|
}
|
|
|
|
async getSources(lang?: string): Promise<MangaSource[]> {
|
|
const resolved = await resolveSuwayomiConfig(this.options);
|
|
const query = `
|
|
query GetSources {
|
|
sources {
|
|
nodes {
|
|
id
|
|
name
|
|
lang
|
|
displayName
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const data = await this.graphqlRequest<{
|
|
sources?: { nodes?: Array<{ id: string; name?: string; lang?: string; displayName?: string }> };
|
|
}>(query);
|
|
|
|
const nodes = data.sources?.nodes || [];
|
|
const filtered = nodes.filter((item) => !lang || item.lang === lang);
|
|
const scoped = resolved.sourceIds.length > 0
|
|
? filtered.filter((item) => resolved.sourceIds.includes(String(item.id)))
|
|
: filtered;
|
|
|
|
return scoped.map((item) => ({
|
|
id: String(item.id),
|
|
name: item.name || item.displayName || String(item.id),
|
|
lang: item.lang,
|
|
displayName: item.displayName,
|
|
}));
|
|
}
|
|
|
|
async getSearchSources(sourceId?: string): Promise<Array<{ id: string; displayName?: string; name?: string }>> {
|
|
const resolved = await resolveSuwayomiConfig(this.options);
|
|
|
|
if (sourceId) {
|
|
const matched = (await this.getSources()).find((item) => item.id === sourceId);
|
|
return [
|
|
{
|
|
id: sourceId,
|
|
displayName: matched?.displayName || matched?.name || sourceId,
|
|
name: matched?.name || matched?.displayName || sourceId,
|
|
},
|
|
];
|
|
}
|
|
|
|
try {
|
|
return (await this.getSources(resolved.defaultLang)).slice(0, resolved.maxSources);
|
|
} catch (error) {
|
|
if (resolved.sourceIds.length === 0) {
|
|
throw error;
|
|
}
|
|
return resolved.sourceIds.slice(0, resolved.maxSources).map((id) => ({
|
|
id,
|
|
displayName: id,
|
|
name: id,
|
|
}));
|
|
}
|
|
}
|
|
|
|
async searchMangaSource(
|
|
keyword: string,
|
|
source: { id: string; displayName?: string; name?: string },
|
|
page = 1
|
|
): Promise<{ source: { id: string; displayName?: string; name?: string }; results: MangaSearchItem[] }> {
|
|
const query = `
|
|
mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) {
|
|
fetchSourceManga(input: $input) {
|
|
mangas {
|
|
id
|
|
title
|
|
thumbnailUrl
|
|
sourceId
|
|
description
|
|
author
|
|
artist
|
|
genre
|
|
status
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const data = await this.graphqlRequest<{
|
|
fetchSourceManga?: {
|
|
mangas?: Array<{
|
|
id: string | number;
|
|
title?: string;
|
|
thumbnailUrl?: string;
|
|
sourceId?: string | number;
|
|
description?: string;
|
|
author?: string;
|
|
artist?: string;
|
|
genre?: string;
|
|
status?: string;
|
|
}>;
|
|
};
|
|
}>(
|
|
query,
|
|
{
|
|
input: {
|
|
type: 'SEARCH',
|
|
source: source.id,
|
|
query: keyword,
|
|
page,
|
|
},
|
|
},
|
|
'GET_SOURCE_MANGAS_FETCH'
|
|
);
|
|
|
|
const seen = new Set<string>();
|
|
const sourceName = source.displayName || source.name || String(source.id);
|
|
const results = (data.fetchSourceManga?.mangas || [])
|
|
.filter((manga) => {
|
|
const key = `${source.id}:${manga.id}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
})
|
|
.map((manga) => ({
|
|
id: String(manga.id),
|
|
sourceId: String(manga.sourceId || source.id),
|
|
sourceName,
|
|
title: manga.title || '未命名漫画',
|
|
cover: buildSuwayomiImageProxyUrl(manga.thumbnailUrl || ''),
|
|
description: manga.description,
|
|
author: manga.author,
|
|
artist: manga.artist,
|
|
genre: manga.genre,
|
|
status: normalizeMangaStatus(manga.status),
|
|
}));
|
|
|
|
return { source, results };
|
|
}
|
|
|
|
async searchManga(keyword: string, sourceId?: string, page = 1): Promise<MangaSearchResult> {
|
|
const sources = await this.getSearchSources(sourceId);
|
|
const results: MangaSearchItem[] = [];
|
|
const failedSources: MangaSearchFailure[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
const perSourceResults = await Promise.all(
|
|
sources.map(async (source) => {
|
|
try {
|
|
return await this.searchMangaSource(keyword, source, page);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : '未知错误';
|
|
console.warn(`[Suwayomi] manga search source failed: ${source.id} - ${message}`);
|
|
failedSources.push({
|
|
sourceId: String(source.id),
|
|
sourceName: source.displayName || source.name || String(source.id),
|
|
error: message,
|
|
});
|
|
return {
|
|
source,
|
|
results: [],
|
|
};
|
|
}
|
|
})
|
|
);
|
|
|
|
for (const { results: sourceResults } of perSourceResults) {
|
|
for (const manga of sourceResults) {
|
|
const key = `${manga.sourceId}:${manga.id}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
results.push(manga);
|
|
}
|
|
}
|
|
|
|
return {
|
|
results,
|
|
failedSources,
|
|
};
|
|
}
|
|
|
|
async getRecommendedManga(
|
|
sourceId: string,
|
|
type: MangaRecommendType = 'POPULAR',
|
|
page = 1
|
|
): Promise<MangaRecommendResult> {
|
|
if (!sourceId) {
|
|
return { mangas: [], hasNextPage: false };
|
|
}
|
|
|
|
const query = `
|
|
fragment MANGA_BASE_FIELDS on MangaType {
|
|
id
|
|
title
|
|
thumbnailUrl
|
|
sourceId
|
|
description
|
|
author
|
|
artist
|
|
genre
|
|
status
|
|
}
|
|
|
|
mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) {
|
|
fetchSourceManga(input: $input) {
|
|
hasNextPage
|
|
mangas {
|
|
...MANGA_BASE_FIELDS
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const sources = await this.getSources();
|
|
const matchedSource = sources.find((item) => item.id === sourceId);
|
|
|
|
const data = await this.graphqlRequest<{
|
|
fetchSourceManga?: {
|
|
hasNextPage?: boolean;
|
|
mangas?: Array<{
|
|
id: string | number;
|
|
title?: string;
|
|
thumbnailUrl?: string;
|
|
sourceId?: string | number;
|
|
description?: string;
|
|
author?: string;
|
|
artist?: string;
|
|
genre?: string;
|
|
status?: string;
|
|
}>;
|
|
};
|
|
}>(
|
|
query,
|
|
{
|
|
input: {
|
|
type,
|
|
source: sourceId,
|
|
page,
|
|
},
|
|
},
|
|
'GET_SOURCE_MANGAS_FETCH'
|
|
);
|
|
|
|
return {
|
|
hasNextPage: Boolean(data.fetchSourceManga?.hasNextPage),
|
|
mangas: (data.fetchSourceManga?.mangas || []).map((manga) => ({
|
|
id: String(manga.id),
|
|
sourceId: String(manga.sourceId || sourceId),
|
|
sourceName: matchedSource?.displayName || matchedSource?.name || sourceId,
|
|
title: manga.title || '未命名漫画',
|
|
cover: buildSuwayomiImageProxyUrl(manga.thumbnailUrl || ''),
|
|
description: manga.description,
|
|
author: manga.author,
|
|
artist: manga.artist,
|
|
genre: manga.genre,
|
|
status: normalizeMangaStatus(manga.status),
|
|
})),
|
|
};
|
|
}
|
|
|
|
async getChapters(mangaId: string): Promise<MangaChapter[]> {
|
|
const mutation = `
|
|
mutation GET_MANGA_CHAPTERS_FETCH($input: FetchChaptersInput!) {
|
|
fetchChapters(input: $input) {
|
|
chapters {
|
|
id
|
|
mangaId
|
|
name
|
|
chapterNumber
|
|
scanlator
|
|
isRead
|
|
isDownloaded
|
|
pageCount
|
|
uploadDate
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const data = await this.graphqlRequest<{
|
|
fetchChapters?: {
|
|
chapters?: Array<{
|
|
id: string | number;
|
|
mangaId?: string | number;
|
|
name?: string;
|
|
chapterNumber?: number;
|
|
scanlator?: string;
|
|
isRead?: boolean;
|
|
isDownloaded?: boolean;
|
|
pageCount?: number;
|
|
uploadDate?: number;
|
|
}>;
|
|
};
|
|
}>(mutation, { input: { mangaId: Number(mangaId) || mangaId } }, 'GET_MANGA_CHAPTERS_FETCH');
|
|
|
|
return (data.fetchChapters?.chapters || []).map((chapter) => ({
|
|
id: String(chapter.id),
|
|
mangaId: String(chapter.mangaId || mangaId),
|
|
name: chapter.name || '未命名章节',
|
|
chapterNumber: chapter.chapterNumber,
|
|
scanlator: chapter.scanlator,
|
|
isRead: chapter.isRead,
|
|
isDownloaded: chapter.isDownloaded,
|
|
pageCount: chapter.pageCount,
|
|
uploadDate: chapter.uploadDate,
|
|
}));
|
|
}
|
|
|
|
async getMangaDetail(input: {
|
|
mangaId: string;
|
|
sourceId: string;
|
|
title?: string;
|
|
cover?: string;
|
|
sourceName?: string;
|
|
description?: string;
|
|
author?: string;
|
|
status?: string;
|
|
}): Promise<MangaDetail> {
|
|
const chapters = await this.getChapters(input.mangaId);
|
|
|
|
let metadata: Partial<MangaSearchItem> = {
|
|
id: input.mangaId,
|
|
sourceId: input.sourceId,
|
|
sourceName: input.sourceName || input.sourceId,
|
|
title: input.title || '漫画详情',
|
|
cover: input.cover || '',
|
|
description: input.description,
|
|
author: input.author,
|
|
status: input.status,
|
|
};
|
|
|
|
const detailQuery = `
|
|
query MangaDetail($id: LongString!) {
|
|
manga(id: $id) {
|
|
id
|
|
title
|
|
thumbnailUrl
|
|
sourceId
|
|
description
|
|
author
|
|
artist
|
|
genre
|
|
status
|
|
}
|
|
}
|
|
`;
|
|
|
|
try {
|
|
const detailData = await this.graphqlRequest<{
|
|
manga?: {
|
|
id: string | number;
|
|
title?: string;
|
|
thumbnailUrl?: string;
|
|
sourceId?: string | number;
|
|
description?: string;
|
|
author?: string;
|
|
artist?: string;
|
|
genre?: string;
|
|
status?: string;
|
|
};
|
|
}>(detailQuery, { id: input.mangaId }, 'MangaDetail');
|
|
|
|
if (detailData.manga) {
|
|
metadata = {
|
|
id: String(detailData.manga.id),
|
|
sourceId: String(detailData.manga.sourceId || input.sourceId),
|
|
sourceName: input.sourceName || input.sourceId,
|
|
title: detailData.manga.title || metadata.title || '漫画详情',
|
|
cover: buildSuwayomiImageProxyUrl(detailData.manga.thumbnailUrl || metadata.cover || ''),
|
|
description: detailData.manga.description || metadata.description,
|
|
author: detailData.manga.author || metadata.author,
|
|
artist: detailData.manga.artist,
|
|
genre: detailData.manga.genre,
|
|
status: normalizeMangaStatus(detailData.manga.status) || normalizeMangaStatus(metadata.status),
|
|
};
|
|
}
|
|
} catch {
|
|
// 某些 Suwayomi 版本不支持直接 manga(id) 查询,降级为外部参数 + 章节信息
|
|
}
|
|
|
|
return {
|
|
id: metadata.id || input.mangaId,
|
|
sourceId: metadata.sourceId || input.sourceId,
|
|
sourceName: metadata.sourceName || input.sourceId,
|
|
title: metadata.title || '漫画详情',
|
|
cover: buildSuwayomiImageProxyUrl(metadata.cover || ''),
|
|
description: metadata.description,
|
|
author: metadata.author,
|
|
artist: metadata.artist,
|
|
genre: metadata.genre,
|
|
status: normalizeMangaStatus(metadata.status),
|
|
chapters,
|
|
};
|
|
}
|
|
|
|
async getChapterPages(chapterId: string): Promise<string[]> {
|
|
const mutation = `
|
|
mutation GET_CHAPTER_PAGES_FETCH($input: FetchChapterPagesInput!) {
|
|
fetchChapterPages(input: $input) {
|
|
pages
|
|
}
|
|
}
|
|
`;
|
|
|
|
const data = await this.graphqlRequest<{
|
|
fetchChapterPages?: { pages?: string[] };
|
|
}>(mutation, { input: { chapterId: Number(chapterId) || chapterId } }, 'GET_CHAPTER_PAGES_FETCH');
|
|
|
|
return (data.fetchChapterPages?.pages || []).map((item) => buildSuwayomiImageProxyUrl(item));
|
|
}
|
|
}
|
|
|
|
export const suwayomiClient = new SuwayomiClient();
|