feat(core): parse MangaBaka and Webtoons links; normalise Webtoons image URLs

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:36:52 +02:00
1 parent dfb21a6c01
commit 995b0178d5
4 files changed
+207

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
export class LinkError extends Error {
override name = "LinkError";
}
/** Parses a pasted link; a missing scheme means https. */
export function parseLink(input: string): URL {
const s = input.trim();
if (!s) throw new LinkError("Paste a link first.");
try {
return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`);
} catch {
throw new LinkError(`That doesn't look like a link: ${s}`);
}
}
/**
* Accepts a bare id or any mangabaka.org link to a series. `/9999` redirects to the canonical
* `/manhwa/9999/Tower-of-God` and `/series/9999` is a 404 (spec §4.5), so we take the first
* all-digits path segment.
*/
export function parseMangaBakaSeriesId(input: string): number {
const s = input.trim();
if (/^\d+$/.test(s)) return positive(Number(s), s);
const url = parseLink(s);
if (!/^(www\.)?mangabaka\.org$/i.test(url.hostname)) {
throw new LinkError(`Not a MangaBaka link: ${url.hostname}. Paste a mangabaka.org series link or its number.`);
}
const segment = url.pathname.split("/").find((p) => /^\d+$/.test(p));
if (!segment) throw new LinkError("That MangaBaka link has no series number in it.");
return positive(Number(segment), s);
}
function positive(n: number, input: string): number {
if (!Number.isSafeInteger(n) || n <= 0) throw new LinkError(`Not a MangaBaka series number: ${input}`);
return n;
}
+76
View File
@@ -0,0 +1,76 @@
import { LinkError } from "../../links";
export const WEBTOONS_HOSTS: ReadonlySet<string> = new Set(["www.webtoons.com", "m.webtoons.com", "webtoons.com"]);
export const IMAGE_HOST = "swebtoon-phinf.pstatic.net";
/** Hosts that serve the same image paths; only IMAGE_HOST works without a Webtoons Referer (spec §2.1). */
const IMAGE_HOSTS: ReadonlySet<string> = new Set([
IMAGE_HOST, "webtoon-phinf.pstatic.net", "swebtoon.phinf.naver.net", "webtoon.phinf.naver.net",
]);
/** Webtoons path segment → MangaBaka language code. */
const LANGUAGES: Readonly<Record<string, string>> = {
en: "en", es: "es", fr: "fr", de: "de", th: "th", id: "id", "zh-hant": "zh-Hant",
};
export interface WebtoonsRef {
titleNo: string;
languageSegment: string | null;
canvas: boolean;
}
export function isWebtoonsSeriesUrl(url: URL): boolean {
return WEBTOONS_HOSTS.has(url.hostname) && /^\d+$/.test(url.searchParams.get("title_no") ?? "");
}
export function parseWebtoonsUrl(url: URL): WebtoonsRef {
if (!WEBTOONS_HOSTS.has(url.hostname)) throw new LinkError(`Not a WEBTOON link: ${url.hostname}`);
const titleNo = url.searchParams.get("title_no") ?? "";
if (!/^\d+$/.test(titleNo)) {
throw new LinkError("That WEBTOON link has no title_no. Use the series page (…/list?title_no=…).");
}
const segments = url.pathname.split("/").filter(Boolean).map((s) => s.toLowerCase());
const first = segments[0];
return {
titleNo,
languageSegment: first && LANGUAGES[first] ? first : null,
canvas: segments.includes("canvas") || segments.includes("challenge"),
};
}
export function mangabakaLanguage(segment: string | null): string {
return (segment && LANGUAGES[segment]) || "unknown";
}
/**
* `/-/-/-/list` (official) and `/-/canvas/-/list` (canvas) 301 to the canonical page; the wrong
* form is a 404 (verified 2026-10-14). A made-up slug like `/en/x/x/` made Webtoons hang instead,
* so we never fetch the user's own path.
*/
export function fetchUrlFor(titleNo: string, canvas = false): string {
return `https://www.webtoons.com/-/${canvas ? "canvas" : "-"}/-/list?title_no=${titleNo}`;
}
export function archivePrefixes(canonicalUrl: string): string[] {
const u = new URL(canonicalUrl);
const rest = `${u.pathname}?title_no=${u.searchParams.get("title_no") ?? ""}`;
return [`www.webtoons.com${rest}`, `m.webtoons.com${rest}`];
}
/** Largest, hotlinkable form: IMAGE_HOST, same path, no `?type=` resize query. */
export function normalizeImageUrl(raw: string, base?: string | URL): string {
let u: URL;
try {
u = new URL(raw.trim(), base);
} catch {
return raw.trim();
}
return IMAGE_HOSTS.has(u.hostname) ? `https://${IMAGE_HOST}${u.pathname}` : u.toString();
}
export function isSameWebtoonsSeries(linkUrl: string, titleNo: string): boolean {
try {
const u = new URL(linkUrl);
return WEBTOONS_HOSTS.has(u.hostname) && u.searchParams.get("title_no") === titleNo;
} catch {
return false;
}
}
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from "bun:test";
import { LinkError, parseLink, parseMangaBakaSeriesId } from "../src/core/links";
test.each([
["9999", 9999],
[" 9999 ", 9999],
["https://mangabaka.org/9999", 9999],
["https://mangabaka.org/manhwa/9999/Tower-of-God", 9999],
["mangabaka.org/manhwa/9999/Tower-of-God", 9999],
["https://www.mangabaka.org/manhwa/9999", 9999],
["https://mangabaka.org/data/submissions/series/9999/images", 9999],
])("parseMangaBakaSeriesId(%p) = %p", (input, id) => {
expect(parseMangaBakaSeriesId(input)).toBe(id);
});
test.each(["", "abc", "https://example.org/9999", "https://mangabaka.org/manhwa/Tower-of-God", "0", "-5"])(
"parseMangaBakaSeriesId(%p) throws LinkError",
(input) => {
expect(() => parseMangaBakaSeriesId(input)).toThrow(LinkError);
},
);
test("parseLink adds https:// when the scheme is missing", () => {
expect(parseLink("www.webtoons.com/en/x/list?title_no=95").href).toBe("https://www.webtoons.com/en/x/list?title_no=95");
expect(() => parseLink(" ")).toThrow(LinkError);
});
+69
View File
@@ -0,0 +1,69 @@
import { expect, test } from "bun:test";
import { LinkError } from "../src/core/links";
import {
archivePrefixes, fetchUrlFor, isSameWebtoonsSeries, isWebtoonsSeriesUrl, mangabakaLanguage,
normalizeImageUrl, parseWebtoonsUrl,
} from "../src/core/sources/webtoons/urls";
test("parses official, canvas, mobile and canonicalised links", () => {
expect(parseWebtoonsUrl(new URL("https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95")))
.toEqual({ titleNo: "95", languageSegment: "en", canvas: false });
expect(parseWebtoonsUrl(new URL("https://m.webtoons.com/fr/romance/x/list?title_no=1&page=3")))
.toEqual({ titleNo: "1", languageSegment: "fr", canvas: false });
expect(parseWebtoonsUrl(new URL("https://www.webtoons.com/en/canvas/crow-time/list?title_no=693372")))
.toEqual({ titleNo: "693372", languageSegment: "en", canvas: true });
expect(parseWebtoonsUrl(new URL("https://www.webtoons.com/-/-/-/list?title_no=95")))
.toEqual({ titleNo: "95", languageSegment: null, canvas: false });
expect(parseWebtoonsUrl(new URL("https://www.webtoons.com/zh-hant/fantasy/x/list?title_no=160")).languageSegment)
.toBe("zh-hant");
});
test("rejects links without a numeric title_no or on other hosts", () => {
expect(() => parseWebtoonsUrl(new URL("https://www.webtoons.com/en/fantasy/tower-of-god/list"))).toThrow(LinkError);
expect(() => parseWebtoonsUrl(new URL("https://www.webtoons.com/en/x/list?title_no=abc"))).toThrow(LinkError);
expect(() => parseWebtoonsUrl(new URL("https://tapas.io/series/x"))).toThrow(LinkError);
expect(isWebtoonsSeriesUrl(new URL("https://www.webtoons.com/en/x/list?title_no=95"))).toBe(true);
expect(isWebtoonsSeriesUrl(new URL("https://www.webtoons.com/en/x/viewer?title_no=95&episode_no=1"))).toBe(true);
expect(isWebtoonsSeriesUrl(new URL("https://tapas.io/?title_no=95"))).toBe(false);
});
test("maps Webtoons language segments to MangaBaka codes", () => {
expect(mangabakaLanguage("en")).toBe("en");
expect(mangabakaLanguage("zh-hant")).toBe("zh-Hant");
expect(mangabakaLanguage("de")).toBe("de");
expect(mangabakaLanguage(null)).toBe("unknown");
expect(mangabakaLanguage("xx")).toBe("unknown");
});
test("fetches the /-/-/-/ form (official) or /-/canvas/-/ form (canvas); both redirect to the canonical page", () => {
expect(fetchUrlFor("95")).toBe("https://www.webtoons.com/-/-/-/list?title_no=95");
expect(fetchUrlFor("693372", true)).toBe("https://www.webtoons.com/-/canvas/-/list?title_no=693372");
});
test("archive prefixes cover www. and m. without a scheme", () => {
expect(archivePrefixes("https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95")).toEqual([
"www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95",
"m.webtoons.com/en/fantasy/tower-of-god/list?title_no=95",
]);
});
test.each([
["https://webtoon-phinf.pstatic.net/20250204_46/17386458444547o1b2_JPEG/95.jpg?type=crop540_540",
"https://swebtoon-phinf.pstatic.net/20250204_46/17386458444547o1b2_JPEG/95.jpg"],
["http://swebtoon.phinf.naver.net/20140710_28/1404979632969uKBA8_JPEG/01_EC8BA0EC9D98ED8391.jpg",
"https://swebtoon-phinf.pstatic.net/20140710_28/1404979632969uKBA8_JPEG/01_EC8BA0EC9D98ED8391.jpg"],
["http://webtoon.phinf.naver.net/20140915_195/1410759727783HvB4R_JPEG/11_EC8BA0EC9D98ED8391_bg.jpg",
"https://swebtoon-phinf.pstatic.net/20140915_195/1410759727783HvB4R_JPEG/11_EC8BA0EC9D98ED8391_bg.jpg"],
["//swebtoon-phinf.pstatic.net/a/b c).jpg", "https://swebtoon-phinf.pstatic.net/a/b%20c).jpg"],
["https://example.org/x.jpg?keep=1", "https://example.org/x.jpg?keep=1"],
])("normalizeImageUrl(%p)", (raw, expected) => {
expect(normalizeImageUrl(raw, "https://www.webtoons.com/en/x/list?title_no=1")).toBe(expected);
});
test("isSameWebtoonsSeries compares title_no exactly, including MangaBaka's canonical form", () => {
expect(isSameWebtoonsSeries("https://www.webtoons.com/-/-/-/list?title_no=95", "95")).toBe(true);
expect(isSameWebtoonsSeries("http://www.webtoons.com:80/en/fantasy/tower-of-god/list?title_no=95", "95")).toBe(true);
expect(isSameWebtoonsSeries("https://www.webtoons.com/-/-/-/list?title_no=950", "95")).toBe(false);
expect(isSameWebtoonsSeries("https://comic.naver.com/webtoon/list?titleId=95", "95")).toBe(false);
expect(isSameWebtoonsSeries("not a url", "95")).toBe(false);
});