// 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); } };