feat: 集成漫画源并支持在线阅读

This commit is contained in:
lofyer
2026-08-08 19:32:47 +08:00
parent c2292da442
commit 87dcc307e6
30 changed files with 3681 additions and 58 deletions
+228
View File
@@ -0,0 +1,228 @@
const { fetchJson, clampPage } = require('./http');
const { tryMirrors } = require('./mirror');
const mangaDownload = require('../library/manga-download');
const MIRRORS = [
'https://api.2024manga.com',
'https://api.mangacopy.com',
'https://api.copy-manga.com'
];
const PAGE_SIZE = 21;
const PLATFORM = '3';
const CACHE_TTL = 5 * 60 * 1000;
const detailCache = new Map();
const chapterCache = new Map();
const API_HEADERS = {
source: 'com.manga2020.app',
version: '2024.4.28',
Referer: 'https://www.mangacopy.com/'
};
function query(params) {
const value = new URLSearchParams();
for (const [key, item] of Object.entries(params)) {
if (item != null) value.set(key, item);
}
return value.toString();
}
async function api(path) {
return tryMirrors('copymanga', MIRRORS, (base) => (
fetchJson(`${base}${path}`, { headers: API_HEADERS, retries: 0, timeout: 20000 })
));
}
function detailUrl(slug) {
return `https://www.mangacopy.com/comic/${encodeURIComponent(slug)}`;
}
function names(value) {
return (Array.isArray(value) ? value : []).map((item) => item && item.name).filter(Boolean);
}
function toItem(item) {
return {
postId: item.path_word,
title: item.name || '(无标题)',
cover: item.cover || '',
date: item.datetime_updated || '',
url: detailUrl(item.path_word),
subtitle: names(item.author).join(', ')
};
}
function chapterNumber(chapter) {
const match = String(chapter.name || '').match(/(\d+(?:\.\d+)?)/);
if (match) return match[1];
return chapter.ordered ? String(Number(chapter.ordered) / 10) : '';
}
async function cached(cache, key, maxEntries, load) {
const existing = cache.get(key);
if (existing && existing.expiresAt > Date.now()) return existing.promise;
const promise = Promise.resolve().then(load);
cache.set(key, { promise, expiresAt: Date.now() + CACHE_TTL });
while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
try {
return await promise;
} catch (error) {
cache.delete(key);
throw error;
}
}
function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
for (;;) {
const index = cursor++;
if (index >= items.length) return;
results[index] = await fn(items[index], index);
}
}
return Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
.then(() => results);
}
function book(slug) {
return cached(detailCache, slug, 50, async () => {
const json = await api(`/api/v3/comic2/${encodeURIComponent(slug)}?platform=${PLATFORM}`);
const results = json && json.results || {};
if (!results.comic || !results.comic.path_word) throw new Error('拷贝漫画未返回漫画详情');
return results;
});
}
function allChapters(slug) {
return cached(chapterCache, slug, 30, async () => {
const info = await book(slug);
const version = info.comic.reclass == null ? '2' : '';
const groups = Object.values(info.groups || {});
const requests = groups.flatMap((group) => {
const count = Math.max(1, Number(group.count) || 1);
const pages = Math.ceil(count / 100);
return Array.from({ length: pages }, (_, index) => ({ group, index }));
});
const chunks = await mapLimit(requests, 4, async ({ group, index }) => {
const qs = query({ limit: 100, offset: index * 100, platform: PLATFORM });
const json = await api(
`/api/v3/comic/${encodeURIComponent(slug)}/group/${encodeURIComponent(group.path_word)}/chapters?${qs}`
);
const list = json && json.results && json.results.list || [];
return list.map((chapter) => ({
chapterId: `${slug}|${version}|${chapter.uuid}`,
volume: '',
chapter: chapterNumber(chapter),
title: chapter.name || '',
label: chapter.name || '未命名章节',
translatedLanguage: 'zh',
pages: chapter.size || 0,
publishAt: chapter.datetime_created || '',
group: group.name || '',
external: false,
unavailable: false
}));
});
return chunks.flat();
});
}
module.exports = {
id: 'copymanga',
name: '拷贝漫画(实验)',
category: 'manga',
experimental: true,
supportsSearch: true,
chapterBased: true,
async list(page) {
page = clampPage(page);
const qs = query({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
ordering: '-datetime_updated',
platform: PLATFORM
});
const json = await api(`/api/v3/comics?${qs}`);
const data = json && json.results || {};
return {
items: (data.list || []).map(toItem).filter((item) => item.postId),
maxPage: Math.max(1, Math.ceil((Number(data.total) || 0) / PAGE_SIZE)),
page
};
},
async search(keyword, page) {
page = clampPage(page);
const value = String(keyword || '').trim();
if (!value) return { items: [], maxPage: 1, page };
const qs = query({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
q: value,
q_type: '',
platform: PLATFORM
});
const json = await api(`/api/v3/search/comic?${qs}`);
const data = json && json.results || {};
return {
items: (data.list || []).map(toItem).filter((item) => item.postId),
maxPage: Math.max(1, Math.ceil((Number(data.total) || 0) / PAGE_SIZE)),
page
};
},
async detail(postId) {
const data = await book(postId);
const comic = data.comic;
const tags = names(comic.theme).map((name) => `标签:${name}`);
if (comic.region && comic.region.display) tags.unshift(`地区:${comic.region.display}`);
if (comic.status && comic.status.display) tags.unshift(`状态:${comic.status.display}`);
const url = detailUrl(postId);
return {
postId,
title: comic.name || '(无标题)',
cover: comic.cover || '',
authors: names(comic.author),
date: comic.datetime_updated || '',
tags,
brief: comic.brief || '',
url,
links: [{ name: '拷贝漫画页面', url }],
originalLanguage: 'zh'
};
},
async download() {
throw new Error('拷贝漫画请先在章节列表中选择要下载的具体章节');
},
async chapters(postId, page) {
page = clampPage(page);
const items = await allChapters(postId);
const maxPage = Math.max(1, Math.ceil(items.length / 100));
return { items: items.slice((page - 1) * 100, page * 100), maxPage, page };
},
async chapterImageUrls(chapterId) {
const [slug, version, uuid] = String(chapterId || '').split('|');
if (!slug || !uuid || !['', '2'].includes(version)) throw new Error('拷贝漫画章节 ID 无效');
const json = await api(
`/api/v3/comic/${encodeURIComponent(slug)}/chapter${version}/${encodeURIComponent(uuid)}?platform=${PLATFORM}`
);
const contents = json && json.results && json.results.chapter && json.results.chapter.contents;
const urls = (contents || []).map((item) => item && item.url).filter(Boolean);
if (!urls.length) throw new Error('拷贝漫画未返回章节图片');
return {
urls,
mustReport: false,
quality: 'data',
headers: { Referer: 'https://www.mangacopy.com/' }
};
},
async downloadChapter(library, payload, onProgress) {
return mangaDownload.downloadChapter(this, library, payload, onProgress);
}
};
+36 -1
View File
@@ -38,6 +38,37 @@ function fetchWithElectron(url, options = {}) {
return fetchWithProxy(url, options);
}
async function responseBytes(res, maxBytes) {
const declared = Number(res.headers && res.headers.get && res.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
try { if (res.body && res.body.cancel) await res.body.cancel(); } catch (e) { /* ignore */ }
return null;
}
if (!res.body || typeof res.body.getReader !== 'function') {
const bytes = Buffer.from(await res.arrayBuffer());
return bytes.length <= maxBytes ? bytes : null;
}
const reader = res.body.getReader();
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
size += chunk.length;
if (size > maxBytes) {
await reader.cancel();
return null;
}
chunks.push(chunk);
}
} finally {
try { reader.releaseLock(); } catch (e) { /* ignore */ }
}
return Buffer.concat(chunks, size);
}
// 简易 cookie jar: Map<domain, Map<name, value>>
const cookieJar = new Map();
@@ -218,4 +249,8 @@ async function withRetry(fn, { tries = 2, delay = 1000 } = {}) {
}
}
module.exports = { UA, fetchRaw, fetchText, fetchJson, decodeEntities, stripTags, clampPage, tooShort, isRetryable, withRetry, getCookies, setCookies, clearCookies, setProxy, getProxy, fetchWithProxy };
module.exports = {
UA, fetchRaw, fetchText, fetchJson, responseBytes, decodeEntities, stripTags,
clampPage, tooShort, isRetryable, withRetry, getCookies, setCookies, clearCookies,
setProxy, getProxy, fetchWithProxy
};
+28 -2
View File
@@ -14,6 +14,27 @@ const openstax = require('./openstax');
const opentextbook = require('./opentextbook');
const wikisourceZh = require('./wikisource-zh');
const wikisourceEn = require('./wikisource-en');
const mangadex = require('./mangadex');
const copymanga = require('./copymanga');
const CATEGORY_BY_ID = {
arxiv: 'academic',
doaj: 'academic',
pmc: 'academic',
biorxiv: 'academic',
semanticscholar: 'academic',
scihub: 'academic',
gutenberg: 'books',
openlibrary: 'books',
standardebooks: 'books',
libgen: 'books',
zlib: 'books',
openstax: 'open',
opentextbook: 'open',
'wikisource-zh': 'open',
'wikisource-en': 'open',
motw: 'archive'
};
const sources = [
arxiv,
@@ -31,7 +52,9 @@ const sources = [
libgen,
zlib,
scihub,
motw
motw,
mangadex,
copymanga
];
const byId = new Map(sources.map((s) => [s.id, s]));
@@ -39,8 +62,11 @@ function listSources() {
return sources.map((s) => ({
id: s.id,
name: s.name,
category: s.category || CATEGORY_BY_ID[s.id] || 'other',
experimental: s.experimental === true,
supportsSearch: s.supportsSearch !== false,
downloadOnDemand: s.downloadOnDemand === true
downloadOnDemand: s.downloadOnDemand === true,
chapterBased: s.chapterBased === true
}));
}
+248
View File
@@ -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);
}
};