249 lines
9.6 KiB
JavaScript
249 lines
9.6 KiB
JavaScript
// 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);
|
||
}
|
||
};
|