XHR 후킹 추가: 치지직이 axios 로 부르는 live-detail 도 가로채도록 수정
문제: v1.1.0 의 fetch 후킹은 작동(`fetch hook installed` 로그 확인)했지만 실제 라이브 페이지에서 `forcing timeMachine ON` 로그가 한 번도 안 떴음. 콘솔 스택트레이스에 `XMLHttpRequest.send` 와 `xhr @ main.d2cbcc55.js` 가 반복 등장해 치지직 React 앱이 axios over XHR 로 live-detail 을 호출하는 것이 확인됨 → fetch 만 후킹한 v1.1.0 은 무용지물이었음. 수정: - timemachine.js: XMLHttpRequest 의 open/setRequestHeader/send 를 후킹. live-detail URL 이면 native send 대신 우리가 fetch 로 직접 요청을 날리고, 응답을 patchLiveDetailData 로 패치한 뒤 defineProperty 로 readyState/status/responseText/response/responseURL 등을 덮어쓰고 readystatechange/load/loadend 이벤트를 합성 발화. responseType (text/json/arraybuffer/blob) 별 response 값도 맞춰 만든다. - fetch 후킹도 유지 (혹시 일부 경로가 fetch 쓸 수 있음). - 로그 메시지를 `hooks installed (fetch + XHR)` 로 변경하고 XHR 진입 지점에 `XHR live-detail intercepted for <channelId>` 진단 로그 추가. - urlOf() 헬퍼로 string/Request/URL 입력을 일관 처리. - manifest 버전 1.1.0 → 1.1.1. - README 동작 방식 설명/확인 로그 갱신.
This commit is contained in:
12
README.md
12
README.md
@@ -20,10 +20,11 @@
|
|||||||
|
|
||||||
동작 방식:
|
동작 방식:
|
||||||
|
|
||||||
1. `api.chzzk.naver.com/service/vN/channels/{id}/live-detail` 응답을 `fetch`
|
1. `api.chzzk.naver.com/service/v3.2/channels/{id}/live-detail` 응답을
|
||||||
후킹으로 가로챕니다.
|
`window.fetch` 와 `XMLHttpRequest` 양쪽에서 가로챕니다. 치지직 React 앱은
|
||||||
2. `content.timeMachineActive` 플래그를 강제로 `true` 로 만듭니다 → 플레이어
|
axios 기반이라 실제 요청이 XHR 로 나가므로 XHR 후킹이 필수입니다.
|
||||||
UI 가 되감기 바를 노출합니다.
|
2. 응답 본문의 `content.timeMachineActive` 와 `content.timeMachinePlayback`
|
||||||
|
두 플래그를 강제로 `true` 로 만듭니다 → 플레이어 UI 가 되감기 바를 노출합니다.
|
||||||
3. 같은 채널의 `…/channels/{id}/live-playback-json` 을 호출해 DVR 매니페스트
|
3. 같은 채널의 `…/channels/{id}/live-playback-json` 을 호출해 DVR 매니페스트
|
||||||
문자열을 받아 `content.livePlaybackJson` 을 교체합니다 → 매니페스트가 실제
|
문자열을 받아 `content.livePlaybackJson` 을 교체합니다 → 매니페스트가 실제
|
||||||
seek 을 지원하게 됩니다.
|
seek 을 지원하게 됩니다.
|
||||||
@@ -60,7 +61,8 @@ icons/ 확장 아이콘
|
|||||||
|
|
||||||
브라우저 devtools 콘솔에서 다음 로그를 확인할 수 있습니다.
|
브라우저 devtools 콘솔에서 다음 로그를 확인할 수 있습니다.
|
||||||
|
|
||||||
- `[chzzk-bypass:timemachine] fetch hook installed` — 후킹 성공
|
- `[chzzk-bypass:timemachine] hooks installed (fetch + XHR)` — 후킹 성공
|
||||||
|
- `[chzzk-bypass:timemachine] XHR live-detail intercepted for <channelId>` — XHR 가로채기 진입
|
||||||
- `[chzzk-bypass:timemachine] forcing timeMachine ON for <channelId>` — 패치 적용
|
- `[chzzk-bypass:timemachine] forcing timeMachine ON for <channelId>` — 패치 적용
|
||||||
- `[chzzk-bypass:timemachine] livePlaybackJson swapped for DVR manifest` — 매니페스트 교체 성공
|
- `[chzzk-bypass:timemachine] livePlaybackJson swapped for DVR manifest` — 매니페스트 교체 성공
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Chzzk Bypass",
|
"name": "Chzzk Bypass",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"description": "치지직(CHZZK) 시청 환경 개선: 1) Mac 위장으로 그리드 없이 1080p 시청, 2) 스트리머가 타임머신을 꺼둔 라이브에서도 되감기 UI 강제 표시.",
|
"description": "치지직(CHZZK) 시청 환경 개선: 1) Mac 위장으로 그리드 없이 1080p 시청, 2) 스트리머가 타임머신을 꺼둔 라이브에서도 되감기 UI 강제 표시.",
|
||||||
"icons": {
|
"icons": {
|
||||||
|
|||||||
210
timemachine.js
210
timemachine.js
@@ -3,17 +3,20 @@
|
|||||||
// 가능하면 DVR 매니페스트(URL) 까지 함께 갈아끼워 실제 되감기도 동작하도록 만든다.
|
// 가능하면 DVR 매니페스트(URL) 까지 함께 갈아끼워 실제 되감기도 동작하도록 만든다.
|
||||||
//
|
//
|
||||||
// 동작 원리
|
// 동작 원리
|
||||||
// - 치지직 웹 플레이어는 `api.chzzk.naver.com/service/vN/channels/{id}/live-detail`
|
// - 치지직 웹 플레이어는 `api.chzzk.naver.com/service/v3.2/channels/{id}/live-detail`
|
||||||
// 응답의 `content.timeMachineActive` 플래그로 되감기 UI 노출 여부를 결정한다.
|
// 응답의 `content.timeMachineActive` / `content.timeMachinePlayback` 플래그 두 개로
|
||||||
|
// 되감기 UI 노출 여부를 결정한다.
|
||||||
// - 같은 채널의 `live-playback-json` 엔드포인트는 DVR 가능한 HLS 매니페스트 URL 을
|
// - 같은 채널의 `live-playback-json` 엔드포인트는 DVR 가능한 HLS 매니페스트 URL 을
|
||||||
// 항상 돌려준다. ChzzkDownloader 의 `--stream force-timemachine` 옵션이 쓰는
|
// 항상 돌려준다. ChzzkDownloader 의 `--stream force-timemachine` 옵션이 쓰는
|
||||||
// 바로 그 엔드포인트다.
|
// 바로 그 엔드포인트다.
|
||||||
// - 따라서 `live-detail` 응답을 가로채서 (1) 플래그를 강제로 true 로 만들고,
|
// - 따라서 `live-detail` 응답을 가로채서 (1) 두 플래그를 강제로 true 로 만들고,
|
||||||
// (2) `livePlaybackJson` 안의 매니페스트 URL 을 `live-playback-json` 응답으로
|
// (2) `livePlaybackJson` 안의 매니페스트 URL 을 `live-playback-json` 응답으로
|
||||||
// 교체하면, 스트리머 설정과 무관하게 되감기 UI 와 실제 seek 동작 모두 살아난다.
|
// 교체하면, 스트리머 설정과 무관하게 되감기 UI 와 실제 seek 동작 모두 살아난다.
|
||||||
//
|
//
|
||||||
|
// 치지직 React 앱은 axios 기반이라 실제 요청은 `XMLHttpRequest` 로 나가고, 일부 경로는
|
||||||
|
// `fetch` 도 쓴다. 그래서 두 경로 모두 후킹한다.
|
||||||
// manifest 의 `world: "MAIN"`, `run_at: "document_start"` 덕분에 페이지 자체 스크립트가
|
// manifest 의 `world: "MAIN"`, `run_at: "document_start"` 덕분에 페이지 자체 스크립트가
|
||||||
// 실행되기 전에 fetch 를 후킹할 수 있다.
|
// 실행되기 전에 후킹할 수 있다.
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -32,8 +35,21 @@
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 원본 fetch 를 캡처. 이후 후킹된 fetch 가 재귀 호출되지 않도록 내부 호출도 이걸 쓴다.
|
||||||
const originalFetch = window.fetch.bind(window);
|
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) {
|
async function fetchPlaybackJson(channelId) {
|
||||||
for (const ver of PLAYBACK_JSON_VERSIONS) {
|
for (const ver of PLAYBACK_JSON_VERSIONS) {
|
||||||
try {
|
try {
|
||||||
@@ -67,17 +83,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patchLiveDetail(response, channelIdFromUrl) {
|
// 파싱된 live-detail JSON 객체를 in-place 로 패치. 변경 여부 반환.
|
||||||
let data;
|
async function patchLiveDetailData(data, channelIdFromUrl) {
|
||||||
try {
|
|
||||||
data = await response.clone().json();
|
|
||||||
} catch (e) {
|
|
||||||
log('response is not JSON, passthrough');
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = data && data.content;
|
const content = data && data.content;
|
||||||
if (!content) return response;
|
if (!content) return false;
|
||||||
|
|
||||||
// 치지직 live-detail 응답에는 두 개의 별개 플래그가 존재한다.
|
// 치지직 live-detail 응답에는 두 개의 별개 플래그가 존재한다.
|
||||||
// - timeMachineActive : 채널/방송 단위 타임머신 활성 여부
|
// - timeMachineActive : 채널/방송 단위 타임머신 활성 여부
|
||||||
@@ -89,7 +98,7 @@
|
|||||||
|
|
||||||
if (alreadyOn) {
|
if (alreadyOn) {
|
||||||
log('timeMachine already active for', channelId, '— passthrough');
|
log('timeMachine already active for', channelId, '— passthrough');
|
||||||
return response;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log('forcing timeMachine ON for', channelId);
|
log('forcing timeMachine ON for', channelId);
|
||||||
@@ -106,44 +115,159 @@
|
|||||||
log('playback-json unavailable; UI shown but seek may not work');
|
log('playback-json unavailable; UI shown but seek may not work');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------- fetch hook --------
|
||||||
window.fetch = async function patchedFetch(input, init) {
|
window.fetch = async function patchedFetch(input, init) {
|
||||||
let url = '';
|
const url = urlOf(input);
|
||||||
try {
|
|
||||||
url = typeof input === 'string'
|
|
||||||
? input
|
|
||||||
: (input && input.url) || '';
|
|
||||||
} catch (_) { url = ''; }
|
|
||||||
|
|
||||||
const match = url && url.match(LIVE_DETAIL_RE);
|
const match = url && url.match(LIVE_DETAIL_RE);
|
||||||
if (!match) {
|
if (!match) return originalFetch(input, init);
|
||||||
return originalFetch(input, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response;
|
const response = await originalFetch(input, init);
|
||||||
try {
|
try {
|
||||||
response = await originalFetch(input, init);
|
const data = await response.clone().json();
|
||||||
} catch (e) {
|
const changed = await patchLiveDetailData(data, match[1]);
|
||||||
throw e;
|
if (!changed) return response;
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const newBody = JSON.stringify(data);
|
||||||
return await patchLiveDetail(response, match[1]);
|
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) {
|
} catch (e) {
|
||||||
log('patch failed, returning original response', e);
|
log('fetch patch failed, returning original', e);
|
||||||
return response;
|
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)');
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user