feat(core): KakaoPage links, image URLs and same-series check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
899e8a0d54
commit
cd3a7b869a
2 files changed
+103
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { LinkError } from "../../links";
|
||||
|
||||
export const PAGE_HOSTS: ReadonlySet<string> = 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<string> = 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user