diff --git a/README.md b/README.md index 5b1b1d6..f96251a 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,11 @@ 동작 방식: -1. `api.chzzk.naver.com/service/vN/channels/{id}/live-detail` 응답을 `fetch` - 후킹으로 가로챕니다. -2. `content.timeMachineActive` 플래그를 강제로 `true` 로 만듭니다 → 플레이어 - UI 가 되감기 바를 노출합니다. +1. `api.chzzk.naver.com/service/v3.2/channels/{id}/live-detail` 응답을 + `window.fetch` 와 `XMLHttpRequest` 양쪽에서 가로챕니다. 치지직 React 앱은 + axios 기반이라 실제 요청이 XHR 로 나가므로 XHR 후킹이 필수입니다. +2. 응답 본문의 `content.timeMachineActive` 와 `content.timeMachinePlayback` + 두 플래그를 강제로 `true` 로 만듭니다 → 플레이어 UI 가 되감기 바를 노출합니다. 3. 같은 채널의 `…/channels/{id}/live-playback-json` 을 호출해 DVR 매니페스트 문자열을 받아 `content.livePlaybackJson` 을 교체합니다 → 매니페스트가 실제 seek 을 지원하게 됩니다. @@ -60,7 +61,8 @@ icons/ 확장 아이콘 브라우저 devtools 콘솔에서 다음 로그를 확인할 수 있습니다. -- `[chzzk-bypass:timemachine] fetch hook installed` — 후킹 성공 +- `[chzzk-bypass:timemachine] hooks installed (fetch + XHR)` — 후킹 성공 +- `[chzzk-bypass:timemachine] XHR live-detail intercepted for ` — XHR 가로채기 진입 - `[chzzk-bypass:timemachine] forcing timeMachine ON for ` — 패치 적용 - `[chzzk-bypass:timemachine] livePlaybackJson swapped for DVR manifest` — 매니페스트 교체 성공 diff --git a/manifest.json b/manifest.json index f67dfa1..a2487a7 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "Chzzk Bypass", - "version": "1.1.0", + "version": "1.1.1", "manifest_version": 3, "description": "치지직(CHZZK) 시청 환경 개선: 1) Mac 위장으로 그리드 없이 1080p 시청, 2) 스트리머가 타임머신을 꺼둔 라이브에서도 되감기 UI 강제 표시.", "icons": { diff --git a/timemachine.js b/timemachine.js index feddd8b..79f9129 100644 --- a/timemachine.js +++ b/timemachine.js @@ -3,17 +3,20 @@ // 가능하면 DVR 매니페스트(URL) 까지 함께 갈아끼워 실제 되감기도 동작하도록 만든다. // // 동작 원리 -// - 치지직 웹 플레이어는 `api.chzzk.naver.com/service/vN/channels/{id}/live-detail` -// 응답의 `content.timeMachineActive` 플래그로 되감기 UI 노출 여부를 결정한다. +// - 치지직 웹 플레이어는 `api.chzzk.naver.com/service/v3.2/channels/{id}/live-detail` +// 응답의 `content.timeMachineActive` / `content.timeMachinePlayback` 플래그 두 개로 +// 되감기 UI 노출 여부를 결정한다. // - 같은 채널의 `live-playback-json` 엔드포인트는 DVR 가능한 HLS 매니페스트 URL 을 // 항상 돌려준다. ChzzkDownloader 의 `--stream force-timemachine` 옵션이 쓰는 // 바로 그 엔드포인트다. -// - 따라서 `live-detail` 응답을 가로채서 (1) 플래그를 강제로 true 로 만들고, +// - 따라서 `live-detail` 응답을 가로채서 (1) 두 플래그를 강제로 true 로 만들고, // (2) `livePlaybackJson` 안의 매니페스트 URL 을 `live-playback-json` 응답으로 // 교체하면, 스트리머 설정과 무관하게 되감기 UI 와 실제 seek 동작 모두 살아난다. // +// 치지직 React 앱은 axios 기반이라 실제 요청은 `XMLHttpRequest` 로 나가고, 일부 경로는 +// `fetch` 도 쓴다. 그래서 두 경로 모두 후킹한다. // manifest 의 `world: "MAIN"`, `run_at: "document_start"` 덕분에 페이지 자체 스크립트가 -// 실행되기 전에 fetch 를 후킹할 수 있다. +// 실행되기 전에 후킹할 수 있다. (function () { 'use strict'; @@ -32,8 +35,21 @@ } 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 ''; } + } + async function fetchPlaybackJson(channelId) { for (const ver of PLAYBACK_JSON_VERSIONS) { try { @@ -67,17 +83,10 @@ } } - async function patchLiveDetail(response, channelIdFromUrl) { - let data; - try { - data = await response.clone().json(); - } catch (e) { - log('response is not JSON, passthrough'); - return response; - } - + // 파싱된 live-detail JSON 객체를 in-place 로 패치. 변경 여부 반환. + async function patchLiveDetailData(data, channelIdFromUrl) { const content = data && data.content; - if (!content) return response; + if (!content) return false; // 치지직 live-detail 응답에는 두 개의 별개 플래그가 존재한다. // - timeMachineActive : 채널/방송 단위 타임머신 활성 여부 @@ -89,7 +98,7 @@ if (alreadyOn) { log('timeMachine already active for', channelId, '— passthrough'); - return response; + return false; } log('forcing timeMachine ON for', channelId); @@ -106,44 +115,159 @@ log('playback-json unavailable; UI shown but seek may not work'); } } - - 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, - }); + return true; } + // -------- fetch hook -------- window.fetch = async function patchedFetch(input, init) { - let url = ''; - try { - url = typeof input === 'string' - ? input - : (input && input.url) || ''; - } catch (_) { url = ''; } - + const url = urlOf(input); const match = url && url.match(LIVE_DETAIL_RE); - if (!match) { - return originalFetch(input, init); - } + if (!match) return originalFetch(input, init); - let response; + const response = await originalFetch(input, init); try { - response = await originalFetch(input, init); - } catch (e) { - throw e; - } + const data = await response.clone().json(); + const changed = await patchLiveDetailData(data, match[1]); + if (!changed) return response; - try { - return await patchLiveDetail(response, match[1]); + 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('patch failed, returning original response', e); + log('fetch patch failed, returning original', e); return response; } }; - log('fetch hook installed'); + // -------- 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)'); })();