Files
peoplelib/src/sources/mangadex.js
T

249 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MangaDex 数据源:公开漫画聚合站,官方文档化 REST API(无需登录即可只读浏览)。
// 与其它源的关键差异:条目本身不是可下载的单文件,而是一部漫画下的多个章节,
// 每个章节又是几十张图片。list/search/detail 沿用通用接口语义(对象是"一部漫画"),
// download() 按接口约定必须存在但没有意义,交给 chapters()/atHome() 支撑的专用
// 下载流程(组装漫画 EPUB,见 main.js 与 src/library/manga-epub.js)。
//
// 图片必须由服务端代理转发:MangaDex 明确禁止渲染层热链其图片域名
// https://api.mangadex.org/docs/2-limitations/),这与本仓库“渲染层不直接
// 访问外部资源”的既有边界天然吻合。
//
// MangaDex@Home 的图片分发要求调用方对每张图片上报成功/失败
// POST https://api.mangadex.network/report),否则健康检测无法剔除故障节点;
// 这一步在实际下载编排里完成(main.js),本模块只负责取地址。
const { fetchJson, clampPage } = require('./http');
const mangaDownload = require('../library/manga-download');
const BASE = 'https://api.mangadex.org';
const PAGE_SIZE = 20;
const CHAPTER_PAGE_SIZE = 100;
const MAX_OFFSET_TOTAL = 10000; // 接口硬限制:offset + size 不能超过 10000
// 标题/标签是 LocalizedString{ en: '...', ja: '...' } 形式),按偏好语言取值。
const PREFERRED_LANGS = ['zh', 'zh-hk', 'en', 'ja-ro', 'ja'];
function pickLocalized(obj) {
if (!obj || typeof obj !== 'object') return '';
for (const lang of PREFERRED_LANGS) {
if (obj[lang]) return obj[lang];
}
const first = Object.values(obj).find(Boolean);
return first || '';
}
function pickTitle(attrs) {
const direct = pickLocalized(attrs && attrs.title);
if (direct) return direct;
for (const alt of (attrs && attrs.altTitles) || []) {
const t = pickLocalized(alt);
if (t) return t;
}
return '(无标题)';
}
function buildQuery(params) {
const q = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value == null) continue;
if (Array.isArray(value)) value.forEach((item) => q.append(key, item));
else q.append(key, value);
}
return q.toString();
}
function findRelationship(relationships, type) {
return (relationships || []).find((r) => r.type === type);
}
function coverUrl(mangaId, relationships) {
const cover = findRelationship(relationships, 'cover_art');
const fileName = cover && cover.attributes && cover.attributes.fileName;
if (!fileName) return '';
return `https://uploads.mangadex.org/covers/${mangaId}/${fileName}.512.jpg`;
}
function authorNames(relationships) {
return [...new Set((relationships || [])
.filter((r) => r.type === 'author' || r.type === 'artist')
.map((r) => r.attributes && r.attributes.name)
.filter(Boolean))];
}
function mangaUrl(id) { return `https://mangadex.org/title/${id}`; }
function toItem(m) {
const attrs = m.attributes || {};
return {
postId: m.id,
title: pickTitle(attrs),
cover: coverUrl(m.id, m.relationships),
date: attrs.year ? String(attrs.year) : '',
url: mangaUrl(m.id),
subtitle: authorNames(m.relationships).slice(0, 3).join(', ')
};
}
const STATUS_LABEL = { ongoing: '连载中', completed: '已完结', hiatus: '暂停', cancelled: '已取消' };
const DEMOGRAPHIC_LABEL = { shounen: '少年', shoujo: '少女', josei: '女性向', seinen: '青年' };
function chapterLabel(attrs) {
const parts = [];
if (attrs.volume) parts.push(`第 ${attrs.volume} 卷`);
parts.push(attrs.chapter ? `第 ${attrs.chapter} 话` : '单话');
if (attrs.title) parts.push(attrs.title);
return parts.join(' ') || '未命名章节';
}
function toChapterItem(c) {
const attrs = c.attributes || {};
const group = findRelationship(c.relationships, 'scanlation_group');
return {
chapterId: c.id,
volume: attrs.volume || '',
chapter: attrs.chapter || '',
title: attrs.title || '',
label: chapterLabel(attrs),
translatedLanguage: attrs.translatedLanguage || '',
pages: attrs.pages || 0,
publishAt: attrs.publishAt || '',
group: (group && group.attributes && group.attributes.name) || '',
external: !!attrs.externalUrl,
unavailable: !!attrs.isUnavailable
};
}
function clampedMaxPage(total, pageSize) {
return Math.max(1, Math.ceil(Math.min(total, MAX_OFFSET_TOTAL) / pageSize));
}
async function fetchMangaList(query, page) {
page = clampPage(page);
const offset = (page - 1) * PAGE_SIZE;
const qs = buildQuery({
...query,
limit: PAGE_SIZE,
offset,
'includes[]': ['cover_art', 'author', 'artist']
});
const j = await fetchJson(`${BASE}/manga?${qs}`);
const total = j.total || 0;
return { items: (j.data || []).map(toItem), maxPage: clampedMaxPage(total, PAGE_SIZE), page };
}
module.exports = {
id: 'mangadex',
name: 'MangaDex 漫画',
category: 'manga',
supportsSearch: true,
// 与 zlib 的 downloadOnDemand 语义不同:这里不是"点击才解析",而是条目本身
// 就没有单一下载文件,必须先选具体章节。sources/index.js 与 browse.js 用这个
// 标记切换到章节列表面板,而不是通用的下载框。
chapterBased: true,
// 默认只看安全/暗示性内容,不含情色/色情分级:面向通用书库场景的保守默认值,
// 与年龄分级过滤无关的高级选项超出本次范围。
async list(page) {
return fetchMangaList({
'order[followedCount]': 'desc',
'contentRating[]': ['safe', 'suggestive']
}, page);
},
async search(keyword, page) {
const title = String(keyword || '').trim();
if (!title) return { items: [], maxPage: 1, page: clampPage(page) };
return fetchMangaList({
title,
'contentRating[]': ['safe', 'suggestive']
}, page);
},
async detail(postId) {
const qs = buildQuery({ 'includes[]': ['cover_art', 'author', 'artist'] });
const j = await fetchJson(`${BASE}/manga/${encodeURIComponent(postId)}?${qs}`);
const m = j.data;
if (!m) throw new Error('未找到该漫画');
const attrs = m.attributes || {};
const tags = [];
if (attrs.status) tags.push(`状态:${STATUS_LABEL[attrs.status] || attrs.status}`);
if (attrs.publicationDemographic) {
tags.push(`分级:${DEMOGRAPHIC_LABEL[attrs.publicationDemographic] || attrs.publicationDemographic}`);
}
for (const tag of attrs.tags || []) {
const name = pickLocalized(tag.attributes && tag.attributes.name);
if (name) tags.push(`标签:${name}`);
}
return {
postId: m.id,
title: pickTitle(attrs),
cover: coverUrl(m.id, m.relationships),
authors: authorNames(m.relationships),
date: attrs.year ? String(attrs.year) : '',
tags,
brief: pickLocalized(attrs.description),
url: mangaUrl(m.id),
links: [{ name: 'MangaDex 页', url: mangaUrl(m.id) }],
// 供下载编排复用,避免为拼 EPUB 元数据再多打一次详情请求
originalLanguage: attrs.originalLanguage || 'ja'
};
},
// 按接口约定必须存在,但漫画没有"整部下载"的单一文件;
// 真正的下载走 chapters() 选出具体章节后调用 atHome()。
async download() {
throw new Error('MangaDex 请先在章节列表中选择要下载的具体章节');
},
// 不按语言过滤:不同语言的翻译组各自独立,筛选逻辑交给调用方按 translatedLanguage 分组展示,
// 避免对用户能读什么语言做隐性假设。
async chapters(mangaId, page, options) {
page = clampPage(page);
const offset = (page - 1) * CHAPTER_PAGE_SIZE;
const language = options && ['zh', 'zh-hk', 'all'].includes(options.language)
? options.language
: 'zh';
const qs = buildQuery({
limit: CHAPTER_PAGE_SIZE,
offset,
'order[volume]': 'asc',
'order[chapter]': 'asc',
'includes[]': ['scanlation_group'],
'translatedLanguage[]': language === 'all' ? null : [language]
});
const j = await fetchJson(`${BASE}/manga/${encodeURIComponent(mangaId)}/feed?${qs}`);
const total = j.total || 0;
return {
items: (j.data || []).map(toChapterItem),
maxPage: clampedMaxPage(total, CHAPTER_PAGE_SIZE),
page
};
},
// baseUrl 只保证 15 分钟有效,调用方不能缓存它去拼后续的图片地址。
async atHome(chapterId, forcePort443 = false) {
const qs = forcePort443 ? '?forcePort443=true' : '';
const j = await fetchJson(`${BASE}/at-home/server/${encodeURIComponent(chapterId)}${qs}`);
if (!j || !j.baseUrl || !j.chapter || !j.chapter.hash) throw new Error('获取章节图片地址失败');
return j;
},
// quality: 'data'(原画质)| 'dataSaver'(压缩,默认)
async chapterImageUrls(chapterId, quality) {
const q = quality === 'data' ? 'data' : 'dataSaver';
const pathSegment = q === 'data' ? 'data' : 'data-saver';
const info = await this.atHome(chapterId);
const files = q === 'data' ? info.chapter.data : info.chapter.dataSaver;
if (!Array.isArray(files) || !files.length) throw new Error('该章节没有可下载的图片');
const urls = files.map((name) => `${info.baseUrl}/${pathSegment}/${info.chapter.hash}/${name}`);
// 官方主域名(mangadex.org)不需要健康上报;只有转发到 @Home 志愿节点时才需要,
// 用 baseUrl 是否落在主域名判断,不能靠是否有端口号等启发式。
const mustReport = !/(^|\.)mangadex\.org(:|\/|$)/i.test(new URL(info.baseUrl).hostname);
return { urls, mustReport, quality: q };
},
async downloadChapter(library, payload, onProgress) {
return mangaDownload.downloadChapter(this, library, payload, onProgress);
}
};