diff --git a/src/core/sources/kakaopage/urls.ts b/src/core/sources/kakaopage/urls.ts new file mode 100644 index 0000000..a7a7220 --- /dev/null +++ b/src/core/sources/kakaopage/urls.ts @@ -0,0 +1,54 @@ +import { LinkError } from "../../links"; + +export const PAGE_HOSTS: ReadonlySet = new Set(["page.kakao.com", "m.page.kakao.com"]); +export const API_HOST = "bff-page.kakao.com"; +export const IMAGE_HOST = "page-images.kakaoentcdn.com"; +/** Older pages name dn-img-page.kakao.com; the same keys answer on IMAGE_HOST (spec 004 §1). */ +export const IMAGE_HOSTS: ReadonlySet = new Set([IMAGE_HOST, "dn-img-page.kakao.com"]); + +const CONTENT_PATH = /^\/content\/(\d+)(?:\/|$)/; +/** An image key as the API and old pages give it: "mW3j5/dJMcaacSrAK/WVoJJAYNlS9rayXUWi6AY0". */ +const BARE_KEY = /^[A-Za-z0-9]+\/[A-Za-z0-9]+\/[A-Za-z0-9]+$/; + +function idOf(url: URL): string | null { + if (!PAGE_HOSTS.has(url.hostname)) return null; + const content = CONTENT_PATH.exec(url.pathname)?.[1]; + if (content) return content; + const home = url.pathname.replace(/\/+$/, "") === "/home" ? url.searchParams.get("seriesId") : null; + return home && /^\d+$/.test(home) ? home : null; +} + +export const isKakaoPageUrl = (url: URL): boolean => idOf(url) !== null; + +export function parseKakaoPageUrl(url: URL): string { + const id = idOf(url); + if (id) return id; + if (!PAGE_HOSTS.has(url.hostname)) throw new LinkError(`Not a KakaoPage link: ${url.hostname}`); + throw new LinkError("That KakaoPage link isn't a series. Use the series page (page.kakao.com/content/…)."); +} + +export const overviewUrl = (id: string): string => `https://${API_HOST}/api/gateway/api/v1/content/overview?series_id=${id}`; +export const contentUrl = (id: string): string => `https://page.kakao.com/content/${id}`; +const originalFor = (kid: string) => `https://${IMAGE_HOST}/download/resource?kid=${kid}&filename=o1`; + +/** The original image for a key or any of its URLs; other URLs pass through. */ +export function normalizeImageUrl(raw: string, base?: string | URL): string { + const s = raw.trim(); + if (BARE_KEY.test(s)) return originalFor(s); + let u: URL; + try { + u = new URL(s.startsWith("//") ? `https:${s}` : s, base); + } catch { + return s; + } + const kid = u.searchParams.get("kid"); + return IMAGE_HOSTS.has(u.hostname) && u.pathname === "/download/resource" && kid && BARE_KEY.test(kid) ? originalFor(kid) : u.toString(); +} + +export function isSameKakaoSeries(linkUrl: string, id: string): boolean { + try { + return idOf(new URL(linkUrl)) === id; + } catch { + return false; + } +} diff --git a/test/kakaopage-urls.test.ts b/test/kakaopage-urls.test.ts new file mode 100644 index 0000000..efebf46 --- /dev/null +++ b/test/kakaopage-urls.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { LinkError } from "../src/core/links"; +import { isKakaoPageUrl, isSameKakaoSeries, normalizeImageUrl, overviewUrl, parseKakaoPageUrl } from "../src/core/sources/kakaopage/urls"; + +const u = (s: string) => new URL(s); + +describe("links", () => { + test("both addresses, with or without the mobile host and extras", () => { + expect(parseKakaoPageUrl(u("https://page.kakao.com/content/54845189"))).toBe("54845189"); + expect(parseKakaoPageUrl(u("https://page.kakao.com/content/54845189/viewer/1?tab=episode"))).toBe("54845189"); + expect(parseKakaoPageUrl(u("https://m.page.kakao.com/home?seriesId=54845189&utm_source=tw"))).toBe("54845189"); + }); + + test("anything else is not a KakaoPage series", () => { + for (const s of ["https://page.kakao.com/", "https://page.kakao.com/content/abc", "https://page.kakao.com/home", "https://webtoon.kakao.com/content/x/2383"]) { + expect(isKakaoPageUrl(u(s))).toBe(false); + expect(() => parseKakaoPageUrl(u(s))).toThrow(LinkError); + } + }); + + test("the data endpoint", () => { + expect(overviewUrl("54845189")).toBe("https://bff-page.kakao.com/api/gateway/api/v1/content/overview?series_id=54845189"); + }); +}); + +describe("image URLs", () => { + test("the original on page-images.kakaoentcdn.com", () => { + const original = "https://page-images.kakaoentcdn.com/download/resource?kid=mW3j5/dJMcaacSrAK/WVoJJAYNlS9rayXUWi6AY0&filename=o1"; + expect(normalizeImageUrl("https://page-images.kakaoentcdn.com/download/resource?kid=mW3j5/dJMcaacSrAK/WVoJJAYNlS9rayXUWi6AY0&filename=o1/dims/resize/384")).toBe(original); + expect(normalizeImageUrl("//dn-img-page.kakao.com/download/resource?kid=mW3j5/dJMcaacSrAK/WVoJJAYNlS9rayXUWi6AY0&filename=th3", "https://page.kakao.com/content/1")).toBe(original); + expect(normalizeImageUrl("http://dn-img-page.kakao.com/download/resource?kid=mW3j5/dJMcaacSrAK/WVoJJAYNlS9rayXUWi6AY0&filename=th2".replace("&", "&"))).toBe(original); + }); + + test("a bare key becomes the original URL; other hosts are left alone", () => { + expect(normalizeImageUrl("bx4cET/hyATqBNkj9/kUeJCNRNNpYDYRBtpK1Oh1")) + .toBe("https://page-images.kakaoentcdn.com/download/resource?kid=bx4cET/hyATqBNkj9/kUeJCNRNNpYDYRBtpK1Oh1&filename=o1"); + expect(normalizeImageUrl("https://example.org/x.jpg?filename=th3")).toBe("https://example.org/x.jpg?filename=th3"); + }); +}); + +describe("same series", () => { + test("the exact id, in either address", () => { + expect(isSameKakaoSeries("https://page.kakao.com/content/54845189", "54845189")).toBe(true); + expect(isSameKakaoSeries("https://page.kakao.com/home?seriesId=54845189&utm_source=tw", "54845189")).toBe(true); + expect(isSameKakaoSeries("https://page.kakao.com/home?seriesId=548451890", "54845189")).toBe(false); + expect(isSameKakaoSeries("https://page.kakao.com/content/548451890", "54845189")).toBe(false); + expect(isSameKakaoSeries("nope", "54845189")).toBe(false); + }); +});