Files
drinkmarble/lib/chzzk/channel.ts
2026-09-21 13:03:01 +09:00

137 lines
4.2 KiB
TypeScript

/** 치지직 라이브/채널 URL에서 channelId 추출 */
export function parseChzzkChannelId(input: string): string | null {
const raw = input.trim();
if (!raw) return null;
// 해시만 붙여 넣은 경우
if (/^[a-f0-9]{32}$/i.test(raw)) return raw.toLowerCase();
try {
const url = new URL(raw.startsWith('http') ? raw : `https://${raw}`);
if (!url.hostname.includes('chzzk.naver.com')) return null;
const parts = url.pathname.split('/').filter(Boolean);
// /live/{id} | /{id} | /channel/{id}
const liveIdx = parts.indexOf('live');
if (liveIdx >= 0 && parts[liveIdx + 1]) {
const id = parts[liveIdx + 1];
return /^[a-f0-9]{32}$/i.test(id) ? id.toLowerCase() : null;
}
const channelIdx = parts.indexOf('channel');
if (channelIdx >= 0 && parts[channelIdx + 1]) {
const id = parts[channelIdx + 1];
return /^[a-f0-9]{32}$/i.test(id) ? id.toLowerCase() : null;
}
if (parts[0] && /^[a-f0-9]{32}$/i.test(parts[0])) {
return parts[0].toLowerCase();
}
} catch {
return null;
}
return null;
}
const CHZZK_API = 'https://api.chzzk.naver.com';
const GAME_API = 'https://comm-api.game.naver.com/nng_main';
const DEFAULT_HEADERS = {
Accept: 'application/json',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
Origin: 'https://chzzk.naver.com',
Referer: 'https://chzzk.naver.com/',
};
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url, {
headers: DEFAULT_HEADERS,
cache: 'no-store',
});
if (!res.ok) {
throw new Error(`치지직 API 오류 (${res.status})`);
}
return (await res.json()) as T;
}
export type ChannelInfo = {
channelId: string;
channelName: string | null;
};
export async function fetchChannelInfo(channelId: string): Promise<ChannelInfo> {
const data = await getJson<{
content?: { channelId?: string; channelName?: string };
}>(`${CHZZK_API}/service/v1/channels/${channelId}`);
return {
channelId: data.content?.channelId || channelId,
channelName: data.content?.channelName ?? null,
};
}
export type LiveChatInfo = {
chatChannelId: string;
liveTitle: string | null;
status: string | null;
};
/** 방송 중일 때 chatChannelId를 얻는다. */
export async function fetchLiveChatInfo(channelId: string): Promise<LiveChatInfo> {
// polling live-status 우선
try {
const status = await getJson<{
content?: { chatChannelId?: string; status?: string; liveTitle?: string };
}>(`${CHZZK_API}/polling/v2/channels/${channelId}/live-status`);
if (status.content?.chatChannelId) {
return {
chatChannelId: status.content.chatChannelId,
liveTitle: status.content.liveTitle ?? null,
status: status.content.status ?? null,
};
}
} catch {
// fallback below
}
const detail = await getJson<{
content?: { chatChannelId?: string; liveTitle?: string; status?: string };
}>(`${CHZZK_API}/service/v3/channels/${channelId}/live-detail`);
if (!detail.content?.chatChannelId) {
throw new Error('방송이 꺼져 있거나 chatChannelId를 찾지 못했습니다.');
}
return {
chatChannelId: detail.content.chatChannelId,
liveTitle: detail.content.liveTitle ?? null,
status: detail.content.status ?? null,
};
}
export async function fetchChatAccessToken(chatChannelId: string): Promise<string> {
const data = await getJson<{
code?: number;
message?: string;
content?: { accessToken?: string };
}>(
`${GAME_API}/v1/chats/access-token?channelId=${encodeURIComponent(chatChannelId)}&chatType=STREAMING`,
);
if (data.code === 42601) {
throw new Error('연령 제한 방송이라 익명 채팅 토큰을 받을 수 없습니다.');
}
const token = data.content?.accessToken;
if (!token) {
// 외부 응답 원문은 로그/화면으로 전달하지 않는다.
throw new Error('채팅 연결 인증 정보를 받지 못했습니다.');
}
return token;
}
export function chatServerUrl(chatChannelId: string) {
const sum = chatChannelId
.split('')
.map((c) => c.charCodeAt(0))
.reduce((a, b) => a + b, 0);
const serverId = (Math.abs(sum) % 9) + 1;
return `wss://kr-ss${serverId}.chat.naver.com/chat`;
}