@usercase/mcp 0.2.10-next.0 → 0.2.10-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,211 +1,219 @@
1
- "use strict";
2
- /**
3
- * 가상 사용자 방문의 방어선. **고객 사이트에서 하면 안 되는 것을 네트워크 층에서 막는다.**
4
- *
5
- * 도구 설명이나 프롬프트로 "하지 마세요" 라고 하는 게 아니라 여기서 요청 자체를 끊는다.
6
- * AI 가 무엇을 고르든, MCP 를 부른 AI 도구가 무엇을 시키든 깨지면 안 되는 선이다(PLAN.md "코드로 강제하는 선").
7
- *
8
- * - **사이트에 쓰지 않는다.** 사이트 origin 으로 가는 GET·HEAD·OPTIONS 가 아닌 요청은 끊는다(폼 제출·좋아요·댓글)
9
- * - **분석 도구에 찍히지 않는다.** GA·Hotjar 등은 기본으로 막는다 — 고객이 온보딩 ② 를 안 했어도 데이터가 안 섞이게
10
- * - **SDK 를 심을 때는 사이트에 원래 박힌 운영 SDK 를 막는다.** 두면 가상 사용자 방문이 운영 서버에 진짜 방문으로 쌓인다
11
- * - 로컬 개발 사이트(localhost)는 밖으로 한 발도 안 나간다(블루태그스 개발 때 쓰던 방식)
12
- *
13
- * **`open.js` 만 이걸 부른다.** 컨텍스트를 여는 곳이 여러 군데면 이 선이 빠지는 곳이 생긴다(9/21 에 UA 꼬리표가 그렇게 빠졌다).
14
- *
15
- * 의존성 없음. Playwright context 객체를 받는다.
16
- */
17
-
18
- /** 사이트 주인의 분석 도구. 가상 사용자가 여기 진짜 방문으로 찍히면 전환율·A/B 테스트가 흔들린다 */
19
- const TRACKERS =
20
- /(^|\.)(hotjar\.com|hotjar\.io|maze\.co|google-analytics\.com|analytics\.google\.com|googletagmanager\.com|clarity\.ms|doubleclick\.net|connect\.facebook\.net|amplitude\.com|mixpanel\.com|segment\.io|segment\.com)$/;
21
-
22
- /** 어닝버드 운영 SDK·API. SDK 를 심을 때(`inject`)만 막는다 — 안 심을 때는 이게 고객 사이트의 SDK 다 */
23
- // 옛 도메인도 — 사이트에 옛 설치 코드(app.earningbirds.com)가 남아 있을 수 있다
24
- const PRODUCTION = /(^|\.)(usercase\.io|earningbirds\.com)$/;
25
-
26
- const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
27
-
28
- /** 새 탭·창도 그 자체로 최상위다. 페이지 안 iframe(영상·지도)은 사이트 밖이어도 그냥 둔다 */
29
- function isMainFrame(req) {
30
- try {
31
- return !req.frame().parentFrame();
32
- } catch {
33
- return false; // 서비스 워커 요청 등 — 이동이 아니다
34
- }
35
- }
36
-
37
- const isLocal = (site) => /^https?:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(site);
38
-
39
- /**
40
- * @param site 고객 사이트 주소
41
- * @param inject { project, sdkSrc, serverOrigin } — 사이트에 SDK 가 없거나 로컬 SDK 로 돌릴 때. 없으면 사이트의 SDK 를 그대로 쓴다
42
- * @param blockTrackers 분석 도구를 막나(기본 true)
43
- * @param allowWritePaths 쓰기를 허용할 경로 정규식들. 로그인 뒤 조회를 POST 로 하는 사이트용(PLAN.md "로그인")
44
- */
45
- function createGuard({ site, inject = null, blockTrackers = true, allowWritePaths = [], policy = null, robots = null } = {}) {
46
- const origin = new URL(site).origin;
47
- const local = isLocal(site);
48
- /** 우리가 심은 SDK 와 그 서버. 이건 막지 않는다 */
49
- const ours = new Set(inject ? [new URL(inject.sdkSrc).origin, inject.serverOrigin].filter(Boolean) : []);
50
- const writeOk = allowWritePaths.map((p) => (p instanceof RegExp ? p : new RegExp(p)));
51
- /** 막은 것. 막은 요청 때문에 사이트가 제대로 안 돌았을 수 있어 결과와 같이 보여준다 */
52
- const stats = { writes: new Map(), trackers: 0, production: 0, offsite: 0, paths: new Map() };
53
-
54
- /**
55
- * 이 주소로 **옮겨 가도** 되나. 막으면 사람이 읽을 이유를 돌려준다.
56
- * 두 가지를 본다 — 고객이 정한 금지 구역(policy)과 사이트가 적어 둔 robots.txt.
57
- * 클릭 자체는 막지 않는다(SDK 가 그 클릭을 봐야 한다) — 그 클릭으로 일어나는 **이동**을 막는다.
58
- */
59
- /**
60
- * 사이트로 가는 GET 아닌 요청을 보내도 되나.
61
- * 환경변수로 연 경로(allowWritePaths)와 고객 정책(policy.allowNonGet·allowNonGetPaths)을 같이 본다.
62
- * 정책 쪽 `allowNonGet` 은 폼 제출까지 여는 스위치라 고객이 자기 사이트에서 켜는 것이다.
63
- */
64
- const bare = (h) => h.replace(/^www\./, "");
65
- const siteHost = bare(new URL(origin).host);
66
- const sameSite = (u) => u.origin === origin || bare(u.host) === siteHost;
67
-
68
- function writeAllowed(pathname) {
69
- if (writeOk.some((rx) => rx.test(pathname))) return true;
70
- return !!(policy && policy.nonGetAllowed(pathname));
71
- }
72
-
73
- function navBlocked(u) {
74
- if (u.origin !== origin) return null; // 사이트 밖은 아래에서 따로 다룬다
75
- const byPolicy = policy ? policy.pathReason(u.pathname) : null;
76
- if (byPolicy) return byPolicy;
77
- // robots 는 나중에 읽혀 들어올 수 있다(네트워크). 함수로 받으면 그때그때 최신을 본다
78
- const rb = typeof robots === "function" ? robots() : robots;
79
- const byRobots = rb ? rb.blocked(u.pathname) : null;
80
- return byRobots ? `robots.txt Disallow ${byRobots}` : null;
81
- }
82
-
83
- /**
84
- * @param onOffsite 사이트 밖으로 옮겨 가려 할 때 그 주소로 부른다. 방문이 "다른 사이트로 가는 링크였다" 를 알린다
85
- * @param onWrite 사이트에 쓰는 요청을 끊었을 때 "METHOD /path" 로 부른다. 방문자가 "안 된다" 로 읽지 않게 알린다
86
- */
87
- async function apply(ctx, { onOffsite = null, onWrite = null, onExternal = null } = {}) {
88
- await ctx.route("**/*", (r) => {
89
- const req = r.request();
90
- let u;
91
- try {
92
- u = new URL(req.url());
93
- } catch {
94
- return r.continue();
95
- }
96
-
97
- /**
98
- * **사이트 밖으로는 옮겨 가지 않는다.** 방문은 이 사이트 하나를 본다.
99
- * 전에는 로컬에서는 그냥 끊고(방문자에게는 "링크가 안 열린다"), 운영에서는 남의 사이트까지 따라갔다.
100
- * 10/13 bluetags 문서·약관 링크(about.bluetags.app)가 "안 열린다" 는 가짜 발견 둘이 됐다.
101
- * 끊되 방문에 알려서, 방문자가 "다른 사이트로 가는 링크" 로 알게 한다.
102
- * 사이트가 스스로 넘기는 것(www 붙이기 같은 리다이렉트)은 그대로 둔다.
103
- */
104
- if (/^https?:$/.test(u.protocol) && !sameSite(u) && req.isNavigationRequest() && !req.redirectedFrom() && isMainFrame(req)) {
105
- stats.offsite++;
106
- if (onOffsite) onOffsite(u.href);
107
- // 끊으면(abort) 브라우저가 오류 화면으로 바뀌어 방문자가 빈 화면에 남는다. 204 는 "이동 안 함" 이라 보던 화면에 그대로 있다
108
- return r.fulfill({ status: 204, body: "" });
109
- }
110
- if (ours.has(u.origin)) return r.continue();
111
-
112
- /**
113
- * 금지 구역으로 **옮겨 가는 것**만 막는다(클릭은 막지 않는다 — SDK 가 그 클릭을 봐야 한다).
114
- * 화면 안에서 쓰는 요청(XHR 등)까지 이걸로 막으면 멀쩡한 사이트가 안 돈다.
115
- */
116
- if (u.origin === origin && req.isNavigationRequest()) {
117
- const why = navBlocked(u);
118
- if (why) {
119
- const k = `${u.pathname} — ${why}`;
120
- stats.paths.set(k, (stats.paths.get(k) || 0) + 1);
121
- return r.abort("blockedbyclient");
122
- }
123
- }
124
-
125
- /**
126
- * 사이트에 쓰는 요청. 로컬이면 다른 포트의 로컬 API 도 그 사이트다.
127
- * 전에는 아래 로컬 분기가 localhost 를 먼저 통과시켜 로컬에서는 쓰기가 한 번도 안 막혔다(10/14 발견).
128
- * 개발 서버 자체 요청(/_next/, /__nextjs)은 사이트 데이터가 아니다
129
- */
130
- const siteReq = u.origin === origin || (local && /^https?:\/\/(localhost|127\.0\.0\.1)/.test(u.origin) && !/^\/(_next|__nextjs)/.test(u.pathname));
131
- if (siteReq && !READ_METHODS.has(req.method()) && !writeAllowed(u.pathname)) {
132
- const k = `${req.method()} ${u.pathname}`;
133
- stats.writes.set(k, (stats.writes.get(k) || 0) + 1);
134
- // 배경 비콘(ping)은 방문자가 한 일이 아니다. 화면이 보내는 것(fetch·xhr·폼)만 알린다
135
- if (onWrite && ["fetch", "xhr", "document"].includes(req.resourceType())) onWrite(k);
136
- return r.abort("blockedbyclient");
137
- }
138
-
139
- if (local) {
140
- // 로컬 개발 사이트: 밖으로 안 나간다. 로드만 막고 클릭은 그대로 둔다.
141
- // 단 사이트에 넣은 SDK(app.usercase.io)는 연다 — 막으면 로컬에서 설치 확인·카드가 영영 안 된다.
142
- // 로컬 SDK 를 심어 돌릴 때(inject)는 운영 SDK 를 막는다(두 SDK 가 겹친다)
143
- if (/^https?:\/\/(localhost|127\.0\.0\.1)/.test(u.origin)) return r.continue();
144
- if (!inject && PRODUCTION.test(u.hostname)) return r.continue();
145
- if (inject && PRODUCTION.test(u.hostname)) stats.production++;
146
- // 화면이 바깥 서버와 주고받으려던 것(실시간 채팅 소켓·시세 API). 끊긴 줄 모르면 방문자는 "보내도 안 뜬다" 를 사이트 탓으로 적는다
147
- // (10/15 bluetags 100명 p57·실험실 x23: 채팅이 운영 서버 소켓에 먼저 보내고 답을 받아야 저장한다). 그림·글꼴 같은 불러오기는 알리지 않는다
148
- else if (onExternal && ["fetch", "xhr", "websocket", "eventsource"].includes(req.resourceType()) && !TRACKERS.test(u.hostname)) onExternal(u.host);
149
- return r.abort();
150
- }
151
- if (inject && PRODUCTION.test(u.hostname)) {
152
- stats.production++;
153
- return r.abort("blockedbyclient");
154
- }
155
- if (blockTrackers && TRACKERS.test(u.hostname)) {
156
- stats.trackers++;
157
- return r.abort("blockedbyclient");
158
- }
159
- return r.continue();
160
- });
161
- if (inject) await ctx.addInitScript(injectSdk, { project: inject.project, src: inject.sdkSrc });
162
- }
163
-
164
- return {
165
- apply,
166
- local,
167
- /** 사람이 읽을 요약. 아무것도 안 막았으면 null */
168
- summary() {
169
- const paths = [...stats.paths].map(([k, n]) => `${k} ×${n}`);
170
- const writes = [...stats.writes].map(([k, n]) => `${k} ×${n}`);
171
- if (local) return paths.length || writes.length ? { writes, trackers: 0, production: 0, paths } : null;
172
- return { writes, trackers: stats.trackers, production: stats.production, paths };
173
- },
174
- };
175
- }
176
-
177
- /**
178
- * 사이트에 SDK 를 심는다. 페이지 안에서 돈다.
179
- * 사이트에 원래 스니펫이 있으면 로드 뒤에 project 를 운영 id 로 덮어쓴다 — 못 바꾸게 묶는다(9/21 bluetags.app).
180
- * 우리가 먼저 `EarningbirdsInitialized` 를 세우므로 사이트 스니펫은 운영 SDK 를 부르지 않는다.
181
- *
182
- * **묶어도 결국 풀린다.** SDK 가 뜨면서 `window.Earningbirds` 를 새 객체로 갈아 끼우고(core.js functionHandler),
183
- * 그 뒤 사이트 스니펫이 새 객체에 운영 id 를 쓴다. 그래도 괜찮은 이유: SDK 는 시작할 때 한 번 읽어 기록을 보낸다.
184
- * 나중에 읽는 곳은 리서치 참여 팝업뿐이라 가상 사용자는 안 간다(9/21 확인). 페이지를 새로 열면 이 스크립트가 다시 먼저 돈다.
185
- * 그 팝업 경로를 탈 일이 생기면 여기를 다시 볼 것.
186
- */
187
- function injectSdk({ project, src }) {
188
- var w = window;
189
- if (w.top !== w) return;
190
- w.Usercase = w.Earningbirds = w.Usercase || w.Earningbirds || {};
191
- try {
192
- Object.defineProperty(w.Earningbirds, "project", { value: project, writable: false, configurable: false });
193
- } catch (e) {
194
- w.Earningbirds.project = project;
195
- }
196
- function l() {
197
- if (w.EarningbirdsInitialized) return;
198
- w.EarningbirdsInitialized = w.UsercaseInitialized = true;
199
- var s = document.createElement("script");
200
- s.async = true;
201
- s.src = src;
202
- (document.head || document.documentElement).appendChild(s);
203
- }
204
- if (document.readyState === "complete") l();
205
- else {
206
- w.addEventListener("DOMContentLoaded", l);
207
- w.addEventListener("load", l);
208
- }
209
- }
210
-
211
- module.exports = { createGuard, isLocal, TRACKERS };
1
+ "use strict";
2
+ /**
3
+ * 가상 사용자 방문의 방어선. **고객 사이트에서 하면 안 되는 것을 네트워크 층에서 막는다.**
4
+ *
5
+ * 도구 설명이나 프롬프트로 "하지 마세요" 라고 하는 게 아니라 여기서 요청 자체를 끊는다.
6
+ * AI 가 무엇을 고르든, MCP 를 부른 AI 도구가 무엇을 시키든 깨지면 안 되는 선이다(PLAN.md "코드로 강제하는 선").
7
+ *
8
+ * - **사이트에 쓰지 않는다.** 사이트 origin 으로 가는 GET·HEAD·OPTIONS 가 아닌 요청은 끊는다(폼 제출·좋아요·댓글)
9
+ * - **분석 도구에 찍히지 않는다.** GA·Hotjar 등은 기본으로 막는다 — 고객이 온보딩 ② 를 안 했어도 데이터가 안 섞이게
10
+ * - **SDK 를 심을 때는 사이트에 원래 박힌 운영 SDK 를 막는다.** 두면 가상 사용자 방문이 운영 서버에 진짜 방문으로 쌓인다
11
+ * - 로컬 개발 사이트(localhost)는 밖으로 한 발도 안 나간다(블루태그스 개발 때 쓰던 방식)
12
+ *
13
+ * **`open.js` 만 이걸 부른다.** 컨텍스트를 여는 곳이 여러 군데면 이 선이 빠지는 곳이 생긴다(9/21 에 UA 꼬리표가 그렇게 빠졌다).
14
+ *
15
+ * 의존성 없음. Playwright context 객체를 받는다.
16
+ */
17
+
18
+ /** 사이트 주인의 분석 도구. 가상 사용자가 여기 진짜 방문으로 찍히면 전환율·A/B 테스트가 흔들린다 */
19
+ const TRACKERS =
20
+ /(^|\.)(hotjar\.com|hotjar\.io|maze\.co|google-analytics\.com|analytics\.google\.com|googletagmanager\.com|clarity\.ms|doubleclick\.net|connect\.facebook\.net|amplitude\.com|mixpanel\.com|segment\.io|segment\.com)$/;
21
+
22
+ /** 어닝버드 운영 SDK·API. SDK 를 심을 때(`inject`)만 막는다 — 안 심을 때는 이게 고객 사이트의 SDK 다 */
23
+ // 옛 도메인도 — 사이트에 옛 설치 코드(app.earningbirds.com)가 남아 있을 수 있다
24
+ const PRODUCTION = /(^|\.)(usercase\.io|earningbirds\.com)$/;
25
+
26
+ /**
27
+ * 리서치가 끝난 뒤 설문(우리 앱 survey.usercase.io, 개발 survey.localhost:3000).
28
+ * 리서치에 참여한 방문은 과제가 끝나면 이리로 옮겨 간다(hub 새 탭 처리 · study.js) — 사이트 밖이지만 막지 않는다
29
+ */
30
+ const SURVEY = /^https?:\/\/survey\.(usercase\.io|localhost)(:\d+)?$/;
31
+
32
+ const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
33
+
34
+ /** 새 탭·창도 그 자체로 최상위다. 페이지 안 iframe(영상·지도)은 사이트 밖이어도 그냥 둔다 */
35
+ function isMainFrame(req) {
36
+ try {
37
+ return !req.frame().parentFrame();
38
+ } catch {
39
+ return false; // 서비스 워커 요청 등 — 이동이 아니다
40
+ }
41
+ }
42
+
43
+ const isLocal = (site) => /^https?:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(site);
44
+
45
+ /**
46
+ * @param site 고객 사이트 주소
47
+ * @param inject { project, sdkSrc, serverOrigin } — 사이트에 SDK 가 없거나 로컬 SDK 로 돌릴 때. 없으면 사이트의 SDK 를 그대로 쓴다
48
+ * @param blockTrackers 분석 도구를 막나(기본 true)
49
+ * @param allowWritePaths 쓰기를 허용할 경로 정규식들. 로그인 뒤 조회를 POST 로 하는 사이트용(PLAN.md "로그인")
50
+ */
51
+ function createGuard({ site, inject = null, blockTrackers = true, allowWritePaths = [], policy = null, robots = null } = {}) {
52
+ const origin = new URL(site).origin;
53
+ const local = isLocal(site);
54
+ /** 우리가 심은 SDK 와 그 서버. 이건 막지 않는다 */
55
+ const ours = new Set(inject ? [new URL(inject.sdkSrc).origin, inject.serverOrigin].filter(Boolean) : []);
56
+ const writeOk = allowWritePaths.map((p) => (p instanceof RegExp ? p : new RegExp(p)));
57
+ /** 막은 것. 막은 요청 때문에 사이트가 제대로 안 돌았을 수 있어 결과와 같이 보여준다 */
58
+ const stats = { writes: new Map(), trackers: 0, production: 0, offsite: 0, paths: new Map() };
59
+
60
+ /**
61
+ * 이 주소로 **옮겨 가도** 되나. 막으면 사람이 읽을 이유를 돌려준다.
62
+ * 두 가지를 본다 — 고객이 정한 금지 구역(policy)과 사이트가 적어 둔 robots.txt.
63
+ * 클릭 자체는 막지 않는다(SDK 가 그 클릭을 봐야 한다) — 그 클릭으로 일어나는 **이동**을 막는다.
64
+ */
65
+ /**
66
+ * 사이트로 가는 GET 아닌 요청을 보내도 되나.
67
+ * 환경변수로 연 경로(allowWritePaths)와 고객 정책(policy.allowNonGet·allowNonGetPaths)을 같이 본다.
68
+ * 정책 쪽 `allowNonGet` 은 폼 제출까지 여는 스위치라 고객이 자기 사이트에서 켜는 것이다.
69
+ */
70
+ const bare = (h) => h.replace(/^www\./, "");
71
+ const siteHost = bare(new URL(origin).host);
72
+ const sameSite = (u) => u.origin === origin || bare(u.host) === siteHost;
73
+
74
+ function writeAllowed(pathname) {
75
+ if (writeOk.some((rx) => rx.test(pathname))) return true;
76
+ return !!(policy && policy.nonGetAllowed(pathname));
77
+ }
78
+
79
+ function navBlocked(u) {
80
+ if (u.origin !== origin) return null; // 사이트 밖은 아래에서 따로 다룬다
81
+ const byPolicy = policy ? policy.pathReason(u.pathname) : null;
82
+ if (byPolicy) return byPolicy;
83
+ // robots 는 나중에 읽혀 들어올 수 있다(네트워크). 함수로 받으면 그때그때 최신을 본다
84
+ const rb = typeof robots === "function" ? robots() : robots;
85
+ const byRobots = rb ? rb.blocked(u.pathname) : null;
86
+ return byRobots ? `robots.txt Disallow ${byRobots}` : null;
87
+ }
88
+
89
+ /**
90
+ * @param onOffsite 사이트 밖으로 옮겨 가려 할 때 그 주소로 부른다. 방문이 "다른 사이트로 가는 링크였다" 를 알린다
91
+ * @param onWrite 사이트에 쓰는 요청을 끊었을 때 "METHOD /path" 로 부른다. 방문자가 "안 된다" 로 읽지 않게 알린다
92
+ */
93
+ async function apply(ctx, { onOffsite = null, onWrite = null, onExternal = null } = {}) {
94
+ await ctx.route("**/*", (r) => {
95
+ const req = r.request();
96
+ let u;
97
+ try {
98
+ u = new URL(req.url());
99
+ } catch {
100
+ return r.continue();
101
+ }
102
+
103
+ if (SURVEY.test(u.origin)) return r.continue();
104
+
105
+ /**
106
+ * **사이트 밖으로는 옮겨 가지 않는다.** 방문은 이 사이트 하나를 본다.
107
+ * 전에는 로컬에서는 그냥 끊고(방문자에게는 "링크가 안 열린다"), 운영에서는 남의 사이트까지 따라갔다.
108
+ * 10/13 bluetags 문서·약관 링크(about.bluetags.app)가 "안 열린다" 는 가짜 발견 둘이 됐다.
109
+ * 끊되 방문에 알려서, 방문자가 "다른 사이트로 가는 링크" 로 알게 한다.
110
+ * 사이트가 스스로 넘기는 것(www 붙이기 같은 리다이렉트)은 그대로 둔다.
111
+ */
112
+ if (/^https?:$/.test(u.protocol) && !sameSite(u) && req.isNavigationRequest() && !req.redirectedFrom() && isMainFrame(req)) {
113
+ stats.offsite++;
114
+ if (onOffsite) onOffsite(u.href);
115
+ // 끊으면(abort) 브라우저가 오류 화면으로 바뀌어 방문자가 빈 화면에 남는다. 204 는 "이동 안 함" 이라 보던 화면에 그대로 있다
116
+ return r.fulfill({ status: 204, body: "" });
117
+ }
118
+ if (ours.has(u.origin)) return r.continue();
119
+
120
+ /**
121
+ * 금지 구역으로 **옮겨 가는 것**만 막는다(클릭은 막지 않는다 — SDK 가 그 클릭을 봐야 한다).
122
+ * 화면 안에서 쓰는 요청(XHR 등)까지 이걸로 막으면 멀쩡한 사이트가 안 돈다.
123
+ */
124
+ if (u.origin === origin && req.isNavigationRequest()) {
125
+ const why = navBlocked(u);
126
+ if (why) {
127
+ const k = `${u.pathname} — ${why}`;
128
+ stats.paths.set(k, (stats.paths.get(k) || 0) + 1);
129
+ return r.abort("blockedbyclient");
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 사이트에 쓰는 요청. 로컬이면 다른 포트의 로컬 API 도 그 사이트다.
135
+ * 전에는 아래 로컬 분기가 localhost 를 먼저 통과시켜 로컬에서는 쓰기가 한 번도 안 막혔다(10/14 발견).
136
+ * 개발 서버 자체 요청(/_next/, /__nextjs)은 사이트 데이터가 아니다
137
+ */
138
+ const siteReq = u.origin === origin || (local && /^https?:\/\/(localhost|127\.0\.0\.1)/.test(u.origin) && !/^\/(_next|__nextjs)/.test(u.pathname));
139
+ if (siteReq && !READ_METHODS.has(req.method()) && !writeAllowed(u.pathname)) {
140
+ const k = `${req.method()} ${u.pathname}`;
141
+ stats.writes.set(k, (stats.writes.get(k) || 0) + 1);
142
+ // 배경 비콘(ping)은 방문자가 한 일이 아니다. 화면이 보내는 것(fetch·xhr·폼)만 알린다
143
+ if (onWrite && ["fetch", "xhr", "document"].includes(req.resourceType())) onWrite(k);
144
+ return r.abort("blockedbyclient");
145
+ }
146
+
147
+ if (local) {
148
+ // 로컬 개발 사이트: 밖으로 안 나간다. 로드만 막고 클릭은 그대로 둔다.
149
+ // 단 사이트에 넣은 SDK(app.usercase.io)는 연다 — 막으면 로컬에서 설치 확인·카드가 영영 안 된다.
150
+ // 로컬 SDK 를 심어 돌릴 때(inject)는 운영 SDK 를 막는다(두 SDK 가 겹친다)
151
+ if (/^https?:\/\/(localhost|127\.0\.0\.1)/.test(u.origin)) return r.continue();
152
+ if (!inject && PRODUCTION.test(u.hostname)) return r.continue();
153
+ if (inject && PRODUCTION.test(u.hostname)) stats.production++;
154
+ // 화면이 바깥 서버와 주고받으려던 것(실시간 채팅 소켓·시세 API). 끊긴 줄 모르면 방문자는 "보내도 안 뜬다" 를 사이트 탓으로 적는다
155
+ // (10/15 bluetags 100명 p57·실험실 x23: 채팅이 운영 서버 소켓에 먼저 보내고 답을 받아야 저장한다). 그림·글꼴 같은 불러오기는 알리지 않는다
156
+ else if (onExternal && ["fetch", "xhr", "websocket", "eventsource"].includes(req.resourceType()) && !TRACKERS.test(u.hostname)) onExternal(u.host);
157
+ return r.abort();
158
+ }
159
+ if (inject && PRODUCTION.test(u.hostname)) {
160
+ stats.production++;
161
+ return r.abort("blockedbyclient");
162
+ }
163
+ if (blockTrackers && TRACKERS.test(u.hostname)) {
164
+ stats.trackers++;
165
+ return r.abort("blockedbyclient");
166
+ }
167
+ return r.continue();
168
+ });
169
+ if (inject) await ctx.addInitScript(injectSdk, { project: inject.project, src: inject.sdkSrc });
170
+ }
171
+
172
+ return {
173
+ apply,
174
+ local,
175
+ /** 사람이 읽을 요약. 아무것도 안 막았으면 null */
176
+ summary() {
177
+ const paths = [...stats.paths].map(([k, n]) => `${k} ×${n}`);
178
+ const writes = [...stats.writes].map(([k, n]) => `${k} ×${n}`);
179
+ if (local) return paths.length || writes.length ? { writes, trackers: 0, production: 0, paths } : null;
180
+ return { writes, trackers: stats.trackers, production: stats.production, paths };
181
+ },
182
+ };
183
+ }
184
+
185
+ /**
186
+ * 사이트에 SDK 를 심는다. 페이지 안에서 돈다.
187
+ * 사이트에 원래 스니펫이 있으면 로드 뒤에 project 를 운영 id 로 덮어쓴다 — 못 바꾸게 묶는다(9/21 bluetags.app).
188
+ * 우리가 먼저 `EarningbirdsInitialized` 를 세우므로 사이트 스니펫은 운영 SDK 를 부르지 않는다.
189
+ *
190
+ * **묶어도 결국 풀린다.** SDK 가 뜨면서 `window.Earningbirds` 를 새 객체로 갈아 끼우고(core.js functionHandler),
191
+ * 그 뒤 사이트 스니펫이 새 객체에 운영 id 를 쓴다. 그래도 괜찮은 이유: SDK 는 시작할 때 한 번 읽어 기록을 보낸다.
192
+ * 나중에 읽는 곳은 리서치 참여 팝업뿐이라 가상 사용자는 안 간다(9/21 확인). 페이지를 새로 열면 이 스크립트가 다시 먼저 돈다.
193
+ * 그 팝업 경로를 탈 일이 생기면 여기를 다시 볼 것.
194
+ */
195
+ function injectSdk({ project, src }) {
196
+ var w = window;
197
+ if (w.top !== w) return;
198
+ w.Usercase = w.Earningbirds = w.Usercase || w.Earningbirds || {};
199
+ try {
200
+ Object.defineProperty(w.Earningbirds, "project", { value: project, writable: false, configurable: false });
201
+ } catch (e) {
202
+ w.Earningbirds.project = project;
203
+ }
204
+ function l() {
205
+ if (w.EarningbirdsInitialized) return;
206
+ w.EarningbirdsInitialized = w.UsercaseInitialized = true;
207
+ var s = document.createElement("script");
208
+ s.async = true;
209
+ s.src = src;
210
+ (document.head || document.documentElement).appendChild(s);
211
+ }
212
+ if (document.readyState === "complete") l();
213
+ else {
214
+ w.addEventListener("DOMContentLoaded", l);
215
+ w.addEventListener("load", l);
216
+ }
217
+ }
218
+
219
+ module.exports = { createGuard, isLocal, TRACKERS };
@@ -1,59 +1,60 @@
1
- "use strict";
2
- /**
3
- * 브라우저와 방문 컨텍스트를 **여기서만** 연다.
4
- *
5
- * 9/21 에 드러난 버그 둘이 전부 "여는 곳이 여러 군데" 라서 생겼다.
6
- * - 비밀값 route 를 다른 route 보다 먼저 걸어 한 번도 안 나갔다(driver.js)
7
- * - UA 꼬리표가 데스크톱·링크 수집 방문에서 빠져 고객 SDK 에 진짜 방문으로 찍혔다(qa-explore.js)
8
- * 그래서 순서를 여기 하나로 고정한다: **UA 꼬리표 → 컨텍스트 → 방어선(guard) → 가상 사용자 표시(markAgent, 비밀값은 마지막)**.
9
- *
10
- * Playwright 는 받아 쓴다(이 파일은 어느 설치본인지 모른다).
11
- */
12
-
13
- const { markAgent, withAgentUA } = require("./card.js");
14
-
15
- /** HTTPS 사이트에서 localhost SDK·소켓을 부르려면 사설망 제한을 꺼야 한다. 로컬 SDK 로 돌릴 때만 필요하다 */
16
- const LOCAL_NETWORK_ARGS = ["--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults"];
17
-
18
- /**
19
- * 브라우저를 띄운다. 고객 컴퓨터에 깔린 Chrome 을 먼저 쓴다 — 수백 MB 를 몰래 내려받지 않는다(PLAN.md "브라우저").
20
- * @param localSdk 로컬 SDK·서버를 부르나(사설망 제한을 끈다)
21
- */
22
- async function launchBrowser(playwright, { headed = false, localSdk = false, preferChrome = true } = {}) {
23
- const opts = { headless: !headed, args: localSdk ? LOCAL_NETWORK_ARGS : [] };
24
- if (preferChrome) {
25
- try {
26
- return await playwright.chromium.launch({ ...opts, channel: "chrome" });
27
- } catch (e) {
28
- // Chrome 이 없다. Playwright 가 받아 둔 Chromium 으로
29
- }
30
- }
31
- try {
32
- return await playwright.chromium.launch(opts);
33
- } catch (e) {
34
- throw new Error("Could not start a browser. Install Google Chrome, or run `npx playwright install chromium` once.\n" + e.message);
35
- }
36
- }
37
-
38
- /**
39
- * 가상 사용자 한 명의 방문 컨텍스트.
40
- * @param devices Playwright devices
41
- * @param device "mobile" | "desktop"
42
- * @param guard createGuard(...) 결과
43
- * @param persona SDK 가 응답에 실을 그 사람 정보(markAgent)
44
- * @param storageState 로그인 세션(PLAN.md "로그인"). 없으면 로그인 안 한 사람
45
- * @param onOffsite 사이트 밖으로 가려던 주소를 받는다(guard.js)
46
- * @param onWrite 사이트에 쓰려다 끊긴 요청을 받는다(guard.js)
47
- */
48
- async function openVisit(browser, devices, { agent, site, guard, device = "desktop", persona = null, debugCards = true, storageState = undefined, onOffsite = null, onWrite = null, onExternal = null } = {}) {
49
- if (!agent) throw new Error("No agent name — a visit is never opened without identifying it as a virtual user");
50
- const base = device === "mobile" ? { ...devices["Pixel 7"] } : { viewport: { width: 1280, height: 860 } };
51
- delete base.defaultBrowserType;
52
- const ctx = await browser.newContext({ ...withAgentUA(agent, base, devices["Desktop Chrome"].userAgent), storageState });
53
- await guard.apply(ctx, { onOffsite, onWrite, onExternal });
54
- // **반드시 guard 뒤.** markAgent 의 비밀값 route 가 마지막에 걸려야 먼저 돈다
55
- await markAgent(ctx, agent, { siteOrigin: site, persona, debugCards, hideDevOverlay: guard.local });
56
- return ctx;
57
- }
58
-
59
- module.exports = { launchBrowser, openVisit, LOCAL_NETWORK_ARGS };
1
+ "use strict";
2
+ /**
3
+ * 브라우저와 방문 컨텍스트를 **여기서만** 연다.
4
+ *
5
+ * 9/21 에 드러난 버그 둘이 전부 "여는 곳이 여러 군데" 라서 생겼다.
6
+ * - 비밀값 route 를 다른 route 보다 먼저 걸어 한 번도 안 나갔다(driver.js)
7
+ * - UA 꼬리표가 데스크톱·링크 수집 방문에서 빠져 고객 SDK 에 진짜 방문으로 찍혔다(qa-explore.js)
8
+ * 그래서 순서를 여기 하나로 고정한다: **UA 꼬리표 → 컨텍스트 → 방어선(guard) → 가상 사용자 표시(markAgent, 비밀값은 마지막)**.
9
+ *
10
+ * Playwright 는 받아 쓴다(이 파일은 어느 설치본인지 모른다).
11
+ */
12
+
13
+ const { markAgent, withAgentUA } = require("./card.js");
14
+
15
+ /** HTTPS 사이트에서 localhost SDK·소켓을 부르려면 사설망 제한을 꺼야 한다. 로컬 SDK 로 돌릴 때만 필요하다 */
16
+ const LOCAL_NETWORK_ARGS = ["--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults"];
17
+
18
+ /**
19
+ * 브라우저를 띄운다. 고객 컴퓨터에 깔린 Chrome 을 먼저 쓴다 — 수백 MB 를 몰래 내려받지 않는다(PLAN.md "브라우저").
20
+ * @param localSdk 로컬 SDK·서버를 부르나(사설망 제한을 끈다)
21
+ */
22
+ async function launchBrowser(playwright, { headed = false, localSdk = false, preferChrome = true } = {}) {
23
+ const opts = { headless: !headed, args: localSdk ? LOCAL_NETWORK_ARGS : [] };
24
+ if (preferChrome) {
25
+ try {
26
+ return await playwright.chromium.launch({ ...opts, channel: "chrome" });
27
+ } catch (e) {
28
+ // Chrome 이 없다. Playwright 가 받아 둔 Chromium 으로
29
+ }
30
+ }
31
+ try {
32
+ return await playwright.chromium.launch(opts);
33
+ } catch (e) {
34
+ throw new Error("Could not start a browser. Install Google Chrome, or run `npx playwright install chromium` once.\n" + e.message);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 가상 사용자 한 명의 방문 컨텍스트.
40
+ * @param devices Playwright devices
41
+ * @param device "mobile" | "desktop"
42
+ * @param guard createGuard(...) 결과
43
+ * @param persona SDK 가 응답에 실을 그 사람 정보(markAgent)
44
+ * @param storageState 로그인 세션(PLAN.md "로그인"). 없으면 로그인 안 한 사람
45
+ * @param onOffsite 사이트 밖으로 가려던 주소를 받는다(guard.js)
46
+ * @param onWrite 사이트에 쓰려다 끊긴 요청을 받는다(guard.js)
47
+ * @param research 참여하러 온 리서치 id(study.js). 있으면 SDK 가 그 리서치 초대를 바로 띄운다
48
+ */
49
+ async function openVisit(browser, devices, { agent, site, guard, device = "desktop", persona = null, research = null, debugCards = true, storageState = undefined, onOffsite = null, onWrite = null, onExternal = null } = {}) {
50
+ if (!agent) throw new Error("No agent name — a visit is never opened without identifying it as a virtual user");
51
+ const base = device === "mobile" ? { ...devices["Pixel 7"] } : { viewport: { width: 1280, height: 860 } };
52
+ delete base.defaultBrowserType;
53
+ const ctx = await browser.newContext({ ...withAgentUA(agent, base, devices["Desktop Chrome"].userAgent), storageState });
54
+ await guard.apply(ctx, { onOffsite, onWrite, onExternal });
55
+ // **반드시 guard 뒤.** markAgent 의 비밀값 route 가 마지막에 걸려야 먼저 돈다
56
+ await markAgent(ctx, agent, { siteOrigin: site, persona, research, debugCards, hideDevOverlay: guard.local });
57
+ return ctx;
58
+ }
59
+
60
+ module.exports = { launchBrowser, openVisit, LOCAL_NETWORK_ARGS };