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:
1 parent
7247e096f7
commit
dd1604ea91
3 files changed
+282
No files matched your search
@@ -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}` : ""}.`;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user