@usercase/mcp 0.0.0-stage → 0.2.1-next.0

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.
@@ -0,0 +1,211 @@
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 };
@@ -0,0 +1,368 @@
1
+ "use strict";
2
+ /**
3
+ * 손 — 마우스가 어떻게 가고, 어디를 겨냥하고, 어떻게 치는가.
4
+ *
5
+ * 정확히 한가운데를 누르고 오타 없이 치면 사람 기록이 아니다. 헛클릭도, 지웠다 다시 치는 것도
6
+ * 사이트가 보는 신호의 일부다. 어디까지 흔들리는지는 그 사람 성향에서 나온다(persona.js Rhythm).
7
+ *
8
+ * 빗나가는 것은 허용하되 **엉뚱한 버튼을 눌러 버리지는 않는다.** 그러면 기록이 거짓이 된다 —
9
+ * 누르기 직전에 알아채고 대상 안으로 끌어오는 쪽이 사람에 가깝다.
10
+ *
11
+ * 의존성 없음. Playwright page 와 Rhythm 을 받는다.
12
+ */
13
+
14
+ const OWN_SEL = "#eb-insight,#eb-plugin";
15
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
+ const rand = (a, b) => a + Math.random() * (b - a);
17
+
18
+ /** 커서를 그 자리까지. 직선으로 가지 않고 살짝 휘며, 가끔 지나쳤다 되돌아온다 */
19
+ async function moveTo(page, state, x, y) {
20
+ const vp = page.viewportSize() || { width: 1200, height: 800 };
21
+ const from = state.mouse || { x: vp.width / 2, y: vp.height * 0.4 };
22
+ const h = state.rhythm ? state.rhythm.hand() : { stride: 110, overshoot: 0.2 };
23
+ const dx = x - from.x;
24
+ const dy = y - from.y;
25
+ const dist = Math.hypot(dx, dy);
26
+ if (dist < 2) {
27
+ state.mouse = { x, y };
28
+ return;
29
+ }
30
+ const steps = Math.max(2, Math.min(28, Math.round(dist / h.stride) + 2));
31
+ const bow = Math.min(60, dist * 0.12) * (Math.random() < 0.5 ? -1 : 1);
32
+ await page.mouse.move(from.x + dx * 0.55 - (dy / dist) * bow, from.y + dy * 0.55 + (dx / dist) * bow, {
33
+ steps: Math.max(2, Math.round(steps * 0.6)),
34
+ });
35
+ if (dist > 80 && Math.random() < h.overshoot) {
36
+ const over = 6 + Math.random() * Math.min(40, dist * 0.08);
37
+ await page.mouse.move(x + (dx / dist) * over, y + (dy / dist) * over, { steps: Math.max(2, Math.round(steps * 0.4)) });
38
+ await sleep(40 + Math.random() * 90);
39
+ }
40
+ await page.mouse.move(x, y, { steps: Math.max(2, Math.round(steps * 0.4)) });
41
+ state.mouse = { x, y };
42
+ }
43
+
44
+ /** 어디를 겨냥하나. 한가운데가 아니라 그 사람 정확도만큼 흩어진 점. null 이면 좌표를 포기한다 */
45
+ async function aimAt(page, state, el) {
46
+ const box = await el.boundingBox().catch(() => null);
47
+ if (!box) return null;
48
+ const vp = page.viewportSize() || { width: 1200, height: 800 };
49
+ const cx = box.x + box.width / 2;
50
+ const cy = box.y + box.height / 2;
51
+ const o = state.rhythm ? state.rhythm.aimErrorPx() : { dx: 0, dy: 0 };
52
+ const x = cx + o.dx;
53
+ const y = cy + o.dy;
54
+ const outDist = Math.hypot(Math.max(box.x - x, 0, x - (box.x + box.width)), Math.max(box.y - y, 0, y - (box.y + box.height)));
55
+ const onScreen = (px, py) => px > 1 && py > 1 && px < vp.width - 1 && py < vp.height - 1;
56
+
57
+ const at = async (px, py) => {
58
+ if (!onScreen(px, py)) return "밖";
59
+ return el
60
+ .evaluate((node, [qx, qy, ownSel]) => {
61
+ const hit = document.elementFromPoint(qx, qy);
62
+ if (!hit) return "빈 곳";
63
+ if (node === hit || node.contains(hit)) return "대상";
64
+ if (hit.contains(node)) {
65
+ // 감싼 상자를 누른 것이다. 그 상자가 스스로 눌리는 것(링크·버튼·클릭이 붙은 것)일 때만 같은 조작이다.
66
+ // 크기만 보고 "대상" 으로 쳤더니 달력 Month 아래 여백을 눌러 놓고 눌린 줄 알았다 — 클릭은 안쪽 버튼에만 붙어 있었다(10/15 실험실 p14)
67
+ let selfClick = !!hit.closest('a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],summary,label');
68
+ for (const k in hit) if (k.startsWith("__reactProps") && hit[k] && typeof hit[k].onClick === "function") selfClick = true;
69
+ if (!selfClick) return "빈 곳";
70
+ const a = node.getBoundingClientRect();
71
+ const b = hit.getBoundingClientRect();
72
+ return b.width * b.height <= a.width * a.height * 3 ? "대상" : "빈 곳";
73
+ }
74
+ if (ownSel && hit.closest(ownSel)) return "가림";
75
+ return hit.closest('a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],summary,input,select,textarea,label')
76
+ ? "다른 조작"
77
+ : "빈 곳";
78
+ }, [px, py, OWN_SEL])
79
+ .catch(() => "가림");
80
+ };
81
+
82
+ const first = await at(x, y);
83
+ if (first === "대상") return { x, y, missed: false };
84
+ // 대상 밖 빈 곳 — 사람이 하는 헛클릭. 다만 아주 가까운 빗나감은 브라우저가 그 요소로 보내므로
85
+ // "빗나갔다" 고 적고 실제로는 눌리는 거짓 기록이 된다. 그럴 땐 테두리 안으로 끌어온다.
86
+ // 헛클릭은 빈 곳에 떨어질 때만 — 옆 버튼에 떨어지면 그 버튼이 눌려 "Join 이 관심목록으로 보낸다" 같은 가짜 결함이 된다.
87
+ // 끌어온 점도 대상인지 본다 — 아래 고정 메뉴에 깔린 버튼은 끌어온 점이 메뉴라 Watchlist 가 눌렸다(10/15 실험실 y57)
88
+ if (outDist > 0) {
89
+ const margin = state.rhythm ? state.rhythm.missMarginPx() : 6;
90
+ if (outDist >= margin && first === "빈 곳") return { x, y, missed: true };
91
+ const px = Math.min(Math.max(x, box.x + 2), box.x + box.width - 2);
92
+ const py = Math.min(Math.max(y, box.y + 2), box.y + box.height - 2);
93
+ if ((await at(px, py)) === "대상") return { x: px, y: py, missed: false };
94
+ }
95
+ return (await at(cx, cy)) === "대상" ? { x: cx, y: cy, missed: false } : null;
96
+ }
97
+
98
+ /**
99
+ * 그 요소 한가운데를 다른 것이 덮고 있나. 아니면 null.
100
+ * { say } 는 눈에 보이는 것(팝업 등)이 덮은 것 — 방문자에게 할 말.
101
+ * { invisible, x, y } 는 글자 없는 막 — 사람 눈에는 안 보이니 사람은 그냥 누르고, 클릭은 그 막에 닿는다
102
+ */
103
+ async function coveredBy(page, el) {
104
+ const box = await el.boundingBox().catch(() => null);
105
+ const vp = page.viewportSize() || { width: 1200, height: 800 };
106
+ if (!box) return null;
107
+ const cx = box.x + box.width / 2;
108
+ const cy = box.y + box.height / 2;
109
+ if (cx < 1 || cy < 1 || cx > vp.width - 1 || cy > vp.height - 1) return null;
110
+ const hit = await el
111
+ .evaluate(
112
+ (node, [qx, qy, ownSel]) => {
113
+ const h = document.elementFromPoint(qx, qy);
114
+ if (!h || node === h || node.contains(h) || h.contains(node)) return null;
115
+ if (ownSel && h.closest(ownSel)) return { own: true };
116
+ // 덮은 덩어리 — 대화상자·팝업이면 그 전체, 아니면 맞은 요소
117
+ // 덮은 것이 링크·버튼 안이면 눈에 보이는 조작이다. 그 안의 그림(아이콘)만 맞으면 글자가 없어 "투명한 막" 으로 보고
118
+ // 그냥 눌렀다 — 아래 고정 메뉴의 Watchlist 아이콘이 눌려 관심목록으로 갔다(10/15 실험실 y57, 정보 창의 Join)
119
+ const ctl = h.closest('a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"]');
120
+ const box = h.closest('[role="dialog"],[role="alertdialog"],dialog,[aria-modal="true"]') || ctl || h;
121
+ const text = String(box.innerText || box.getAttribute("aria-label") || box.getAttribute("title") || "").replace(/\s+/g, " ").trim();
122
+ return { text: text.slice(0, 80) || (ctl ? "another button" : "") };
123
+ },
124
+ [cx, cy, OWN_SEL],
125
+ )
126
+ .catch(() => null);
127
+ if (!hit) return null;
128
+ if (hit.own) return { say: "the survey card is on top of it" };
129
+ if (hit.text) return { say: `something else on the screen is on top of it ("${hit.text}") — you cannot reach it until that is out of the way` };
130
+ return { invisible: true, x: cx, y: cy };
131
+ }
132
+
133
+ /** 손가락으로 옆으로 민다(Chrome 터치 입력). 터치가 없는 화면이면 아무 일 없다 */
134
+ async function touchSwipe(page, x0, x1, y) {
135
+ const cdp = await page.context().newCDPSession(page);
136
+ try {
137
+ const pt = (x) => [{ x: Math.round(x), y: Math.round(y), id: 1 }];
138
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: pt(x0) });
139
+ for (let i = 1; i <= 10; i++) {
140
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: pt(x0 + ((x1 - x0) * i) / 10) });
141
+ await sleep(rand(12, 28));
142
+ }
143
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
144
+ } finally {
145
+ await cdp.detach().catch(() => {});
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 가로로 넘치는 줄을 옆으로 밀어 그 요소를 화면 안으로 가져온다. 됐으면 true.
151
+ * 스크롤되는 줄이면 옆으로 굴리고, 아니면(transform 으로 넘기는 회전 배너) 손가락으로 끌듯 끈다
152
+ */
153
+ async function swipeIntoView(page, state, el, vw) {
154
+ for (let tries = 0; tries < 4; tries++) {
155
+ const info = await el.evaluate((n) => {
156
+ const r = n.getBoundingClientRect();
157
+ for (let p = n.parentElement, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
158
+ const cs = getComputedStyle(p);
159
+ if (cs.overflowX === "visible") continue;
160
+ const pr = p.getBoundingClientRect();
161
+ if (pr.bottom < 0 || pr.top > innerHeight || pr.width < 40) return null;
162
+ const scrollable = /auto|scroll/.test(cs.overflowX) && p.scrollWidth > p.clientWidth + 2;
163
+ return { scrollable, cx: r.x + r.width / 2, row: { x: pr.x, y: pr.y, w: pr.width, h: pr.height } };
164
+ }
165
+ // 잘린 줄이 없는데 페이지 자체가 화면보다 넓다(모바일 배치가 넘친 사이트) — 사람은 페이지를 옆으로 끌어 본다
166
+ const vwNow = (window.visualViewport && visualViewport.width) || document.documentElement.clientWidth;
167
+ if (document.documentElement.scrollWidth > vwNow + 2) return { page: true, cx: r.x + r.width / 2 };
168
+ return null;
169
+ });
170
+ if (!info) return false;
171
+ if (info.page) {
172
+ await page.evaluate((dx) => window.scrollBy({ left: dx, behavior: "instant" }), info.cx - vw / 2);
173
+ await sleep(rand(300, 600));
174
+ const now = await el.boundingBox().catch(() => null);
175
+ return !!(now && now.x + now.width / 2 > 1 && now.x + now.width / 2 < vw - 1);
176
+ }
177
+ const target = vw / 2;
178
+ if (info.cx > 1 && info.cx < vw - 1 && tries > 0) return true;
179
+ const delta = info.cx - target;
180
+ if (info.scrollable) {
181
+ await el.evaluate((n, dx) => {
182
+ for (let p = n.parentElement; p && p !== document.body; p = p.parentElement) {
183
+ const cs = getComputedStyle(p);
184
+ if (/auto|scroll/.test(cs.overflowX) && p.scrollWidth > p.clientWidth + 2) return p.scrollBy({ left: dx, behavior: "instant" });
185
+ }
186
+ }, delta);
187
+ } else {
188
+ // 줄 가운데를 잡고 반대쪽으로 끈다. 한 번에 다 못 오면 다시 끈다
189
+ const y = Math.min(Math.max(info.row.y + info.row.h / 2, 5), (page.viewportSize() || { height: 800 }).height - 5);
190
+ const x0 = Math.min(Math.max(vw / 2 + Math.sign(delta) * vw * 0.3, 10), vw - 10);
191
+ const x1 = Math.min(Math.max(x0 - Math.sign(delta) * Math.min(Math.abs(delta) + 40, vw * 0.6), 5), vw - 5);
192
+ const before = await el.boundingBox().catch(() => null);
193
+ await moveTo(page, state, x0, y);
194
+ await page.mouse.down();
195
+ for (let i = 1; i <= 8; i++) {
196
+ await page.mouse.move(x0 + ((x1 - x0) * i) / 8, y + rand(-2, 2));
197
+ await sleep(rand(12, 30));
198
+ }
199
+ await page.mouse.up();
200
+ state.mouse = { x: x1, y };
201
+ await sleep(rand(350, 600));
202
+ // 휴대폰 화면에서는 배너가 손가락 밀기에만 반응한다 — 마우스로 안 움직였으면 터치로 민다
203
+ const after = await el.boundingBox().catch(() => null);
204
+ if (before && after && Math.abs(after.x - before.x) < 5) await touchSwipe(page, x0, x1, y).catch(() => {});
205
+ }
206
+ await sleep(rand(450, 800));
207
+ const now = await el.boundingBox().catch(() => null);
208
+ if (now && now.x + now.width / 2 > 1 && now.x + now.width / 2 < vw - 1) return true;
209
+ }
210
+ return false;
211
+ }
212
+
213
+ /**
214
+ * 그 요소를 사람처럼 누른다.
215
+ * @returns { missed: boolean } missed 면 빈 곳을 눌러 아무 일도 안 일어난 것이다
216
+ */
217
+ async function clickLikePerson(page, state, el) {
218
+ // 위아래로만 굴린다. 사람은 휴대폰에서 화면을 옆으로 끌어 숨은 버튼을 찾지 않는다 — 옆으로 굴리면 화면 밖 것을 끌어와 눌렀다(10/15)
219
+ await el
220
+ .evaluate((n) => {
221
+ const r = n.getBoundingClientRect();
222
+ if (r.top < 0 || r.bottom > innerHeight) window.scrollBy(0, r.top - Math.max(40, innerHeight * 0.25));
223
+ })
224
+ .catch(() => {});
225
+ let aim = await aimAt(page, state, el);
226
+ if (!aim) {
227
+ // 화면 끝에 걸쳐 고정 막대(위 머리줄·아래 메뉴)에 가린 것은 사람이 조금 더 굴려서 누른다.
228
+ // 이걸 안 하고 "덮였다" 고 하면 모바일 아래 메뉴 밑 항목이 안 눌린다(10/14 bluetags p28, 0.1.12)
229
+ await el
230
+ .evaluate((n) => {
231
+ const r = n.getBoundingClientRect();
232
+ window.scrollBy(0, r.top + r.height / 2 - innerHeight / 2);
233
+ })
234
+ .catch(() => {});
235
+ await sleep(rand(150, 350));
236
+ aim = await aimAt(page, state, el);
237
+ }
238
+ if (!aim) {
239
+ // 다른 것이 위에 덮여 있으면 사람은 그게 눈에 보인다. 8초 기다려 "Timeout" 을 전하면
240
+ // 방문자는 화면이 멈췄다고 읽고 나간다(10/14 bluetags p4) — 무엇이 덮었는지 바로 말한다
241
+ const cover = await coveredBy(page, el);
242
+ if (cover && cover.invisible) {
243
+ // 투명한 막. 사람은 못 보니 그 자리를 그대로 누른다 — 클릭은 막에 닿고, 막이 닫히는 사이트가 많다.
244
+ // 안 누르고 "아무 일 없다" 고만 하면 막이 영영 안 닫혀 메뉴가 다 죽은 것처럼 된다(10/14 bluetags 달력 날짜 뒤, 0.1.12~13)
245
+ await moveTo(page, state, cover.x, cover.y);
246
+ await sleep(rand(40, 140));
247
+ await page.mouse.click(cover.x, cover.y);
248
+ // 막이 걷혔으면 클릭은 떠 있던 창을 닫은 것이다. 사람은 그걸 눈으로 안다 — 모르면 "링크가 한 번에 안 된다" 가 된다(10/14 실험실 p3)
249
+ // 닫히는 데 애니메이션이 붙은 창이 있다 — 0.4초에 한 번만 보면 놓쳤다(10/15 실험실 p15). 1.5초까지 본다
250
+ let gone = false;
251
+ for (let i = 0; i < 6 && !gone; i++) {
252
+ await sleep(250);
253
+ const still = await coveredBy(page, el).catch(() => null);
254
+ gone = !(still && still.invisible);
255
+ }
256
+ return { missed: false, closedOverlay: gone };
257
+ }
258
+ if (cover) {
259
+ const e = new Error(cover.say);
260
+ e.human = cover.say;
261
+ throw e;
262
+ }
263
+ // 화면 가장자리 밖으로 잘려 나간 것. 굴려도 옆으로는 안 온다 — 사람은 "잘려 있다" 를 본다.
264
+ // 이걸 Playwright 에 맡기면 "덮였다" 로 나왔다(10/15 실험실 p18: 모바일에서 공유 창 오른쪽 절반이 화면 밖)
265
+ let bb = await el.boundingBox().catch(() => null);
266
+ const vw = (page.viewportSize() || { width: 1200 }).width;
267
+ // 가로로 넘치는 줄(회전 배너·가로 목록) 안에서 잘린 것이면, 사람은 그 줄을 옆으로 밀어 가져온 뒤 누른다
268
+ // (10/15 실험실 p1·p3: 모바일 첫 화면 배너 끝에 걸친 카드를 끝내 못 눌렀다)
269
+ if (bb && (bb.x + bb.width / 2 > vw - 1 || bb.x + bb.width / 2 < 1)) {
270
+ const swiped = await swipeIntoView(page, state, el, vw).catch(() => false);
271
+ if (swiped) {
272
+ bb = await el.boundingBox().catch(() => null);
273
+ const again = await aimAt(page, state, el);
274
+ if (again && bb && bb.x + bb.width / 2 > 1 && bb.x + bb.width / 2 < vw - 1) {
275
+ await moveTo(page, state, again.x, again.y);
276
+ await sleep(rand(40, 140));
277
+ await page.mouse.click(again.x, again.y);
278
+ return { missed: again.missed, swiped: true };
279
+ }
280
+ }
281
+ }
282
+ if (bb && (bb.x + bb.width / 2 > vw - 1 || bb.x + bb.width / 2 < 1)) {
283
+ const say = bb.x < vw && bb.x + bb.width > 0 ? "it is cut off at the edge of the screen — only a sliver of it shows, and you cannot reach it" : "it is off the edge of the screen and you cannot reach it";
284
+ const e = new Error(say);
285
+ e.human = say;
286
+ throw e;
287
+ }
288
+ // 좌표를 포기한다
289
+ await el.click({ timeout: 8000 });
290
+ return { missed: false };
291
+ }
292
+ await moveTo(page, state, aim.x, aim.y);
293
+ await sleep(rand(40, 140));
294
+ await page.mouse.click(aim.x, aim.y);
295
+ return { missed: aim.missed };
296
+ }
297
+
298
+ const NEAR = { a: "s", s: "d", d: "f", f: "g", g: "h", h: "j", j: "k", k: "l", l: "k", q: "w", w: "e", e: "r", r: "t", t: "y", y: "u", u: "i", i: "o", o: "p", p: "o", z: "x", x: "c", c: "v", v: "b", b: "n", n: "m", m: "n" };
299
+
300
+ /** 사람처럼 친다. 가끔 잘못 치고 알아채서 지운다. 칸에 남는 글자는 언제나 원래 치려던 것이다 */
301
+ async function typeLikePerson(page, state, text) {
302
+ if (!state.rhythm) {
303
+ await page.keyboard.type(text, { delay: rand(70, 160) });
304
+ return 0;
305
+ }
306
+ const h = state.rhythm.hand();
307
+ const wrongFor = (ch, i) => {
308
+ const low = ch.toLowerCase();
309
+ if (NEAR[low]) return ch === low ? NEAR[low] : NEAR[low].toUpperCase();
310
+ const pool = text.replace(new RegExp(ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "");
311
+ return pool ? pool[(i * 7 + 3) % pool.length] : "x";
312
+ };
313
+ let typos = 0;
314
+ for (let i = 0; i < text.length; i++) {
315
+ await sleep(state.rhythm.keyPause());
316
+ if (text.length > 2 && Math.random() < h.typoRate) {
317
+ typos++;
318
+ await page.keyboard.type(wrongFor(text[i], i));
319
+ let extra = 0;
320
+ if (Math.random() < 0.45 && i + 1 < text.length) {
321
+ await sleep(state.rhythm.keyPause());
322
+ await page.keyboard.type(text[i + 1]);
323
+ extra = 1;
324
+ }
325
+ await sleep(180 + Math.random() * 420); // 어? 하는 순간
326
+ for (let k = 0; k <= extra; k++) {
327
+ await page.keyboard.press("Backspace");
328
+ await sleep(60 + Math.random() * 120);
329
+ }
330
+ }
331
+ await page.keyboard.type(text[i]);
332
+ }
333
+ return typos;
334
+ }
335
+
336
+ /** 스크롤도 한 번에 정확히 가지 않는다. 지나쳤다 조금 되돌아오기도 한다 */
337
+ async function scrollLikePerson(page, state, dir = 1) {
338
+ const h = state.rhythm ? state.rhythm.hand() : { stride: 110 };
339
+ const total = (300 + Math.random() * 700) * dir;
340
+ const chunks = 2 + Math.round(Math.random() * 2);
341
+ const y0 = await page.evaluate(() => scrollY).catch(() => null);
342
+ for (let i = 0; i < chunks; i++) {
343
+ await page.mouse.wheel(0, total / chunks);
344
+ await sleep(rand(80, 260));
345
+ // 커서가 메뉴·고정 막대 위에 있으면 휠이 페이지를 안 굴린다. 사람은 커서를 본문 쪽으로 옮기고 다시 굴린다
346
+ // (10/13: 약관 링크를 누른 뒤 세 번 굴려도 0px)
347
+ if (i === 0 && y0 !== null && (await page.evaluate(() => scrollY).catch(() => null)) === y0) {
348
+ const vp = page.viewportSize() || { width: 1200, height: 800 };
349
+ await moveTo(page, state, vp.width * rand(0.45, 0.6), vp.height * rand(0.45, 0.65));
350
+ await page.mouse.wheel(0, total / chunks);
351
+ await sleep(rand(80, 260));
352
+ }
353
+ }
354
+ // 가끔 조금 되돌린다(지나쳤다 싶어서). 실제로 움직였고 끝에 닿지 않았을 때만 —
355
+ // 끝에서 되돌리면 "내렸는데 덜 내려가고", 맨 위에서 위로 굴리면 오히려 내려갔다(10/14 실험실)
356
+ const pos = async () => page.evaluate(() => ({ y: scrollY, max: document.documentElement.scrollHeight - innerHeight })).catch(() => null);
357
+ const after = await pos();
358
+ const moved = after && y0 !== null ? after.y - y0 : 0;
359
+ const atEnd = after && (dir > 0 ? after.y >= after.max - 2 : after.y <= 2);
360
+ if (Math.sign(moved) === Math.sign(dir) && Math.abs(moved) > 120 && !atEnd && Math.random() < 0.25) {
361
+ await sleep(rand(150, 500));
362
+ await page.mouse.wheel(0, -moved * (0.15 + Math.random() * 0.2));
363
+ }
364
+ const end = await pos();
365
+ return end && y0 !== null ? Math.round(end.y - y0) : Math.round(total);
366
+ }
367
+
368
+ module.exports = { moveTo, aimAt, clickLikePerson, typeLikePerson, scrollLikePerson };
@@ -0,0 +1,59 @@
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 };