Add first-party MangaPill source and distinct volume releases

This commit is contained in:
Codex committed 2026-10-14 09:05:34 +02:00
1 parent a8df57f165
commit a08e847932
20 files changed
+364 -34

No files matched your search

+12 -3
View File
@@ -22,6 +22,10 @@ function publicAddress(address) {
}
function validateWeebRequest(value, options = {}) {
return validateHostRequest(value, 'weebcentral.com', options);
}
function validateHostRequest(value, sourceHost, options = {}) {
const url = new URL(value);
const host = url.hostname.toLowerCase();
if (url.protocol !== 'https:' || url.username || url.password || url.port
@@ -29,8 +33,8 @@ function validateWeebRequest(value, options = {}) {
|| host.endsWith('.local') || !host.includes('.')) {
throw new Error('Source request URL is not public HTTPS');
}
if (options.accept === 'text/html' && host !== 'weebcentral.com') {
throw new Error('WeebCentral catalog request left its source host');
if (options.accept === 'text/html' && host !== sourceHost) {
throw new Error('Source catalog request left its source host');
}
return url;
}
@@ -88,4 +92,9 @@ function createWeebHttp(options = {}) {
return new SlowHttp({ ...options, transport: pinnedFetch, validateUrl: validateWeebRequest });
}
module.exports = { publicAddress, validateWeebRequest, pinnedLookup, pinnedFetch, createWeebHttp };
function createHostHttp(sourceHost, options = {}) {
return new SlowHttp({ ...options, transport: pinnedFetch,
validateUrl: (value, requestOptions) => validateHostRequest(value, sourceHost, requestOptions) });
}
module.exports = { publicAddress, validateWeebRequest, validateHostRequest, pinnedLookup, pinnedFetch, createWeebHttp, createHostHttp };
+32
View File
@@ -0,0 +1,32 @@
'use strict';
// First-party parser; inputs are fetched by the paced source broker, not here.
const decode = value => String(value).replace(/&/g, '&').replace(/'|'/gi, "'")
.replace(/&quot;/g, '"').replace(/<[^>]+>/g, '').trim();
function catalog({ html, url }) {
const sourceId = new URL(url).pathname.split('/')[2];
const title = decode(html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i)?.[1] || 'Untitled');
const chapters = [];
const re = /<a\b[^>]*href="(\/chapters\/(\d+-\d+)\/[^"?#]+)"[^>]*>([\s\S]*?)<\/a>/gi;
for (const match of html.matchAll(re)) {
if (match[2].split('-')[0] !== sourceId) continue;
const label = decode(match[3]);
const number = label.match(/^Chapter\s+([0-9]+(?:\.[0-9]+)?)(?:\b|$)/i)?.[1];
if (!number) continue;
chapters.push({ id: match[2], number, title: '', url: new URL(match[1], url).href, raw: label });
}
return { title, chapters: [...new Map(chapters.map(ref => [ref.id, ref])).values()],
chapterLinks: [...html.matchAll(/href="\/chapters\/\d+-\d+\//gi)].length };
}
function pages({ html }) {
const images = [];
for (const match of html.matchAll(/<img\b[^>]*class="[^"]*\bjs-page\b[^"]*"[^>]*>/gi)) {
const url = match[0].match(/\bdata-src="([^"]+)"/i)?.[1];
if (url) images.push(url.replace(/&amp;/g, '&'));
}
return { pages: [...new Set(images)] };
}
module.exports = { catalog, pages };
+6
View File
@@ -17,6 +17,12 @@ const manifests = Object.freeze({
provenance: 'MangaCircus existing MangaDex adapter; no Uchiyomi code copied',
operations: Object.freeze(['metadata', 'feed', 'catalog', 'atHome']),
}),
mangapill: Object.freeze({
id: 'mangapill', version: 1, label: 'MangaPill', status: 'in-process',
sourceHosts: Object.freeze(['mangapill.com']), implementation: 'first-party',
provenance: 'MangaCircus implementation; upstream extension reviewed, no code copied',
operations: Object.freeze(['catalog', 'pages']),
}),
});
module.exports = { manifests };
+1
View File
@@ -6,6 +6,7 @@ const { validate } = require('./validation');
const plugins = Object.freeze({
weebcentral: require('./weebcentral'),
mangadex: require('./mangadex'),
mangapill: require('./mangapill'),
});
function parse(plugin, operation, input) {
+28 -1
View File
@@ -33,8 +33,20 @@ function mangaDexUrl(value, kind) {
return url.href;
}
function mangaPillUrl(value, kind) {
const url = new URL(value);
if (url.protocol !== 'https:' || url.username || url.password || url.port) throw new Error('Invalid MangaPill plugin URL');
if (kind === 'chapter' && (url.hostname !== 'mangapill.com' || !/^\/chapters\/\d+-\d+\//.test(url.pathname))) {
throw new Error('Unexpected MangaPill chapter URL');
}
if (kind === 'page' && (url.hostname !== 'cdn.readdetectiveconan.com' || !url.pathname.startsWith('/file/mangap/'))) {
throw new Error('Unexpected MangaPill page host');
}
return url.href;
}
function validate(plugin, operation, output) {
if (!['weebcentral', 'mangadex'].includes(plugin) || !output || typeof output !== 'object' || Array.isArray(output)) {
if (!['weebcentral', 'mangadex', 'mangapill'].includes(plugin) || !output || typeof output !== 'object' || Array.isArray(output)) {
throw new Error('Invalid source plugin output');
}
if (plugin === 'mangadex') {
@@ -51,6 +63,21 @@ function validate(plugin, operation, output) {
} else throw new Error('Unknown source plugin operation');
return output;
}
if (plugin === 'mangapill') {
if (operation === 'catalog') {
if (typeof output.title !== 'string' || !Array.isArray(output.chapters)
|| !Number.isInteger(output.chapterLinks) || output.chapterLinks < 0) throw new Error('Invalid MangaPill catalog');
for (const chapter of output.chapters) {
if (typeof chapter?.id !== 'string' || !/^\d+-\d+$/.test(chapter.id)
|| !/^\d+(?:\.\d+)?$/.test(chapter.number)) throw new Error('Invalid MangaPill chapter');
mangaPillUrl(chapter.url, 'chapter');
}
} else if (operation === 'pages') {
if (!Array.isArray(output.pages)) throw new Error('Invalid MangaPill pages');
for (const page of output.pages) mangaPillUrl(page, 'page');
} else throw new Error('Unknown MangaPill operation');
return output;
}
if (operation === 'series') {
if (typeof output.title !== 'string' || typeof output.listUrl !== 'string' && output.listUrl !== null) throw new Error('Invalid series parse');
if (output.listUrl !== null) sourceUrl(output.listUrl, '/series/');
+8
View File
@@ -1,4 +1,5 @@
'use strict';
const { volumeKey } = require('../../chapter-identity');
// First-party, pure parsing functions. This module never fetches remote content.
const decode = value => value.replace(/&amp;/g, '&').replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/<[^>]+>/g, '').trim();
@@ -17,6 +18,7 @@ function catalog({ html, url }) {
const text = decode(match[3]).replace(/\s+/g, ' ');
const season = Number(text.match(/^S(\d+)\s*-/i)?.[1] || 0) || null;
const regular = text.match(/\b(?:Chapter|Rating|Episode|Night|Log|Act|Scene|Kapitel|File|Battle)\s*[.:]?\s*([0-9]+(?:\.[0-9]+)?)/i)?.[1];
const volume = !regular ? text.match(/^Volume\s+([0-9]+(?:\.[0-9]+)?)(?!\d)/i)?.[1] : null;
const prologue = text.match(/Prologue(?:\s+([0-9]+))?/i)?.[1];
const number = regular || (prologue ? `0.${prologue.padStart(2, '0')}` : /Prologue/i.test(text) ? '0' : null);
if (number) found.push({
@@ -24,6 +26,12 @@ function catalog({ html, url }) {
...(season ? { book: { number: season, title: `Season ${season}` } } : {}),
url: absolute(url, match[1]), raw: text,
});
else if (volume) found.push({
id: match[2], number: volumeKey(match[2]), title: '', special: true,
label: `Volume ${volume}`, releaseKind: 'volume', volume,
book: { number: Number(volume), title: `Volume ${volume}` },
url: absolute(url, match[1]), raw: text,
});
}
const chapters = [...new Map(found.map(chapter => [chapter.id, chapter])).values()];
const chapterLinks = [...html.matchAll(/<a\b[^>]*href="[^"]*\/chapters\/[A-Z0-9]+[^"]*"/gi)].length;