From 995b0178d555722f5a047a2394520f8cdfbd8faa Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 14 Oct 2026 23:36:52 +0200 Subject: [PATCH] feat(core): parse MangaBaka and Webtoons links; normalise Webtoons image URLs Co-Authored-By: Claude Opus 5.5 --- src/core/links.ts | 36 +++++++++++++++ src/core/sources/webtoons/urls.ts | 76 +++++++++++++++++++++++++++++++ test/links.test.ts | 26 +++++++++++ test/webtoons-urls.test.ts | 69 ++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 src/core/links.ts create mode 100644 src/core/sources/webtoons/urls.ts create mode 100644 test/links.test.ts create mode 100644 test/webtoons-urls.test.ts diff --git a/src/core/links.ts b/src/core/links.ts new file mode 100644 index 0000000..2cddc35 --- /dev/null +++ b/src/core/links.ts @@ -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; +} diff --git a/src/core/sources/webtoons/urls.ts b/src/core/sources/webtoons/urls.ts new file mode 100644 index 0000000..cfe0089 --- /dev/null +++ b/src/core/sources/webtoons/urls.ts @@ -0,0 +1,76 @@ +import { LinkError } from "../../links"; + +export const WEBTOONS_HOSTS: ReadonlySet = 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 = 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> = { + 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; + } +} diff --git a/test/links.test.ts b/test/links.test.ts new file mode 100644 index 0000000..5d0c02e --- /dev/null +++ b/test/links.test.ts @@ -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); +}); diff --git a/test/webtoons-urls.test.ts b/test/webtoons-urls.test.ts new file mode 100644 index 0000000..0b356bc --- /dev/null +++ b/test/webtoons-urls.test.ts @@ -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); +});