@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,182 @@
1
+ "use strict";
2
+ /**
3
+ * 고객 저장소의 `.usercase/` — 사람 파일과 방문 기록(PLAN.md "사람 파일은 어디에").
4
+ *
5
+ * - `people.json` 사이트·사이트 설명·가상 사용자들. 고객이 열어 고치고, git 에 넣을지 스스로 정한다
6
+ * - `runs/*.jsonl` 방문 기록. 걸음마다 이유가 남는다 — "왜 여기서 나갔지" 를 확인하는 근거
7
+ * - `sessions/` 데모 계정마다 로그인 세션 하나. **로그인 그 자체라** git 에서 뺀다
8
+ * - `settings.json` 사용자가 고른 것 — 사이트에 쓰는 것을 보낼지(writes)
9
+ *
10
+ * MCP 서버는 AI 도구가 저장소 폴더에서 띄우므로 기본 위치는 cwd 다. `USERCASE_DIR` 로 바꾼다.
11
+ */
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ const DIR = process.env.USERCASE_DIR || path.join(process.cwd(), ".usercase");
16
+
17
+ /** 0.1.x 까지는 이름이 earningbirds 라 `.earningbirds/` 였다. 새 폴더가 없을 때 한 번 옮긴다 */
18
+ (function migrateDir() {
19
+ if (process.env.USERCASE_DIR) return;
20
+ const old = path.join(process.cwd(), ".earningbirds");
21
+ if (!fs.existsSync(old) || fs.existsSync(DIR)) return;
22
+ try {
23
+ fs.renameSync(old, DIR);
24
+ } catch {} // 다른 프로그램이 잡고 있으면 새 폴더로 새로 시작한다
25
+ })();
26
+ const PEOPLE = path.join(DIR, "people.json");
27
+ const RUNS = path.join(DIR, "runs");
28
+ const SESSION = path.join(DIR, "session.json");
29
+ const SETTINGS = path.join(DIR, "settings.json");
30
+
31
+ /** 사용자가 고른 것. 없으면 기본 — 사이트에 쓰는 것은 보내지 않는다 */
32
+ function loadSettings() {
33
+ try {
34
+ return { writes: "block", ...JSON.parse(fs.readFileSync(SETTINGS, "utf8")) };
35
+ } catch {
36
+ return { writes: "block" };
37
+ }
38
+ }
39
+
40
+ function saveSettings(patch) {
41
+ ensure();
42
+ const next = { ...loadSettings(), ...patch };
43
+ fs.writeFileSync(SETTINGS, JSON.stringify(next, null, 2));
44
+ return next;
45
+ }
46
+
47
+ /**
48
+ * 데모 계정마다 세션 하나 — `sessions/<계정>.json`. 회원 방문자를 계정들에 나눠 앉힌다.
49
+ * 한 계정을 모두가 같이 쓰면 관심목록·기록이 한 사람 것이 된다(10/14).
50
+ * 계정 이름은 사용자가 알아볼 짧은 이름(a1, a2 …). 비밀번호는 어디에도 없다 — 사용자가 창에서 직접 로그인한다
51
+ */
52
+ const SESSIONS = path.join(DIR, "sessions");
53
+ const ACCOUNTS_INDEX = path.join(SESSIONS, "index.json");
54
+ const ACCOUNT_NAME = /^[a-z0-9][a-z0-9_-]{0,23}$/i;
55
+
56
+ const sessionPath = (account) => path.join(SESSIONS, `${account}.json`);
57
+
58
+ function readIndex() {
59
+ try {
60
+ return JSON.parse(fs.readFileSync(ACCOUNTS_INDEX, "utf8"));
61
+ } catch {
62
+ return {};
63
+ }
64
+ }
65
+
66
+ /** 0.1.10 까지는 session.json 하나였다. 계정 a1 로 옮긴다 */
67
+ function migrateSession() {
68
+ if (!fs.existsSync(SESSION) || fs.existsSync(sessionPath("a1"))) return;
69
+ fs.mkdirSync(SESSIONS, { recursive: true });
70
+ fs.renameSync(SESSION, sessionPath("a1"));
71
+ }
72
+
73
+ /**
74
+ * 저장된 계정들. status 는 마지막으로 확인한 로그인 상태:
75
+ * signed_in · signed_out(로그인 안 됨) · expired(방문 중 풀림) · unknown(사이트에 비교할 로그인 링크가 없음) · unchecked
76
+ */
77
+ function accounts() {
78
+ migrateSession();
79
+ if (!fs.existsSync(SESSIONS)) return [];
80
+ const idx = readIndex();
81
+ return fs
82
+ .readdirSync(SESSIONS)
83
+ .filter((f) => f.endsWith(".json") && f !== "index.json")
84
+ .map((f) => {
85
+ const account = f.slice(0, -5);
86
+ const st = fs.statSync(path.join(SESSIONS, f));
87
+ const meta = idx[account] || {};
88
+ return {
89
+ account,
90
+ path: path.join(SESSIONS, f),
91
+ savedAt: st.mtime.toISOString(),
92
+ ageH: Math.round((Date.now() - st.mtimeMs) / 36e5),
93
+ // 코드로 본 수명이 지났으면 확인 없이도 만료로 본다
94
+ status: meta.status === "signed_in" && typeof meta.lifetimeH === "number" && Date.now() > st.mtimeMs + meta.lifetimeH * 36e5 ? "expired" : meta.status || "unchecked",
95
+ note:
96
+ meta.status === "signed_in" && typeof meta.lifetimeH === "number" && Date.now() > st.mtimeMs + meta.lifetimeH * 36e5
97
+ ? `its session lifetime (${meta.lifetimeH}h) has passed`
98
+ : meta.note || null,
99
+ checkedAt: meta.checkedAt || null,
100
+ via: meta.via || null,
101
+ lifetimeH: typeof meta.lifetimeH === "number" ? meta.lifetimeH : null,
102
+ // 수명을 알면 언제 끝나나(저장한 때부터)
103
+ expiresAt: typeof meta.lifetimeH === "number" ? new Date(st.mtimeMs + meta.lifetimeH * 36e5).toISOString() : null,
104
+ };
105
+ })
106
+ .sort((a, b) => a.account.localeCompare(b.account, undefined, { numeric: true }));
107
+ }
108
+
109
+ /**
110
+ * @param extra 같이 남길 것 — via(어느 창으로 로그인했나: chrome·playwright), lifetimeH(코드로 본 세션 수명, 시간)
111
+ * 안 주면 전에 적은 것을 둔다
112
+ */
113
+ function setAccountStatus(account, status, note = null, extra = {}) {
114
+ ensure();
115
+ const idx = readIndex();
116
+ idx[account] = { ...(idx[account] || {}), ...extra, status, note, checkedAt: new Date().toISOString() };
117
+ fs.writeFileSync(ACCOUNTS_INDEX, JSON.stringify(idx, null, 2));
118
+ }
119
+
120
+ /** 다음 빈 계정 이름 */
121
+ function nextAccount() {
122
+ const have = new Set(accounts().map((a) => a.account));
123
+ let i = 1;
124
+ while (have.has(`a${i}`)) i++;
125
+ return `a${i}`;
126
+ }
127
+
128
+ function ensure() {
129
+ fs.mkdirSync(RUNS, { recursive: true });
130
+ fs.mkdirSync(SESSIONS, { recursive: true });
131
+ const gi = path.join(DIR, ".gitignore");
132
+ // 방문 기록에는 사람 정보와 사이트 글자가 들어가고 세션은 로그인 그 자체다. 경로 화면(paths/)도 로그인한 화면일 수 있다. 저장소에 올라가지 않게
133
+ if (!fs.existsSync(gi)) fs.writeFileSync(gi, "session.json\nsessions/\nruns/\npaths/\n");
134
+ else {
135
+ let cur = fs.readFileSync(gi, "utf8");
136
+ for (const line of ["sessions/", "paths/"])
137
+ if (!new RegExp(`^${line.replace("/", "\\/")}$`, "m").test(cur)) {
138
+ fs.appendFileSync(gi, (cur.endsWith("\n") ? "" : "\n") + line + "\n");
139
+ cur = fs.readFileSync(gi, "utf8");
140
+ }
141
+ }
142
+ }
143
+
144
+ function loadPeople() {
145
+ if (!fs.existsSync(PEOPLE)) return { site: null, siteAbout: null, people: [] };
146
+ const doc = JSON.parse(fs.readFileSync(PEOPLE, "utf8"));
147
+ // 예전 파일은 사람마다 이름이 있었다 — 번호로 바꾼다. 고유 값이 없던 사람은 여기서 받고 바로 적어 둔다(읽을 때마다 바뀌면 안 된다)
148
+ const lacked = (doc.people ?? []).some((p) => !p.uid);
149
+ doc.people = require("../src/persona/people.js").withIds(doc.people ?? [], []).existing;
150
+ if (lacked) savePeople(doc);
151
+ return doc;
152
+ }
153
+
154
+ function savePeople(doc) {
155
+ ensure();
156
+ fs.writeFileSync(PEOPLE, JSON.stringify(doc, null, 2));
157
+ }
158
+
159
+ function appendRun(row) {
160
+ ensure();
161
+ const file = path.join(RUNS, `${new Date().toISOString().slice(0, 10)}.jsonl`);
162
+ fs.appendFileSync(file, JSON.stringify(row) + "\n");
163
+ return file;
164
+ }
165
+
166
+ /** 최근 방문 기록. 파일이 날짜별이라 뒤에서부터 읽는다 */
167
+ function recentRuns(limit = 50) {
168
+ if (!fs.existsSync(RUNS)) return [];
169
+ const rows = [];
170
+ for (const f of fs.readdirSync(RUNS).filter((x) => x.endsWith(".jsonl")).sort().reverse()) {
171
+ const lines = fs.readFileSync(path.join(RUNS, f), "utf8").trim().split("\n").filter(Boolean).reverse();
172
+ for (const l of lines) {
173
+ try {
174
+ rows.push(JSON.parse(l));
175
+ } catch {}
176
+ if (rows.length >= limit) return rows;
177
+ }
178
+ }
179
+ return rows;
180
+ }
181
+
182
+ module.exports = { DIR, PEOPLE, RUNS, SESSION, SESSIONS, SETTINGS, ACCOUNT_NAME, sessionPath, accounts, setAccountStatus, nextAccount, loadSettings, saveSettings, ensure, loadPeople, savePeople, appendRun, recentRuns };
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1-next.0",
4
+ "description": "Virtual users that browse your website and tell you what they thought — an MCP server for Claude Code",
5
+ "license": "UNLICENSED",
6
+ "homepage": "https://usercase.io",
7
+ "type": "commonjs",
8
+ "main": "mcp/server.js",
9
+ "bin": {
10
+ "usercase-mcp": "mcp/server.js"
11
+ },
12
+ "files": [
13
+ "mcp/",
14
+ "src/browser/",
15
+ "src/persona/",
16
+ "src/run/",
17
+ "src/prompts/",
18
+ "agents/",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "check": "node --check mcp/server.js && node scripts/onboarding.js --check && node scripts/agents.js --check",
29
+ "docs": "node scripts/onboarding.js && node scripts/agents.js",
30
+ "release:next": "node scripts/release.js next",
31
+ "release:promote": "node scripts/release.js promote",
32
+ "release:rollback": "node scripts/release.js rollback",
33
+ "release:status": "node scripts/release.js status"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.30.0",
37
+ "playwright": "^1.58.2",
38
+ "zod": "^4.6.5"
39
+ },
40
+ "devDependencies": {
41
+ "@google/genai": "^2.23.0"
42
+ }
43
+ }
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ /**
3
+ * 사이트가 우리를 막고 있는가. **오진을 막는 장치다.**
4
+ *
5
+ * 고객 사이트 앞에는 보통 Cloudflare·WAF·rate limit 이 있고, 가상 사용자 여럿이 몰리면
6
+ * 공격으로 읽혀 차단된다. 그때 우리가 "방문 실패" 라고만 보고하면 고객은
7
+ * **"너희 도구가 안 되네"** 로 읽는다. 실제로는 고객 인프라가 막은 것이고,
8
+ * 고객이 자기 설정 한 줄로 풀 수 있는 문제다.
9
+ *
10
+ * 2026-09-21 에 실제로 겪었다 — 여기어때는 Cloudflare 차단 페이지(응답을 주면서 거부),
11
+ * 사람인은 연결 리셋(응답 없이 끊음). 두 층이 달라서 신호도 다르다.
12
+ * 우리 쪽은 멀쩡한데(로컬 SDK 200) 사이트가 끊는 것을 알아채는 데 한참 걸렸다.
13
+ *
14
+ * **어느 규칙이 막았는지는 모른다.** 그건 짐작하지 않는다.
15
+ * "이런 모양으로 막혔고, 이 업체는 보통 이렇게 푼다" 까지만 말한다.
16
+ *
17
+ * 의존성 없음. Playwright page 객체만 받는다.
18
+ */
19
+
20
+ const { AGENT_HEADER, UA_TAG, KEY_HEADER, KEY_ENV } = require("./card.js");
21
+
22
+ /** 지시문 안에서 비밀값 자리. 고객이 자기 값으로 바꿔 넣는다 */
23
+ const SECRET = "<your-secret>";
24
+
25
+ /** 응답 없이 연결이 끊기는 것들. 한 번은 흔하지만 반복되면 사이트가 끊는 것이다 */
26
+ const NET_ERRORS = /ERR_CONNECTION_RESET|ERR_CONNECTION_CLOSED|ERR_CONNECTION_REFUSED|ERR_TIMED_OUT|ERR_EMPTY_RESPONSE|ERR_SOCKET_NOT_CONNECTED/i;
27
+
28
+ /** 차단 페이지가 스스로 밝히는 글자. 제목과 본문 앞부분에서 찾는다 */
29
+ const BLOCK_TEXT =
30
+ /you have been blocked|attention required|access denied|forbidden|request blocked|blocked by|security check|are you a robot|unusual traffic|rate limit|too many requests|비정상적인 접근|접근이 차단|잠시 후 다시/i;
31
+
32
+ /** 응답 헤더로 업체를 가른다. 업체를 알아야 푸는 법을 정확히 말해줄 수 있다 */
33
+ const VENDORS = [
34
+ { id: "cloudflare", name: "Cloudflare", test: (h) => !!h["cf-ray"] || /cloudflare/i.test(h["server"] || "") },
35
+ { id: "aws", name: "AWS (CloudFront·WAF)", test: (h) => !!h["x-amz-cf-id"] || /cloudfront/i.test(h["via"] || "") },
36
+ { id: "akamai", name: "Akamai", test: (h) => !!h["x-akamai-transformed"] || /akamai/i.test(h["server"] || "") },
37
+ { id: "sucuri", name: "Sucuri", test: (h) => !!h["x-sucuri-id"] },
38
+ { id: "vercel", name: "Vercel", test: (h) => /vercel/i.test(h["server"] || "") || !!h["x-vercel-id"] },
39
+ ];
40
+
41
+ /**
42
+ * 업체별로 실제로 하는 일. 규칙 이름은 그 제품 화면의 것을 그대로 쓴다.
43
+ *
44
+ * **통과 조건은 `KEY_HEADER` 의 값이다. `AGENT_HEADER` 가 "있으면" 으로 걸면 안 된다** —
45
+ * 그 헤더는 누구나 넣을 수 있어서, 그렇게 열면 아무 봇이나 그 줄 하나로 WAF 를 지나간다.
46
+ *
47
+ * `docs/onboarding.md` 의 업체별 절은 이 목록에서 뽑는다(`npm run docs`). 문서를 손으로 고치지 말 것.
48
+ */
49
+ const FIXES = {
50
+ cloudflare: [
51
+ `Security → WAF → Custom rules → Create rule. Expression: \`any(http.request.headers["${KEY_HEADER.toLowerCase()}"][*] eq "${SECRET}")\` → action Skip, skipping: the remaining Custom rules, Rate limiting rules, Managed rules and Super Bot Fight Mode`,
52
+ "Bot Fight Mode on the Free plan cannot be skipped by a rule — turn it off while the visits run, or run on staging",
53
+ "Or allow this computer's IP in Security → WAF → Tools (IP Access Rules)",
54
+ ],
55
+ aws: [
56
+ `Add a rule to the WAF web ACL: header \`${KEY_HEADER.toLowerCase()}\` Exactly matches string \`${SECRET}\` → Allow. Give it a higher priority (lower number) than rate-based and Bot Control rules`,
57
+ "Or add this computer's IP to an IP set and allow it",
58
+ ],
59
+ akamai: [`In Bot Manager, add requests whose \`${KEY_HEADER}\` header equals \`${SECRET}\` to the allowlist`, "Or allow this computer's IP"],
60
+ sucuri: ["Add this computer's IP to the allowlist in the firewall settings"],
61
+ vercel: [
62
+ `Firewall → Custom rules → condition Request Header \`${KEY_HEADER.toLowerCase()}\` Equals \`${SECRET}\` → action Bypass`,
63
+ "Or turn off Attack Challenge Mode for a while",
64
+ ],
65
+ unknown: [
66
+ `In front of the site (WAF, CDN or reverse proxy), let through requests whose \`${KEY_HEADER}\` header equals \`${SECRET}\``,
67
+ "Or add this computer's IP to the allowlist",
68
+ ],
69
+ };
70
+
71
+ /**
72
+ * 한 번의 실행 동안 신호를 모은다. 방문마다 새로 만들지 말 것 —
73
+ * **반복되는지**가 판단의 핵심이라 실행 전체에서 세야 한다.
74
+ */
75
+ function createBlockWatch({ minNetErrors = 3 } = {}) {
76
+ const netErrors = [];
77
+ const blockPages = [];
78
+ let vendor = null;
79
+ let lastStatus = null;
80
+
81
+ return {
82
+ /** 페이지에 달아 둔다. 응답·실패를 모두 본다 */
83
+ watch(page, siteOrigin) {
84
+ page.on("response", (res) => {
85
+ let u;
86
+ try {
87
+ u = new URL(res.url());
88
+ } catch {
89
+ return;
90
+ }
91
+ if (u.origin !== siteOrigin) return;
92
+ const h = res.headers();
93
+ if (!vendor) {
94
+ const hit = VENDORS.find((v) => v.test(h));
95
+ if (hit) vendor = hit;
96
+ }
97
+ if (res.status() === 403 || res.status() === 429 || res.status() === 503) lastStatus = res.status();
98
+ });
99
+ page.on("requestfailed", (req) => {
100
+ let u;
101
+ try {
102
+ u = new URL(req.url());
103
+ } catch {
104
+ return;
105
+ }
106
+ if (u.origin !== siteOrigin) return;
107
+ const err = req.failure()?.errorText || "";
108
+ if (NET_ERRORS.test(err)) netErrors.push({ at: new Date().toISOString(), err, path: u.pathname });
109
+ });
110
+ },
111
+
112
+ /** 화면이 차단 페이지인가. 제목과 본문 앞부분만 본다 */
113
+ notePage({ title = "", text = "", path = "" }) {
114
+ const head = `${title}\n${text}`.slice(0, 600);
115
+ if (BLOCK_TEXT.test(head)) {
116
+ blockPages.push({ at: new Date().toISOString(), path, title: title.slice(0, 120) });
117
+ return true;
118
+ }
119
+ return false;
120
+ },
121
+
122
+ /**
123
+ * 막혔다고 볼 수 있나. 아니면 null.
124
+ * 연결 오류 한두 번은 흔하다 — 반복되거나 차단 페이지를 봤을 때만 막혔다고 한다.
125
+ */
126
+ verdict() {
127
+ const enoughNet = netErrors.length >= minNetErrors;
128
+ if (!enoughNet && blockPages.length === 0) return null;
129
+ const layer = blockPages.length ? "page" : "network";
130
+ const v = vendor || { id: "unknown", name: "unknown provider" };
131
+ const evidence = [];
132
+ if (blockPages.length) {
133
+ evidence.push(`Block page ${blockPages.length}× — e.g. "${blockPages[0].title}" (${blockPages[0].path || "/"})`);
134
+ }
135
+ if (netErrors.length) {
136
+ const kinds = [...new Set(netErrors.map((e) => (e.err.match(/ERR_[A-Z_]+/) || [e.err])[0]))];
137
+ evidence.push(`Connection dropped without a response ${netErrors.length}× — ${kinds.join(", ")}`);
138
+ }
139
+ if (lastStatus) evidence.push(`HTTP ${lastStatus} response`);
140
+ if (vendor) evidence.push(`${vendor.name}, judging by the response headers`);
141
+ return {
142
+ blocked: true,
143
+ layer, // page = 응답을 주면서 거부 · network = 응답 없이 끊음
144
+ vendor: v.id,
145
+ vendorName: v.name,
146
+ evidence,
147
+ fixes: FIXES[v.id] || FIXES.unknown,
148
+ counts: { netErrors: netErrors.length, blockPages: blockPages.length },
149
+ };
150
+ },
151
+ };
152
+ }
153
+
154
+ /** 고객이 그대로 읽을 글. 도구 문제가 아니라는 것부터 말한다 */
155
+ function describeBlock(v, { ua = UA_TAG, keySet = !!process.env[KEY_ENV] } = {}) {
156
+ if (!v) return null;
157
+ const L = [];
158
+ L.push("⚠ The site is blocking the virtual users. This is not a problem with the tool.");
159
+ L.push("");
160
+ L.push(v.layer === "page" ? "What happened — the site returns a block page" : "What happened — the site drops the connection without a response");
161
+ v.evidence.forEach((e) => L.push(` · ${e}`));
162
+ L.push("");
163
+ L.push(`How to fix it (${v.vendorName})`);
164
+ v.fixes.forEach((f) => L.push(` · ${f}`));
165
+ L.push(" · Or run on staging instead of production — it usually has no bot protection");
166
+ L.push("");
167
+ // 지시문의 <비밀값> 을 채우려면 먼저 정해야 한다. 안 정했으면 그것부터 말한다
168
+ L.push(
169
+ keySet
170
+ ? `${SECRET} is the value you set in ${KEY_ENV}. It is sent only to this site, in the \`${KEY_HEADER}\` header.`
171
+ : `${SECRET} is not set yet — put a random value of 16+ characters in ${KEY_ENV}, and the same value in the rule above.`,
172
+ );
173
+ L.push("");
174
+ // GA 같은 브라우저 쪽 도구는 요청 헤더를 못 본다. 헤더로 거르라고 하면 거르려다 실패한다
175
+ L.push(`To separate virtual-user visits, use the \`${AGENT_HEADER}\` header in logs, and \`${ua}\` in the User-Agent for analytics tools (GA etc.).`);
176
+ L.push("Analytics tools that run in the browser cannot see request headers.");
177
+ return L.join("\n");
178
+ }
179
+
180
+ module.exports = { createBlockWatch, describeBlock, NET_ERRORS, BLOCK_TEXT, VENDORS, FIXES, SECRET };
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ /**
3
+ * 어닝버드 질문 카드를 읽고 답한다. 가상 사용자가 사람처럼 카드를 만나는 자리.
4
+ *
5
+ * 화면만 본다 — 페이지 코드를 읽어 "여기 무슨 질문이 걸려 있다" 를 미리 알아내지 않는다.
6
+ * 카드는 그림자 DOM(#eb-insight) 안에 있고, 방문자에게 보이는 그대로만 본다.
7
+ *
8
+ * 의존성 없음. Playwright 의 page/context 객체를 받아서 쓴다.
9
+ */
10
+
11
+ /**
12
+ * 가상 사용자가 HTTP 층에서 자기를 밝히는 헤더. 값은 agent 이름(`qa-explore` 등).
13
+ *
14
+ * **고객이 이 헤더로 우리를 다룬다** — 분석 도구에서 빼고, 로그에서 가른다.
15
+ * WAF 통과는 이것만으로 하면 안 된다 — 누구나 넣을 수 있다. 그건 아래 `KEY_HEADER` 로.
16
+ * 이름을 바꾸면 고객 설정이 깨진다. 바꿀 거면 `docs/onboarding.md` 를 다시 뽑을 것(`npm run docs`).
17
+ */
18
+ const AGENT_HEADER = "X-Usercase-Agent";
19
+
20
+ /**
21
+ * 브라우저 UA 뒤에 붙이는 꼬리표. 헤더를 안 남기고 UA 만 남기는 로그(nginx 기본 등)에서도
22
+ * 가상 사용자를 알아볼 수 있게 한다. UA 는 컨텍스트를 만들 때만 정할 수 있어 따로 뺐다.
23
+ */
24
+ const UA_TAG = "UsercaseQA/1.0";
25
+ const uaWith = (agent, baseUA) => `${baseUA} ${UA_TAG} (${agent})`;
26
+
27
+ /**
28
+ * 컨텍스트 옵션에 UA 꼬리표를 붙인다. `base.userAgent` 가 없으면 `defaultUA` 에 붙인다
29
+ * (보통 Playwright 의 `devices["Desktop Chrome"].userAgent`). 이 파일은 Playwright 를 모르므로 받아 쓴다.
30
+ *
31
+ * **모든 방문 컨텍스트에 써야 한다.** 온보딩 문서가 고객에게 "분석 도구에서는 UA 로 가르세요" 라고 하므로,
32
+ * 꼬리표가 빠진 방문이 하나라도 있으면 그 방문은 고객 전환율에 진짜 방문으로 찍힌다.
33
+ * (9/21 까지 데스크톱 방문과 링크 수집 방문에 빠져 있었다)
34
+ */
35
+ const withAgentUA = (agent, base, defaultUA) => ({ ...base, userAgent: uaWith(agent, base.userAgent || defaultUA) });
36
+
37
+ /**
38
+ * **위조 대비 비밀값 헤더.** `AGENT_HEADER` 는 누구나 넣을 수 있어서 보안팀이 그것만 보고
39
+ * WAF 를 열어주지 않는다. 고객이 정한 값을 이 헤더로 보내고, 고객은 WAF 규칙에 같은 값을 넣는다.
40
+ *
41
+ * `AGENT_HEADER` 값에 섞지 않고 따로 둔 이유: 에이전트 헤더는 분석 도구·로그에서 가르는 용도라
42
+ * 여러 곳에 남는다. 비밀값이 거기 섞이면 로그를 보는 사람 누구나 알게 된다.
43
+ * 또 WAF 규칙은 "값이 정확히 같은가" 가 가장 단순한데, agent 이름이 앞에 붙으면 그게 안 된다.
44
+ *
45
+ * **고객 사이트 origin 으로 가는 요청에만 붙인다**(`sendKey`). `setExtraHTTPHeaders` 로 붙이면
46
+ * 페이지가 부르는 GA·광고·CDN 등 남의 도메인으로 비밀값이 샌다.
47
+ *
48
+ * 이름을 바꾸면 고객 WAF 규칙이 깨진다. 바꿀 거면 `docs/onboarding.md` 를 다시 뽑을 것.
49
+ */
50
+ const KEY_HEADER = "X-Usercase-Key";
51
+ const KEY_ENV = "USERCASE_AGENT_KEY";
52
+ /** 짧으면 추측된다. 32자 무작위를 권하지만 16자 아래만 거절한다 */
53
+ const KEY_MIN = 16;
54
+
55
+ /** 쓸 비밀값. 인자가 없으면 환경변수. 없으면 null — 비밀값 없이도 돈다(에이전트 헤더만 간다) */
56
+ function agentKey(explicit) {
57
+ const k = explicit != null ? String(explicit) : process.env[KEY_ENV] || "";
58
+ if (!k) return null;
59
+ // 헤더 값에 못 들어가는 글자(공백·한글·제어문자)는 조용히 깨지니 여기서 막는다
60
+ if (!/^[\x21-\x7e]+$/.test(k)) throw new Error(`${KEY_ENV}: use only ASCII letters, digits and symbols (no spaces)`);
61
+ if (k.length < KEY_MIN) throw new Error(`${KEY_ENV}: must be at least ${KEY_MIN} characters (now ${k.length})`);
62
+ return k;
63
+ }
64
+
65
+ /**
66
+ * 비밀값을 **고객 사이트 origin 요청에만** 붙인다. key 가 없으면 아무것도 안 한다.
67
+ *
68
+ * route 를 쓰면 Playwright 는 그 컨텍스트의 HTTP 캐시를 끈다 — 비밀값을 쓸 때만 드는 비용이다.
69
+ * `fallback` 이라 다른 route(외부 차단 등)와 겹쳐도 되지만, **다른 route 를 다 건 뒤 마지막에 불러야 한다.**
70
+ * Playwright 는 나중에 건 route 가 먼저 돈다 — 이걸 먼저 걸면 뒤에 건 route 가 continue() 로 끝내 버려
71
+ * 비밀값이 한 번도 안 나간다(9/21 driver.js 에서 그랬다).
72
+ */
73
+ async function sendKey(ctx, siteOrigin, key) {
74
+ if (!key) return false;
75
+ if (!siteOrigin) throw new Error(`${KEY_HEADER} is never sent without a site origin — the secret would leak to other domains`);
76
+ const origin = new URL(siteOrigin).origin;
77
+ await ctx.route(
78
+ (u) => u.origin === origin,
79
+ async (route) => {
80
+ const headers = { ...(await route.request().allHeaders()), [KEY_HEADER.toLowerCase()]: key };
81
+ await route.fallback({ headers });
82
+ },
83
+ );
84
+ return true;
85
+ }
86
+
87
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
88
+
89
+ /**
90
+ * SDK 가 **실제로 돌기 시작했는지** 기다린다. 첫 걸음을 떼기 전에 불러야 한다.
91
+ *
92
+ * 광고·트래커가 많은 상용 사이트는 `load` 가 늦게 끝나고 SDK 도 늦게 붙는다
93
+ * (2026-09-21 사람인: goto 가 60초 타임아웃, 그 뒤에 붙었다).
94
+ * 그 사이에 봇이 움직이면 **그 조작들이 기록에 안 남는다.**
95
+ * 방문은 성공한 것처럼 끝나고 기록만 앞부분이 잘려서, 나중에 그 기록으로
96
+ * "사람들이 이 화면부터 시작하더라" 같은 틀린 결론이 나온다. **조용히 틀리는 종류다.**
97
+ *
98
+ * 신호는 sessionStorage 의 `Usercase_bt_session_*.sessionId` 다(이름 바꾸기 전 SDK 는 `Earningbirds_bt_session_*`).
99
+ * **localStorage 의 visitorId 를 보면 안 된다** — 지난 방문에서 남아 있어서
100
+ * SDK 가 안 떴는데도 값이 있다. 오늘 그것 때문에 한참 헤맸다.
101
+ */
102
+ async function waitForSdk(page, { timeoutMs = 20000, everyMs = 250 } = {}) {
103
+ const started = Date.now();
104
+ for (;;) {
105
+ const ok = await page
106
+ .evaluate(() => {
107
+ try {
108
+ for (let i = 0; i < sessionStorage.length; i++) {
109
+ const k = sessionStorage.key(i);
110
+ if (!k || !/^(Usercase|Earningbirds)_bt_session_/.test(k)) continue;
111
+ const v = JSON.parse(sessionStorage.getItem(k) || "null");
112
+ if (v && v.sessionId) return true;
113
+ }
114
+ } catch (e) {}
115
+ return false;
116
+ })
117
+ .catch(() => false);
118
+ const ms = Date.now() - started;
119
+ if (ok) return { ready: true, ms };
120
+ if (ms >= timeoutMs) return { ready: false, ms };
121
+ await sleep(everyMs);
122
+ }
123
+ }
124
+
125
+ /** 지금 화면에 떠 있는 카드. 없으면 null */
126
+ async function readCard(page) {
127
+ return page
128
+ .evaluate(() => {
129
+ const host = document.getElementById("eb-insight");
130
+ const card = host && host.shadowRoot && host.shadowRoot.querySelector(".eb-ip-card");
131
+ if (!card || card.classList.contains("eb-ip-leaving")) return null;
132
+ const q = card.querySelector(".eb-ip-question");
133
+ return {
134
+ visible: card.classList.contains("eb-ip-visible"),
135
+ question: q ? q.textContent.trim() : "",
136
+ choices: Array.from(card.querySelectorAll(".eb-ip-opt:not(.eb-ip-other):not(.eb-ip-decline)")).map((b) => b.textContent.trim()),
137
+ };
138
+ })
139
+ .catch(() => null);
140
+ }
141
+
142
+ /** 카드가 뜰 때까지 기다린다. 안 뜨면 null — 안 뜨는 것도 결과다 */
143
+ async function waitCard(page, ms) {
144
+ for (let t = 0; t < ms; t += 500) {
145
+ const c = await readCard(page);
146
+ if (c && c.visible) return c;
147
+ await sleep(500);
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * 카드가 떴을 때 그 사람 화면에 있던 글자. 답할지 무엇을 고를지는 이걸 보고 정한다.
154
+ * 카드 자신(그림자 DOM)은 innerText 에 안 들어간다.
155
+ */
156
+ async function screenText(page, max = 6000) {
157
+ const t = await page.evaluate(() => (document.body ? document.body.innerText : "")).catch(() => "");
158
+ return t.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim().slice(0, max);
159
+ }
160
+
161
+ /**
162
+ * { kind: "choose", index } | { kind: "other", text } | { kind: "close" }
163
+ * 자유 입력 질문은 선택지가 없어 "other"(자기 말로 쓰기) 아니면 닫기뿐이다.
164
+ */
165
+ async function answerCard(page, action) {
166
+ const root = page.locator("#eb-insight .eb-ip-card");
167
+ if (action.kind === "close") {
168
+ await root.locator(".eb-ip-close").click({ timeout: 5000 });
169
+ } else if (action.kind === "choose") {
170
+ await root.locator(".eb-ip-opt:not(.eb-ip-other)").nth(action.index).click({ timeout: 5000 });
171
+ } else {
172
+ // 선택지가 있는 카드는 "기타" 를 눌러야 입력칸이 나온다. 자유 입력 카드는 이미 나와 있다
173
+ const other = root.locator(".eb-ip-other");
174
+ if (await other.count()) await other.click({ timeout: 5000 });
175
+ await root.locator(".eb-ip-input").fill(action.text, { timeout: 5000 });
176
+ await root.locator(".eb-ip-input").press("Enter");
177
+ }
178
+ await sleep(2000);
179
+ }
180
+
181
+ /**
182
+ * 이 방문이 사람이 아니라 가상 사용자임을 밝힌다. SDK 가 이 이름을 응답에 실어 보내고
183
+ * 서버가 진짜 방문자 답과 갈라 센다. **이 표시를 빼면 안 된다.**
184
+ *
185
+ * hideDevOverlay: 로컬 개발 서버에서만. Next.js 개발 오버레이가 화면 귀퉁이를 덮어 탭을 가로챈다.
186
+ * 진짜 방문자에게는 없는 층이라 가상 사용자 방문에서만 숨긴다.
187
+ *
188
+ * siteOrigin·key: 위조 대비 비밀값(`KEY_HEADER`). key 를 안 주면 환경변수 `USERCASE_AGENT_KEY`.
189
+ * 비밀값이 있으면 siteOrigin 이 반드시 있어야 한다 — 그 origin 에만 보낸다.
190
+ */
191
+ async function markAgent(ctx, agent, { debugCards = true, hideDevOverlay = true, persona = null, siteOrigin = null, key } = {}) {
192
+ /**
193
+ * **HTTP 층에서도 밝힌다.** 아래 `window.Earningbirds.agent` 는 페이지 JS 안이라 우리 SDK 만 읽는다.
194
+ * 고객의 WAF·CDN·분석 도구는 페이지 앞에 앉아 헤더만 보므로 그것만으로는 우릴 알아볼 수 없다.
195
+ *
196
+ * 그러면 가상 사용자 방문이 **공격으로 보여 차단되고**(2026-09-21 여기어때 Cloudflare 차단·사람인 연결 리셋),
197
+ * 막히지 않아도 **고객의 분석·A/B 테스트에 실제 방문으로 섞인다.** 둘 다 헤더 한 줄이면 고객이 가를 수 있다.
198
+ *
199
+ * 개발 환경에서만 보내면 안 된다 — 막는 것도 분석이 오염되는 것도 운영에서 일어난다.
200
+ * 운영에서 빼면 다시 "사람인 척하는 봇" 이 되고, 그게 막히는 이유다.
201
+ */
202
+ await ctx.setExtraHTTPHeaders({ [AGENT_HEADER]: agent });
203
+ // 비밀값은 고객이 정했을 때만. 정했는데 siteOrigin 을 안 주면 sendKey 가 멈춘다 — 새는 것보다 낫다
204
+ await sendKey(ctx, siteOrigin, agentKey(key));
205
+ await ctx.addInitScript(
206
+ ({ name, debugCards: dbg, hideDevOverlay: hide, persona: who }) => {
207
+ if (dbg) {
208
+ try {
209
+ localStorage.setItem("Usercase_ip_debug", "1");
210
+ localStorage.setItem("Earningbirds_ip_debug", "1"); // 이름 바꾸기 전 SDK
211
+ } catch (e) {}
212
+ }
213
+ try {
214
+ // 새 SDK 는 window.Usercase, 이름 바꾸기 전 SDK 는 window.Earningbirds 를 읽는다 — 둘 다 이 한 객체를 보게 둔다
215
+ window.Usercase = window.Earningbirds = window.Usercase || window.Earningbirds || {};
216
+ window.Usercase.agent = name;
217
+ // 이 방문이 누구였는지. 닫고 나가도 남아야 해서 노출 때 같이 간다
218
+ if (who) window.Usercase.agentPersona = who;
219
+ } catch (e) {}
220
+ if (hide) {
221
+ try {
222
+ document.addEventListener("DOMContentLoaded", () => {
223
+ const st = document.createElement("style");
224
+ st.textContent = "nextjs-portal{display:none !important}";
225
+ document.head.appendChild(st);
226
+ });
227
+ // 개발 오류 창은 열리면서 페이지 스크롤을 잠근다(body overflow hidden). 창을 숨겨도 잠금이 남아
228
+ // 데스크톱에서 굴려도 안 내려갔다(10/13 bluetags). 그 창이 떠 있을 때만 잠금을 푼다
229
+ setInterval(() => {
230
+ const p = document.querySelector("nextjs-portal");
231
+ const open = p && p.shadowRoot && p.shadowRoot.querySelector("[data-nextjs-dialog],[data-nextjs-dialog-overlay]");
232
+ if (open && document.body && document.body.style.overflow === "hidden") document.body.style.overflow = "";
233
+ }, 500);
234
+ } catch (e) {}
235
+ }
236
+ },
237
+ { name: agent, debugCards, hideDevOverlay, persona },
238
+ );
239
+ }
240
+
241
+ /** 방금 그렇게 한 이유. 답하거나 닫기 **직전에** 넣는다 — SDK 가 보낼 때 읽는다 */
242
+ async function noteReason(page, reason) {
243
+ await page
244
+ .evaluate((r) => {
245
+ window.Usercase = window.Earningbirds = window.Usercase || window.Earningbirds || {};
246
+ window.Usercase.agentNote = r;
247
+ }, String(reason || "").slice(0, 1000))
248
+ .catch(() => {});
249
+ }
250
+
251
+ module.exports = {
252
+ sleep,
253
+ readCard,
254
+ waitCard,
255
+ screenText,
256
+ answerCard,
257
+ markAgent,
258
+ noteReason,
259
+ waitForSdk,
260
+ AGENT_HEADER,
261
+ UA_TAG,
262
+ uaWith,
263
+ withAgentUA,
264
+ KEY_HEADER,
265
+ KEY_ENV,
266
+ KEY_MIN,
267
+ agentKey,
268
+ sendKey,
269
+ };