치지직 클립 다운로더 크롬 익스텐션 개발기 - 외부 API 없이 순수 브라우저에서 작동하는 방법 Building a Chzzk Clip Downloader Chrome Extension - Pure Browser-Based Approach Without External APIs
치지직 클립 다운로더 크롬 익스텐션 개발기: 외부 API 없이 순수 클라이언트 사이드로 구현하기
브라우저 익스텐션으로 영상 다운로더를 만들 때 가장 먼저 떠오르는 방법은 외부 서버를 거쳐 API를 호출하는 것이다. 하지만 이 방식은 서버 유지비용, 개인정보 유출 우려, 그리고 서비스 의존성이라는 세 가지 문제를 동시에 안고 간다. 치지직 클립 다운로더는 이 모든 것을 피하고 순수 클라이언트 사이드에서 동작하는 방식을 택했다. 외부 페이지 이동도, API 호출도 없다. 그냥 브라우저에서 다 끝난다.
크롬 익스텐션의 구조와 권한 모델
크롬 익스텐션을 만들어본 사람이라면 manifest.json이 모든 것의 시작점이라는 걸 안다. Manifest V3로 넘어오면서 권한 체계가 상당히 까다로워졌는데, 영상 다운로더 같은 민감한 기능을 구현하려면 이 부분을 제대로 이해해야 한다.
{
"manifest_version": 3,
"name": "치지직 클립 다운로더",
"version": "1.0.0",
"permissions": [
"activeTab",
"downloads"
],
"host_permissions": [
"https://chzzk.naver.com/*"
],
"content_scripts": [
{
"matches": ["https://chzzk.naver.com/clips/*"],
"js": ["content.js"]
}
],
"action": {
"default_popup": "popup.html"
}
}
핵심은 host_permissions를 치지직 도메인으로 한정한 것이다. 불필요하게 넓은 권한을 요청하면 크롬 웹스토어 심사에서 거절당하기 십상이다. downloads 권한은 chrome.downloads.download() API를 사용하기 위해 필수다.
Content Script와 Background Script의 역할 분리
클라이언트 사이드에서 영상 URL을 추출하려면 현재 페이지의 DOM이나 네트워크 요청을 분석해야 한다. Content Script는 페이지의 컨텍스트에서 실행되므로 DOM 접근이 가능하지만, chrome.downloads 같은 특권 API는 사용할 수 없다.
// content.js - 페이지에서 영상 URL 추출
function extractVideoUrl() {
// 치지직은 HLS 스트리밍을 사용한다
// video 태그의 src 또는 페이지 내 스크립트에서 m3u8 URL 추출
const videoElement = document.querySelector('video');
// 직접적인 src가 없다면 페이지 스크립트 데이터 확인
const scripts = document.querySelectorAll('script');
let videoUrl = null;
scripts.forEach(script => {
const text = script.textContent;
// JSON 형태로 임베드된 영상 정보 파싱
const match = text.match(/"videoUrl"\s*:\s*"([^"]+)"/);
if (match) {
videoUrl = match[1];
}
});
return videoUrl;
}
// Background로 메시지 전송
chrome.runtime.sendMessage({
type: 'DOWNLOAD_VIDEO',
url: extractVideoUrl()
});
Background Script(Manifest V3에서는 Service Worker)에서는 실제 다운로드를 처리한다.
// background.js (Service Worker)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'DOWNLOAD_VIDEO' && message.url) {
chrome.downloads.download({
url: message.url,
filename: `chzzk_clip_${Date.now()}.mp4`,
saveAs: true
}, (downloadId) => {
if (chrome.runtime.lastError) {
console.error('다운로드 실패:', chrome.runtime.lastError);
}
});
}
});
HLS 스트리밍 처리의 현실
여기서 한 가지 불편한 진실을 얘기해야 한다. 대부분의 스트리밍 서비스는 직접 다운로드가 불가능한 HLS(HTTP Live Streaming) 형식을 사용한다. m3u8 플레이리스트 파일과 수십~수백 개의 ts 세그먼트로 쪼개져 있다.
순수 클라이언트 사이드에서 이걸 처리하려면 두 가지 선택지가 있다:
- m3u8 URL만 제공하고 외부 도구 사용 유도 - 사용자 경험이 좋지 않다
- 모든 세그먼트를 fetch해서 클라이언트에서 병합 - 구현은 복잡하지만 완전한 솔루션
// HLS 세그먼트 병합 (간략화된 버전)
async function downloadAndMergeHLS(m3u8Url) {
const response = await fetch(m3u8Url);
const playlist = await response.text();
// ts 세그먼트 URL 추출
const segments = playlist
.split('\n')
.filter(line => line.endsWith('.ts'))
.map(segment => new URL(segment, m3u8Url).href);
// 모든 세그먼트 다운로드
const chunks = await Promise.all(
segments.map(url => fetch(url).then(r => r.arrayBuffer()))
);
// Blob으로 병합
const blob = new Blob(chunks, { type: 'video/mp2t' });
// 다운로드 트리거
const downloadUrl = URL.createObjectURL(blob);
chrome.downloads.download({
url: downloadUrl,
filename: 'clip.ts'
});
}
솔직히 말하면, 이 방식은 메모리를 상당히 잡아먹는다. 10분짜리 클립이면 수백 MB가 브라우저 메모리에 올라간다. 프로덕션 레벨에서는 스트리밍 방식으로 처리하거나, IndexedDB를 중간 버퍼로 활용하는 게 맞다.
CORS와 싸우기
외부 API 없이 작동한다고 했지만, CORS는 여전히 발목을 잡는다. Content Script에서 직접 fetch하면 CORS 정책에 막힌다.
// 이건 실패한다
fetch('https://chzzk.naver.com/api/video/...')
.then(r => r.json()) // CORS 에러!
해결책은 Background Script에서 fetch하는 것이다. Service Worker는 CORS 제약을 받지 않는다(host_permissions에 명시된 도메인에 한해서).
// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'FETCH_VIDEO_INFO') {
fetch(message.url, {
credentials: 'include' // 쿠키 포함 (인증된 요청)
})
.then(r => r.json())
.then(data => sendResponse({ success: true, data }))
.catch(err => sendResponse({ success: false, error: err.message }));
return true; // 비동기 응답을 위해 필수
}
});
보안과 개인정보 보호 관점
외부 서버를 거치지 않는다는 건 사용자 입장에서 엄청난 장점이다. 다운로드 기록이 어딘가에 수집되지 않고, 서버 운영자가 마음먹으면 악성 코드를 주입할 위험도 없다.
하지만 익스텐션 자체의 보안도 신경 써야 한다:
// 절대 하지 말 것: eval 사용
eval(responseFromPage); // 취약점
// 대신 JSON.parse로 안전하게 파싱
try {
const data = JSON.parse(responseFromPage);
} catch (e) {
console.error('잘못된 데이터 형식');
}
실전 팁과 주의사항
1. 버전 호환성 체크 치지직이 프론트엔드를 업데이트하면 DOM 구조나 API 응답 형식이 바뀔 수 있다. 셀렉터를 하드코딩하면 하루아침에 익스텐션이 무용지물이 된다.
2. 속도 제한 준수 짧은 시간에 너무 많은 요청을 보내면 IP 차단을 당할 수 있다. 세그먼트 다운로드 시 적절한 딜레이를 주는 게 좋다.
3. 에러 핸들링 네트워크는 언제든 실패한다. 재시도 로직과 사용자에게 명확한 에러 메시지를 제공해야 한다.
마무리하며
크롬 익스텐션으로 영상 다운로더를 만드는 건 기술적으로 그리 어렵지 않다. 어려운 건 유지보수다. 서비스의 변경에 맞춰 계속 업데이트해야 하고, 크롬 웹스토어 정책 변경에도 대응해야 한다.
외부 API 없이 순수 클라이언트 사이드로 구현하는 접근법은 개인정보 보호 측면에서 옳은 방향이라고 생각한다. 다만 HLS 처리나 대용량 파일 핸들링에서 브라우저의 한계를 만나게 되는데, 이건 트레이드오프로 받아들여야 한다. 완벽한 솔루션은 없고, 있다면 그건 거짓말이다.
Building a Chzzk Clip Downloader Chrome Extension: Pure Client-Side Implementation Without External APIs
When building a video downloader as a browser extension, the first approach that comes to mind is routing through an external server to call APIs. But this method carries three simultaneous problems: server maintenance costs, privacy concerns, and service dependency. The Chzzk clip downloader chose to avoid all of these by operating purely on the client side. No external page redirects, no API calls. Everything happens in the browser.
Chrome Extension Architecture and Permission Model
Anyone who’s built a Chrome extension knows that manifest.json is where everything begins. With the transition to Manifest V3, the permission system became considerably more strict, and you need to understand this properly when implementing sensitive features like video downloaders.
{
"manifest_version": 3,
"name": "Chzzk Clip Downloader",
"version": "1.0.0",
"permissions": [
"activeTab",
"downloads"
],
"host_permissions": [
"https://chzzk.naver.com/*"
],
"content_scripts": [
{
"matches": ["https://chzzk.naver.com/clips/*"],
"js": ["content.js"]
}
],
"action": {
"default_popup": "popup.html"
}
}
The key point is limiting host_permissions to the Chzzk domain. Request unnecessarily broad permissions and you’ll likely get rejected in Chrome Web Store review. The downloads permission is required to use the chrome.downloads.download() API.
Separating Roles: Content Script vs Background Script
To extract video URLs on the client side, you need to analyze the current page’s DOM or network requests. Content Scripts run in the page’s context so they can access the DOM, but they cannot use privileged APIs like chrome.downloads.
// content.js - Extract video URL from page
function extractVideoUrl() {
// Chzzk uses HLS streaming
// Extract m3u8 URL from video tag src or embedded page scripts
const videoElement = document.querySelector('video');
// If no direct src, check page script data
const scripts = document.querySelectorAll('script');
let videoUrl = null;
scripts.forEach(script => {
const text = script.textContent;
// Parse video info embedded as JSON
const match = text.match(/"videoUrl"\s*:\s*"([^"]+)"/);
if (match) {
videoUrl = match[1];
}
});
return videoUrl;
}
// Send message to Background
chrome.runtime.sendMessage({
type: 'DOWNLOAD_VIDEO',
url: extractVideoUrl()
});
The Background Script (Service Worker in Manifest V3) handles the actual download.
// background.js (Service Worker)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'DOWNLOAD_VIDEO' && message.url) {
chrome.downloads.download({
url: message.url,
filename: `chzzk_clip_${Date.now()}.mp4`,
saveAs: true
}, (downloadId) => {
if (chrome.runtime.lastError) {
console.error('Download failed:', chrome.runtime.lastError);
}
});
}
});
The Reality of HLS Streaming
Here’s an inconvenient truth. Most streaming services use HLS (HTTP Live Streaming) format that doesn’t allow direct downloads. It’s split into m3u8 playlist files and dozens to hundreds of ts segments.
To handle this purely on the client side, you have two options:
- Provide only the m3u8 URL and guide users to external tools - Poor user experience
- Fetch all segments and merge on the client - Complex implementation but complete solution
// HLS segment merging (simplified version)
async function downloadAndMergeHLS(m3u8Url) {
const response = await fetch(m3u8Url);
const playlist = await response.text();
// Extract ts segment URLs
const segments = playlist
.split('\n')
.filter(line => line.endsWith('.ts'))
.map(segment => new URL(segment, m3u8Url).href);
// Download all segments
const chunks = await Promise.all(
segments.map(url => fetch(url).then(r => r.arrayBuffer()))
);
// Merge into Blob
const blob = new Blob(chunks, { type: 'video/mp2t' });
// Trigger download
const downloadUrl = URL.createObjectURL(blob);
chrome.downloads.download({
url: downloadUrl,
filename: 'clip.ts'
});
}
Honestly, this approach is memory-hungry. A 10-minute clip means hundreds of MB loaded into browser memory. For production level, you should either process it as a stream or use IndexedDB as an intermediate buffer.
Fighting CORS
I said it works without external APIs, but CORS still trips you up. Direct fetch from Content Script gets blocked by CORS policy.
// This fails
fetch('https://chzzk.naver.com/api/video/...')
.then(r => r.json()) // CORS error!
The solution is fetching from the Background Script. Service Workers aren’t subject to CORS restrictions (for domains specified in host_permissions).
// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'FETCH_VIDEO_INFO') {
fetch(message.url, {
credentials: 'include' // Include cookies (authenticated request)
})
.then(r => r.json())
.then(data => sendResponse({ success: true, data }))
.catch(err => sendResponse({ success: false, error: err.message }));
return true; // Required for async response
}
});
Security and Privacy Perspective
Not going through an external server is a huge advantage for users. Download history isn’t collected somewhere, and there’s no risk of the server operator injecting malicious code.
But you also need to mind the extension’s own security:
// Never do this: using eval
eval(responseFromPage); // Vulnerability
// Instead, parse safely with JSON.parse
try {
const data = JSON.parse(responseFromPage);
} catch (e) {
console.error('Invalid data format');
}
Practical Tips and Gotchas
1. Version Compatibility Checks When Chzzk updates their frontend, DOM structure or API response formats can change. Hardcode selectors and your extension becomes useless overnight.
2. Respect Rate Limits Send too many requests in a short time and you risk IP bans. Add appropriate delays when downloading segments.
3. Error Handling Networks fail. Always. Implement retry logic and provide clear error messages to users.
Wrapping Up
Building a video downloader as a Chrome extension isn’t technically that difficult. What’s difficult is maintenance. You need to keep updating with service changes and respond to Chrome Web Store policy changes.
The pure client-side approach without external APIs is the right direction from a privacy protection standpoint. However, you’ll hit browser limitations with HLS processing and large file handling—that’s a trade-off you have to accept. There’s no perfect solution, and if someone claims there is, they’re lying.
댓글남기기