feat(core): MangaBaka client (paginated public reads; snapshot, preview, save for review)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-14 23:38:08 +02:00
1 parent 7247e096f7
commit dd1604ea91
3 files changed
+282

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
import type { Transport } from "../transport";
import type {
ImageSnapshot, PreviewResult, PublicImage, SaveResult, SeriesLink, SeriesSummary, SubmissionImage,
} from "./types";
export const API_BASE = "https://api.mangabaka.org";
export const SITE_BASE = "https://mangabaka.org";
export const coverFormUrl = (seriesId: number): string => `${SITE_BASE}/data/submissions/series/${seriesId}/images`;
export class MangaBakaError extends Error {
constructor(message: string, readonly status: number, readonly body?: unknown) {
super(message);
this.name = "MangaBakaError";
}
}
const MAX_PAGES = 50;
/** Talks to api.mangabaka.org directly (CORS is open); the token never goes anywhere else. */
export class MangaBakaClient {
constructor(private readonly transport: Transport, private readonly token?: string) {}
async getSeries(id: number): Promise<SeriesSummary> {
return (await this.get<{ data: SeriesSummary }>(`/v1/series/${id}`)).data;
}
getLinks(id: number): Promise<SeriesLink[]> {
return this.getAllPages<SeriesLink>(`/v1/series/${id}/links?limit=100`);
}
/** Paginated, `limit` ≤ 50 (spec §2.3). */
getImages(id: number): Promise<PublicImage[]> {
return this.getAllPages<PublicImage>(`/v1/series/${id}/images?limit=50`);
}
async getImageSnapshot(id: number): Promise<ImageSnapshot> {
const body = await this.get<{ data: { data: { images: SubmissionImage[] }; version: number } }>(
`/v0/my/submissions/series-images/${id}`, true);
return { version: body.data.version, images: body.data.data.images };
}
async previewImages(id: number, version: number, images: SubmissionImage[]): Promise<PreviewResult> {
return (await this.post<{ data: PreviewResult }>(`/v0/my/submissions/series-images/${id}/preview`,
{ version, data: { images } })).data;
}
/** Files a real edit request, always for review. Call only after the user confirmed the preview. */
async saveImagesForReview(id: number, version: number, images: SubmissionImage[], userNote: string): Promise<SaveResult> {
return (await this.post<{ data: SaveResult }>(`/v0/my/submissions/series-images/${id}`,
{ version, user_note: userNote, save_mode: "review", data: { images } })).data;
}
private headers(auth: boolean): Record<string, string> {
const headers: Record<string, string> = { accept: "application/json" };
if (auth) {
if (!this.token) throw new MangaBakaError("This needs a MangaBaka personal access token.", 401);
headers["x-api-key"] = this.token;
}
return headers;
}
private async get<T>(path: string, auth = false): Promise<T> {
const headers = this.headers(auth);
return this.parse<T>(await this.transport.fetch(`${API_BASE}${path}`, { headers }), path);
}
private async post<T>(path: string, body: unknown): Promise<T> {
const headers = { ...this.headers(true), "content-type": "application/json" };
const res = await this.transport.fetch(`${API_BASE}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
return this.parse<T>(res, path);
}
private async getAllPages<T>(path: string): Promise<T[]> {
const out: T[] = [];
let url: string | null = `${API_BASE}${path}`;
for (let page = 0; url && page < MAX_PAGES; page++) {
const res = await this.transport.fetch(url, { headers: this.headers(false) });
const body = await this.parse<{ data: T[]; pagination?: { next?: string | null } }>(res, path);
out.push(...body.data);
const next = body.pagination?.next ?? null;
url = next && new URL(next).origin === API_BASE ? next : null;
}
return out;
}
private async parse<T>(res: Response, path: string): Promise<T> {
const text = await res.text();
let body: unknown;
try {
body = text ? JSON.parse(text) : undefined;
} catch {
body = undefined;
}
if (!res.ok) throw new MangaBakaError(explain(res.status, body, path), res.status, body);
if (body === undefined) throw new MangaBakaError(`MangaBaka sent something that isn't JSON for ${path}.`, res.status);
return body as T;
}
}
function explain(status: number, body: unknown, path: string): string {
const detail = body && typeof body === "object" && "message" in body ? String((body as { message: unknown }).message) : "";
switch (status) {
case 401:
return "MangaBaka rejected the token (401). Check your personal access token.";
case 403:
return "MangaBaka refused (403): the submissions API needs the contributor role or 25 approved submissions. Use the copy-and-paste path instead.";
case 404:
return `MangaBaka has no such series (${path}).`;
case 409:
return "The series' covers changed since they were loaded (409).";
default:
return `MangaBaka answered HTTP ${status}${detail ? `: ${detail}` : ""}.`;
}
}
+70
View File
@@ -0,0 +1,70 @@
// Subset of https://mangabaka.org/api.json (downloaded 2026-10-14) that we use. See spec §2.3.
import type { ExistingImage } from "../covers";
import type { ContentRating, ImageType } from "../model";
export interface SeriesSummary {
id: number;
title: string;
type: string;
content_rating: string | null;
}
export interface SeriesLink {
url: string;
name: string;
language: string | null;
}
/** GET /v1/series/{id}/images item. Has no `is_default`. */
export interface PublicImage {
id: number;
series_id: number;
type: ImageType;
language: string;
note: string | null;
content_rating: ContentRating;
hashes: { sha256?: string | null } | null;
}
/** OpenAPI `series-images` item. `work_id`, `note` and `index_numeric` keys are required, even when null. */
export interface SubmissionImage {
id: number | null;
series_id: number | null;
work_id: string | null;
url: string;
index: string | null;
index_numeric: number | null;
language: string;
type: ImageType;
note: string | null;
content_rating: ContentRating;
is_default: boolean;
}
export interface ImageSnapshot {
version: number;
images: SubmissionImage[];
}
export interface SubmissionChange {
field: string;
type: "added" | "removed" | "changed";
identity?: string;
label?: string;
details?: Record<string, { old?: unknown; new?: unknown }>;
}
export interface PreviewResult {
has_changes: boolean;
changes: SubmissionChange[];
}
export interface SaveResult {
submission_id: number;
status: string;
changes: SubmissionChange[];
}
export function toExisting(img: PublicImage): ExistingImage {
return { id: img.id, sha256: img.hashes?.sha256 ?? null, language: img.language, type: img.type };
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, test } from "bun:test";
import { MangaBakaClient, MangaBakaError, coverFormUrl } from "../src/core/mangabaka/client";
import { toExisting, type SubmissionImage } from "../src/core/mangabaka/types";
import { FakeTransport, json } from "./helpers/fake-transport";
import { fixtureJson } from "./helpers/fixtures";
const API = "https://api.mangabaka.org";
function publicFake() {
return new FakeTransport()
.on(`${API}/v1/series/9999`, async () => json(await fixtureJson("mangabaka/series-9999.json")))
.on(`${API}/v1/series/9999/links?limit=100`, async () => json(await fixtureJson("mangabaka/links-9999.json")))
.on(/\/v1\/series\/9999\/images\?.*page=2/, async () => json(await fixtureJson("mangabaka/images-9999-page2.json")))
.on(`${API}/v1/series/9999/images?limit=50`, async () => json(await fixtureJson("mangabaka/images-9999-page1.json")));
}
describe("public reads (captured fixtures)", () => {
test("series, links, and every page of images", async () => {
const fake = publicFake();
const mb = new MangaBakaClient(fake);
expect((await mb.getSeries(9999)).title).toBe("Tower of God");
expect((await mb.getLinks(9999)).map((l) => l.url)).toContain("https://www.webtoons.com/-/-/-/list?title_no=95");
const images = await mb.getImages(9999);
expect(images).toHaveLength(31);
expect(fake.calls.some((c) => c.init.headers?.["x-api-key"])).toBe(false);
expect(toExisting(images[0]!)).toEqual({
id: images[0]!.id, sha256: images[0]!.hashes!.sha256 ?? null, language: images[0]!.language, type: images[0]!.type,
});
});
test("a next link to another origin is not followed", async () => {
const fake = new FakeTransport().on(`${API}/v1/series/1/images?limit=50`, () =>
json({ status: 200, data: [], pagination: { next: "https://evil.example/page2" } }));
expect(await new MangaBakaClient(fake).getImages(1)).toEqual([]);
expect(fake.calls).toHaveLength(1);
});
test("404 is a MangaBakaError", async () => {
const fake = new FakeTransport().on(() => true, () => json({ status: 404, message: "Not found" }, 404));
const err = await new MangaBakaClient(fake).getSeries(1).catch((e) => e);
expect(err).toBeInstanceOf(MangaBakaError);
expect(err.status).toBe(404);
});
});
// Not capturable without a token: shapes built from the OpenAPI spec (spec §2.3). See docs/TODO.md.
const existing: SubmissionImage = {
id: 7193, series_id: 9999, work_id: null, url: "https://example.org/old.jpg", index: "0.1", index_numeric: 0.1,
language: "ko", type: "volume", note: "Season Cover 3", content_rating: "safe", is_default: true,
};
const snapshotBody = { status: 200, data: { data: { images: [existing] }, version: 1760000000 } };
const previewBody = { status: 200, data: { has_changes: true, changes: [{ field: "images", type: "added", identity: "https://x/new.jpg" }] } };
describe("submissions (token)", () => {
const SUB = `${API}/v0/my/submissions/series-images/9999`;
test("snapshot, preview and save send the token and the right bodies", async () => {
const fake = new FakeTransport()
.on((u, i) => u === SUB && (i.method ?? "GET") === "GET", () => json(snapshotBody))
.on(`${SUB}/preview`, () => json(previewBody))
.on((u, i) => u === SUB && i.method === "POST", () =>
json({ status: 200, data: { submission_id: 5, status: "pending", changes: [] } }));
const mb = new MangaBakaClient(fake, "mb-test-token");
const snapshot = await mb.getImageSnapshot(9999);
expect(snapshot).toEqual({ version: 1760000000, images: [existing] });
expect((await mb.previewImages(9999, snapshot.version, snapshot.images)).has_changes).toBe(true);
const saved = await mb.saveImagesForReview(9999, snapshot.version, snapshot.images, "Add WEBTOON covers");
expect(saved.submission_id).toBe(5);
expect(fake.calls.every((c) => c.init.headers?.["x-api-key"] === "mb-test-token")).toBe(true);
expect(JSON.parse(fake.calls[1]!.init.body as string)).toEqual({ version: 1760000000, data: { images: [existing] } });
expect(JSON.parse(fake.calls[2]!.init.body as string)).toEqual({
version: 1760000000, user_note: "Add WEBTOON covers", save_mode: "review", data: { images: [existing] },
});
});
test("no token: refuses before sending anything", async () => {
const fake = new FakeTransport();
await expect(new MangaBakaClient(fake).getImageSnapshot(9999)).rejects.toMatchObject({ status: 401 });
expect(fake.calls).toHaveLength(0);
});
test.each([
[403, /25 approved submissions/],
[409, /changed since/],
[401, /token/],
])("HTTP %i explains itself", async (status, message) => {
const fake = new FakeTransport().on(() => true, () => json({ status, message: "x" }, status));
const err = await new MangaBakaClient(fake, "mb-t").previewImages(9999, 1, []).catch((e) => e);
expect(err).toBeInstanceOf(MangaBakaError);
expect(err.status).toBe(status);
expect(err.message).toMatch(message);
});
});
test("coverFormUrl", () => {
expect(coverFormUrl(9999)).toBe("https://mangabaka.org/data/submissions/series/9999/images");
});