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:
1 parent
dfb21a6c01
commit
995b0178d5
4 files changed
+207
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user