// 치지직 라이브 응답을 가로채 타임머신 되감기 UI 를 강제로 노출시키는 cosmetic 패치. // // 동작 원리 // - 치지직 웹 플레이어는 `api.chzzk.naver.com/service/v3.2/channels/{id}/live-detail` // 응답의 `content.timeMachineActive` / `content.timeMachinePlayback` 두 플래그와, // 같은 응답에 박혀 있는 `content.livePlaybackJson` (JSON 문자열) 안의 // `meta.liveRewind`, `meta.duration`, `live.timeMachine` 필드 조합으로 // 되감기(seek) UI 노출 여부를 결정한다. // - 따라서 `live-detail` 응답을 가로채서 (1) 두 플래그를 true 로 만들고, // (2) 내부 `livePlaybackJson` 의 meta/live 를 보강해 다시 직렬화하면 플레이어가 // seek 바 UI 를 그리게 된다. // // 한계 (중요) // - 이건 UI 만 켜는 cosmetic 패치다. 실제 DVR window 자체는 CDN 이 스트리머의 // 타임머신 설정에 따라 서버-사이드에서만 프로비저닝하므로, 스트리머가 꺼둔 // 라이브에서는 과거 segment 가 존재하지 않아 실제 seek 은 동작하지 않을 가능성이 // 매우 높다. 자세한 내용은 README 참고. // - 별도로 `live-playback-json` 엔드포인트를 fetch 해서 매니페스트를 갈아끼우는 // 로직은 두지 않는다. 해당 엔드포인트는 스트리머가 타임머신을 켜둔 채널에서만 // 200 을 돌려주고, 꺼둔 채널에서는 404 라 의미가 없다. // // 치지직 React 앱은 axios 기반이라 실제 요청은 `XMLHttpRequest` 로 나가고, 일부 경로는 // `fetch` 도 쓴다. 그래서 두 경로 모두 후킹한다. // manifest 의 `world: "MAIN"`, `run_at: "document_start"` 덕분에 페이지 자체 스크립트가 // 실행되기 전에 후킹할 수 있다. (function () { 'use strict'; const TAG = '[chzzk-bypass:timemachine]'; // 치지직은 `service/v3.2/...` 처럼 minor 가 붙은 버전을 쓴다. 점(.) 포함 허용. const LIVE_DETAIL_RE = /^https:\/\/api\.chzzk\.naver\.com\/service\/v[\d.]+\/channels\/([^\/?#]+)\/live-detail/; // localStorage 'chzzk-bypass:debug' = '1' 로 켜면 응답 본문을 콘솔에 덤프해 디버깅한다. // (기본은 off. 일반 사용자 콘솔이 더러워지지 않도록.) function isDebug() { try { return localStorage.getItem('chzzk-bypass:debug') === '1'; } catch (_) { return false; } } function log() { try { // eslint-disable-next-line no-console console.log.apply(console, [TAG].concat(Array.prototype.slice.call(arguments))); } catch (_) {} } // 원본 fetch 를 캡처. 이후 후킹된 fetch 가 재귀 호출되지 않도록 내부 호출도 이걸 쓴다. const originalFetch = window.fetch.bind(window); function urlOf(input) { try { if (typeof input === 'string') return input; if (!input) return ''; // Request 객체 if (typeof input.url === 'string') return input.url; // URL 객체 if (typeof input.href === 'string') return input.href; return String(input); } catch (_) { return ''; } } // 별도의 live-playback-json 엔드포인트 fetch 는 이전 버전에서 시도했지만 // 모든 버전이 CORS+404 로 막혀 더 이상 호출하지 않는다. (실측 확인) // 매니페스트 URL 갈아끼우기는 별도 메커니즘이 필요하며, 현재 조사 중. // 파싱된 live-detail JSON 객체를 in-place 로 패치. 변경 여부 반환. async function patchLiveDetailData(data, channelIdFromUrl) { const content = data && data.content; if (!content) return false; // 치지직 live-detail 응답에는 두 개의 별개 플래그가 존재한다. // - timeMachineActive : 채널/방송 단위 타임머신 활성 여부 // - timeMachinePlayback : 플레이어가 실제 되감기 UI 를 켤지 결정하는 플래그 // 둘 중 어느 하나라도 false 면 UI 가 안 뜨므로 둘 다 true 로 만든다. // (참고: github.com/jaesung9507/nvver chzzk/live.go LiveDetail 구조체) const alreadyOn = content.timeMachineActive === true && content.timeMachinePlayback === true; const channelId = (content.channel && content.channel.channelId) || channelIdFromUrl; if (alreadyOn) { log('timeMachine already active for', channelId, '— passthrough'); return false; } log('forcing timeMachine ON for', channelId); content.timeMachineActive = true; content.timeMachinePlayback = true; // 내부 livePlaybackJson 의 meta/live 도 같이 패치한다. 플레이어는 외부 boolean // 두 개만 보는 게 아니라, livePlaybackJson 을 파싱해 meta.liveRewind / meta.duration / // live.timeMachine 을 확인해서 seekbar UI 여부를 결정한다. // (참고: jaesung9507/nvver playback.go 의 Meta/Live 구조체. liveRewind/duration 은 // omitempty 라서 DVR ON 일 때만 존재함) // // 한계: 이건 어디까지나 UI 만 켜는 cosmetic 패치다. CDN 의 DVR window 는 스트리머가 // 타임머신을 켰을 때만 서버가 프로비저닝하므로, 실제 segment 가 과거 시점에 존재하지 // 않아 seek 자체는 동작하지 않을 수 있다 (재생바가 떠도 누르면 live edge 로 튕김). try { const raw = content.livePlaybackJson; const pb = typeof raw === 'string' ? JSON.parse(raw) : raw; if (pb && typeof pb === 'object') { pb.meta = pb.meta || {}; pb.meta.liveRewind = true; if (typeof pb.meta.duration !== 'number' || pb.meta.duration <= 0) { pb.meta.duration = 3600; // 1h. 진짜 길이는 CDN 이 정함. } pb.live = pb.live || {}; pb.live.timeMachine = true; content.livePlaybackJson = typeof raw === 'string' ? JSON.stringify(pb) : pb; log('inner livePlaybackJson meta/live patched (liveRewind, duration, timeMachine)'); } } catch (e) { log('inner livePlaybackJson patch failed', e); } // 진단용: live-detail content 와 livePlaybackJson 디코딩 결과를 통째로 덤프. // 플레이어가 어느 필드를 보는지 모를 때 켜서 들여다보는 용도. // 활성화: devtools 콘솔에서 `localStorage.setItem('chzzk-bypass:debug','1')` 후 새로고침. if (isDebug()) { try { const dump = JSON.parse(JSON.stringify(content)); if (typeof dump.livePlaybackJson === 'string') { try { dump.__livePlaybackJsonParsed = JSON.parse(dump.livePlaybackJson); } catch (_) {} } log('DEBUG live-detail content after patch:', dump); } catch (e) { log('debug dump failed', e); } } return true; } // -------- fetch hook -------- window.fetch = async function patchedFetch(input, init) { const url = urlOf(input); const match = url && url.match(LIVE_DETAIL_RE); if (!match) return originalFetch(input, init); const response = await originalFetch(input, init); try { const data = await response.clone().json(); const changed = await patchLiveDetailData(data, match[1]); if (!changed) return response; const newBody = JSON.stringify(data); const headers = new Headers(response.headers); headers.set('content-length', String(new Blob([newBody]).size)); return new Response(newBody, { status: response.status, statusText: response.statusText, headers, }); } catch (e) { log('fetch patch failed, returning original', e); return response; } }; // -------- XHR hook -------- // 치지직 React 앱은 axios 를 쓰므로 live-detail 은 XHR 로 나간다. fetch 만 후킹하면 // 아무 일도 일어나지 않는다 (실제 콘솔 로그에서 확인). // // 전략: live-detail URL 이면 원본 XHR.send 는 호출하지 않고, 내부적으로 fetch 로 같은 // 요청을 직접 날려 응답을 받아 패치한 뒤, defineProperty 로 XHR 의 응답 관련 속성을 // 덮어쓰고 readystatechange/load/loadend 이벤트를 합성 발화한다. const XHRProto = XMLHttpRequest.prototype; const origOpen = XHRProto.open; const origSend = XHRProto.send; const origSetRequestHeader = XHRProto.setRequestHeader; XHRProto.open = function patchedOpen(method, url) { try { this.__cb_url = typeof url === 'string' ? url : (url && url.toString ? url.toString() : ''); this.__cb_method = method; this.__cb_headers = {}; this.__cb_intercept = false; if (this.__cb_url) { const m = this.__cb_url.match(LIVE_DETAIL_RE); if (m) { this.__cb_intercept = true; this.__cb_channel = m[1]; } } } catch (_) {} return origOpen.apply(this, arguments); }; XHRProto.setRequestHeader = function patchedSetRequestHeader(name, value) { try { if (this.__cb_intercept) { this.__cb_headers[name] = value; } } catch (_) {} return origSetRequestHeader.apply(this, arguments); }; function setProp(obj, key, value) { try { Object.defineProperty(obj, key, { configurable: true, writable: true, value }); } catch (_) {} } function synthesizeXHRResponse(xhr, fetchResp, text) { // 응답 본문/메타 덮어쓰기. readyState/status 등은 prototype 의 getter 라서 // instance 에 data property 로 정의하면 shadow 된다. setProp(xhr, 'readyState', 4); setProp(xhr, 'status', fetchResp.status); setProp(xhr, 'statusText', fetchResp.statusText || ''); setProp(xhr, 'responseText', text); setProp(xhr, 'responseURL', xhr.__cb_url); const rt = xhr.responseType || ''; let response; if (rt === '' || rt === 'text') { response = text; } else if (rt === 'json') { try { response = JSON.parse(text); } catch (_) { response = null; } } else if (rt === 'arraybuffer') { response = new TextEncoder().encode(text).buffer; } else if (rt === 'blob') { response = new Blob([text], { type: fetchResp.headers.get('content-type') || 'application/json' }); } else { response = text; } setProp(xhr, 'response', response); // 응답 헤더 메서드 const headerLines = []; fetchResp.headers.forEach((v, k) => headerLines.push(k + ': ' + v)); const headerBlob = headerLines.join('\r\n'); setProp(xhr, 'getAllResponseHeaders', function () { return headerBlob; }); setProp(xhr, 'getResponseHeader', function (name) { return fetchResp.headers.get(name); }); } function dispatchXHREvents(xhr) { try { xhr.dispatchEvent(new Event('readystatechange')); } catch (_) {} try { xhr.dispatchEvent(new ProgressEvent('load')); } catch (_) {} try { xhr.dispatchEvent(new ProgressEvent('loadend')); } catch (_) {} } XHRProto.send = function patchedSend(body) { if (!this.__cb_intercept) { return origSend.apply(this, arguments); } const xhr = this; const channelId = xhr.__cb_channel; log('XHR live-detail intercepted for', channelId); (async function () { let resp; let text = ''; try { resp = await originalFetch(xhr.__cb_url, { method: xhr.__cb_method || 'GET', headers: xhr.__cb_headers || {}, credentials: 'include', body: body && xhr.__cb_method && xhr.__cb_method.toUpperCase() !== 'GET' ? body : undefined, }); text = await resp.text(); } catch (e) { log('XHR underlying fetch failed', e); try { xhr.dispatchEvent(new ProgressEvent('error')); } catch (_) {} try { xhr.dispatchEvent(new ProgressEvent('loadend')); } catch (_) {} return; } try { let data = null; try { data = JSON.parse(text); } catch (_) {} if (data) { const changed = await patchLiveDetailData(data, channelId); if (changed) text = JSON.stringify(data); } } catch (e) { log('XHR patch step failed, returning original body', e); } synthesizeXHRResponse(xhr, resp, text); dispatchXHREvents(xhr); })(); }; log('hooks installed (fetch + XHR)'); })();