feat: 集成漫画源并支持在线阅读
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user