v1.2.0: 타임머신 강제 활성화 기능 제거 + nlog.js getHighEntropyValues 오류 수정
- timemachine.js 제거 및 manifest content_scripts 에서 빼냄 스트리머가 끈 타임머신은 서버 측 DVR 프로비저닝이 없어 cosmetic 우회만 가능했고, 클라이언트-사이드 상태 불일치가 치지직 비정상 접근 감지를 트리거해 "허용되지 않는 비정상적 접근입니다" 팝업 + 계정 제재 위험이 실측으로 확인되어 제거. - content.js 의 navigator.userAgentData override 에 getHighEntropyValues / toJSON 까지 일관된 Mac 값으로 구현. 기존엔 메서드가 없어서 nlog.js 가 Uncaught TypeError: e.getHighEntropyValues is not a function 을 던졌음. - popup.html 의 "타임머신: 강제 활성화" 상태 항목 제거. - README 를 현재 동작 (그리드 우회만) 에 맞춰 재작성, 타임머신 미지원 사유 명시. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
81
README.md
81
README.md
@@ -4,62 +4,40 @@
|
||||
|
||||
## 기능
|
||||
|
||||
### 1. 그리드 우회 (1080p 시청)
|
||||
### 그리드 우회 (1080p 시청)
|
||||
|
||||
`navigator.userAgent`, `navigator.platform`, `navigator.userAgentData` 를 Mac 으로
|
||||
위장해, 치지직이 윈도우에서 1080p 시청 시 요구하는 그리드(Grid) 설치 없이 1080p
|
||||
화질을 시청할 수 있게 합니다.
|
||||
|
||||
`userAgentData` 는 단순히 `brands` / `mobile` / `platform` 만 갈아끼우면
|
||||
치지직이 로드하는 `nlog.js` (네이버 핑거프린팅 / 로깅) 가
|
||||
`e.getHighEntropyValues is not a function` 으로 Uncaught TypeError 를 던집니다.
|
||||
이 자체가 비정상 접근 시그널이 될 수 있어서, `getHighEntropyValues` 메서드도
|
||||
일관된 Mac 값으로 채워 둡니다. (manifest 의 `world: MAIN` + `run_at: document_start`
|
||||
덕분에 페이지 스크립트보다 먼저 적용됩니다.)
|
||||
|
||||
구현: `content.js`
|
||||
|
||||
### 2. 타임머신 강제 활성화 (되감기 UI, cosmetic)
|
||||
## 타임머신 강제 활성화는 지원하지 않습니다
|
||||
|
||||
스트리머가 타임머신을 끄고 방송 중인 라이브에서도 플레이어의 되감기 (seek) 바
|
||||
UI 를 띄우려 시도합니다.
|
||||
이전 버전(`1.1.x`)에는 스트리머가 타임머신을 꺼둔 라이브에서도 되감기 바 UI 를
|
||||
띄우는 기능이 있었습니다. **`1.2.0` 부터는 제거되었습니다.**
|
||||
|
||||
동작 방식:
|
||||
이유:
|
||||
|
||||
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` 로 만듭니다.
|
||||
3. 내부의 `livePlaybackJson` (JSON 문자열) 을 파싱해서 `meta.liveRewind = true`,
|
||||
`meta.duration` 보강, `live.timeMachine = true` 를 주입한 뒤 다시 직렬화합니다.
|
||||
이 필드들이 플레이어가 seek 바 UI 를 켤지 결정하는 트리거입니다.
|
||||
- 치지직의 DVR window 는 스트리머가 타임머신을 켰을 때만 CDN 이
|
||||
프로비저닝합니다. 클라이언트에서 `timeMachineActive` 플래그만 `true` 로
|
||||
바꿔도 HLS 매니페스트(`timemachine=false`) 와 상태가 어긋나서 실제 되감기는
|
||||
동작하지 않습니다 (cosmetic 한 UI 만 뜸).
|
||||
- 더 심각한 문제는, 이 클라이언트-사이드 상태 불일치가 치지직의 비정상 접근
|
||||
감지 로직을 트리거해 **"허용되지 않는 비정상적 접근입니다. 반복적인 접근 시
|
||||
운영 정책에 따라 조치될 수 있습니다."** 팝업이 뜨는 것이 실측으로 확인되었습니다.
|
||||
계정 제재 위험이 있어 기능을 제거했습니다.
|
||||
|
||||
> **중요한 한계 (반드시 읽으세요)**
|
||||
>
|
||||
> 이건 **UI 만 켜는 cosmetic 패치** 입니다. 치지직의 DVR window 는 스트리머가
|
||||
> 타임머신을 켰을 때만 CDN 이 프로비저닝하기 때문에, 스트리머가 꺼둔 라이브의
|
||||
> HLS 매니페스트에는 과거 시점의 segment 자체가 존재하지 않습니다. 따라서:
|
||||
>
|
||||
> - **재생바가 떠도 실제 되감기는 동작하지 않을 가능성이 매우 높습니다** (누르면
|
||||
> live edge 로 튕기거나 segment not found 가 뜸).
|
||||
> - 진짜 되감기를 살리려면 CDN 이 만들어 둔 DVR 매니페스트가 있어야 하는데,
|
||||
> `service/v1/channels/{id}/live-playback-json` 엔드포인트는 스트리머가 타임머신을
|
||||
> 켜둔 채널에서만 200 을 돌려주고 꺼둔 채널에서는 404 입니다 (실측 확인).
|
||||
>
|
||||
> 참고:
|
||||
> [Streamlink chzzk 플러그인](https://github.com/streamlink/streamlink/blob/master/src/streamlink/plugins/chzzk.py)
|
||||
> 도 `timeMachineActive` 가 true 일 때만 `live-playback-json` 을 부릅니다.
|
||||
> [Choonholic/ChzzkDownloader](https://github.com/Choonholic/ChzzkDownloader)
|
||||
> 의 `--stream force-timemachine` 옵션도 마찬가지로 서버-사이드 (CORS 무관)
|
||||
> 에서 동일 엔드포인트를 강제로 두드릴 뿐, 서버가 DVR 을 안 만들었다면 똑같이
|
||||
> 실패합니다.
|
||||
|
||||
구현: `timemachine.js`
|
||||
|
||||
#### 진단 모드
|
||||
|
||||
응답 구조를 직접 들여다보고 싶으면 devtools 콘솔에서:
|
||||
|
||||
```js
|
||||
localStorage.setItem('chzzk-bypass:debug', '1')
|
||||
```
|
||||
|
||||
치고 새로고침하면 `[chzzk-bypass:timemachine] DEBUG live-detail content after patch: {...}`
|
||||
로 패치된 content + `livePlaybackJson` 파싱 결과가 통째로 찍힙니다.
|
||||
스트리머가 끈 타임머신은 클라이언트 확장만으로는 안전하게 우회할 수 없습니다.
|
||||
되감기가 필요하면 스트리머에게 타임머신을 켜달라고 요청하거나 다시보기
|
||||
업로드를 기다려 주세요.
|
||||
|
||||
## 설치
|
||||
|
||||
@@ -73,21 +51,12 @@ localStorage.setItem('chzzk-bypass:debug', '1')
|
||||
|
||||
```
|
||||
manifest.json 확장 정의 (MV3)
|
||||
content.js 그리드 우회 (UA / platform / UA-CH 위장)
|
||||
timemachine.js 타임머신 강제 활성화 (fetch + XHR 후킹)
|
||||
content.js 그리드 우회 (UA / platform / UA-CH 위장 + getHighEntropyValues)
|
||||
rules.json declarativeNetRequest 규칙 (현재 비어 있음)
|
||||
popup.html/css/js 툴바 팝업
|
||||
icons/ 확장 아이콘
|
||||
```
|
||||
|
||||
## 동작 확인
|
||||
|
||||
브라우저 devtools 콘솔에서 다음 로그를 확인할 수 있습니다.
|
||||
|
||||
- `[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] inner livePlaybackJson meta/live patched (liveRewind, duration, timeMachine)` — 내부 매니페스트 메타 패치 성공
|
||||
|
||||
## 면책
|
||||
|
||||
본 확장은 학습 및 개인 사용 목적의 우회 도구입니다. 사용에 따른 모든 책임은
|
||||
|
||||
76
content.js
76
content.js
@@ -1,4 +1,13 @@
|
||||
// 치지직 플레이어가 OS를 검사할 때 Mac으로 인식하도록 속임
|
||||
// 치지직이 윈도우에서 1080p 시청 시 그리드 설치를 요구하는 것을 우회하기 위해
|
||||
// navigator 의 OS/브라우저 식별 값들을 Mac 으로 위장한다.
|
||||
//
|
||||
// userAgentData 의 getHighEntropyValues 까지 구현해 두지 않으면
|
||||
// 치지직이 로드하는 nlog.js (네이버 핑거프린팅 / 로깅) 가
|
||||
// `e.getHighEntropyValues is not a function` 으로 Uncaught TypeError 를 던지고,
|
||||
// 그 자체가 비정상 접근 시그널이 될 수 있다. 그래서 객체 형태와 메서드까지
|
||||
// 일관된 Mac 값으로 채워 둔다. (manifest 의 world: MAIN + run_at: document_start
|
||||
// 덕분에 페이지 스크립트보다 먼저 적용된다.)
|
||||
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
get: function () {
|
||||
return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36';
|
||||
@@ -11,16 +20,63 @@ Object.defineProperty(navigator, 'platform', {
|
||||
}
|
||||
});
|
||||
|
||||
Object.defineProperty(navigator, 'userAgentData', {
|
||||
get: function () {
|
||||
return {
|
||||
brands: [
|
||||
{ brand: "Chromium", version: "122" },
|
||||
{ brand: "Google Chrome", version: "122" },
|
||||
{ brand: "Not-A.Brand", version: "24" }
|
||||
],
|
||||
const __chzzkBypassBrands = [
|
||||
{ brand: 'Chromium', version: '122' },
|
||||
{ brand: 'Google Chrome', version: '122' },
|
||||
{ brand: 'Not-A.Brand', version: '24' }
|
||||
];
|
||||
|
||||
const __chzzkBypassFullVersionList = [
|
||||
{ brand: 'Chromium', version: '122.0.0.0' },
|
||||
{ brand: 'Google Chrome', version: '122.0.0.0' },
|
||||
{ brand: 'Not-A.Brand', version: '24.0.0.0' }
|
||||
];
|
||||
|
||||
const __chzzkBypassHighEntropy = {
|
||||
brands: __chzzkBypassBrands,
|
||||
mobile: false,
|
||||
platform: 'macOS',
|
||||
platformVersion: '10.15.7',
|
||||
architecture: 'x86',
|
||||
bitness: '64',
|
||||
model: '',
|
||||
uaFullVersion: '122.0.0.0',
|
||||
fullVersionList: __chzzkBypassFullVersionList,
|
||||
wow64: false,
|
||||
formFactors: []
|
||||
};
|
||||
|
||||
const __chzzkBypassUAData = {
|
||||
brands: __chzzkBypassBrands,
|
||||
mobile: false,
|
||||
platform: 'macOS',
|
||||
getHighEntropyValues: function (hints) {
|
||||
// 항상 brands / mobile / platform 은 포함하고, 요청된 hints 만 추가로 채운다.
|
||||
const out = {
|
||||
brands: __chzzkBypassBrands,
|
||||
mobile: false,
|
||||
platform: "macOS"
|
||||
platform: 'macOS'
|
||||
};
|
||||
if (Array.isArray(hints)) {
|
||||
for (const h of hints) {
|
||||
if (Object.prototype.hasOwnProperty.call(__chzzkBypassHighEntropy, h)) {
|
||||
out[h] = __chzzkBypassHighEntropy[h];
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve(out);
|
||||
},
|
||||
toJSON: function () {
|
||||
return {
|
||||
brands: __chzzkBypassBrands,
|
||||
mobile: false,
|
||||
platform: 'macOS'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(navigator, 'userAgentData', {
|
||||
get: function () {
|
||||
return __chzzkBypassUAData;
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "Chzzk Bypass",
|
||||
"version": "1.1.3",
|
||||
"version": "1.2.0",
|
||||
"manifest_version": 3,
|
||||
"description": "치지직(CHZZK) 시청 환경 개선: 1) Mac 위장으로 그리드 없이 1080p 시청, 2) 스트리머가 타임머신을 꺼둔 라이브에서도 되감기 UI 강제 표시.",
|
||||
"description": "치지직(CHZZK) 시청 환경 개선: Mac 위장으로 그리드 없이 1080p 시청.",
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
@@ -35,8 +35,7 @@
|
||||
"*://chzzk.naver.com/*"
|
||||
],
|
||||
"js": [
|
||||
"content.js",
|
||||
"timemachine.js"
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_start",
|
||||
"world": "MAIN"
|
||||
|
||||
@@ -21,10 +21,6 @@
|
||||
<span class="label">그리드 우회</span>
|
||||
<span class="value success">Mac 위장 (1080p)</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">타임머신</span>
|
||||
<span class="value success">강제 활성화</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
|
||||
282
timemachine.js
282
timemachine.js
@@ -1,282 +0,0 @@
|
||||
// 치지직 라이브 응답을 가로채 타임머신 되감기 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)');
|
||||
})();
|
||||
Reference in New Issue
Block a user