@usercase/mcp 0.2.3 → 0.2.4-next.1

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.
package/src/run/visit.js CHANGED
@@ -1,1124 +1,1124 @@
1
- "use strict";
2
- /**
3
- * 가상 사용자 한 명의 한 방문 — 자유 탐방.
4
- *
5
- * 길을 정해주지 않는다. 사람과 목적만 주고, 화면에 보이는 것만 보고 다음 행동을 스스로 고른다.
6
- * 길을 박아두면 30명이 같은 화면만 보고 사람도 그 화면을 열 만한 부류로만 뽑힌다(2026-09-18 확인).
7
- *
8
- * 도중에 질문 카드가 뜨면 그 사람으로서 답하거나 닫는다(answer.js). 그게 이 방문의 산출물이고,
9
- * 걸음마다 남긴 이유가 나중에 사람이 확인할 근거다.
10
- *
11
- * **판단과 몸을 가른다.** `Visit` 이 몸이다 — 화면 보기·사람 손으로 누르기·쉬기·카드 누르기·떠나기.
12
- * 판단(무엇을 누를까·답할까)은 밖에서 준다. 누가 주느냐가 두 가지다:
13
- * - `runVisit`: 우리가 AI(`ask`)에 물어 준다. 우리 내부 테스트·자동 실행
14
- * - MCP 한 걸음 도구: 고객의 AI 도구(Claude Code 서브에이전트)가 그 사람이 되어 준다(PLAN.md)
15
- * 둘이 같은 몸을 써야 사람처럼 움직이는 방식·막는 선이 갈라지지 않는다.
16
- *
17
- * 의존성 없음. Playwright page 를 받는다.
18
- */
19
- const fs = require("fs");
20
- const path = require("path");
21
- const { randomUUID } = require("crypto");
22
- const { listControls, visibleText, handleAt, clickBlocked } = require("../browser/page.js");
23
- const { readCard, waitCard, screenText, answerCard, noteReason, sleep, waitForSdk } = require("../browser/card.js");
24
- const hand = require("../browser/hand.js");
25
- const answer = require("./answer.js");
26
- const diag = require("./diag.js");
27
-
28
- const STEP_PROMPT_FILE = path.join(__dirname, "..", "prompts", "step.md");
29
-
30
- const STEP_SCHEMA = {
31
- type: "object",
32
- properties: {
33
- why: { type: "string" },
34
- action: { type: "string", enum: ["click", "type", "scroll", "scroll_up", "read", "back", "leave"] },
35
- index: { type: "integer" },
36
- text: { type: "string" },
37
- done: { type: "boolean" },
38
- times: { type: "integer" },
39
- },
40
- required: ["why", "action", "index", "text", "done", "times"],
41
- };
42
-
43
- /** 같은 조작을 연달아 몇 번까지. 굴리며 훑기·안 눌려서 거듭 누르기 */
44
- const MAX_TIMES = { click: 4, scroll: 5, scroll_up: 5 };
45
-
46
- /**
47
- * 문제가 있던 순간의 화면 — 대시보드가 그 걸음 아래에 보인다(서버 /agent/screens). **방문자(AI)에게는 안 보인다.**
48
- * 걸음마다 찍지 않는다. 영상(세션 리플레이)은 봇의 움직임이 드러나 쓰지 않기로 했다(10/21)
49
- * no_change 눌렀는데 화면이 그대로
50
- * left_unhappy 원하던 걸 못 이루고 떠남(leave, done=false) — 마지막 화면
51
- * covered 사이트의 무언가(팝업 등)가 덮어 못 누름 — 우리 카드·위젯이 덮은 것은 뺀다
52
- * error 화면이 깨짐 — 문서가 오류로 옴(4xx·5xx), 오류 문구뿐인 화면.
53
- * 끝내 안 열리는 화면은 못 찍는다 — 불러오는 중에는 브라우저가 화면을 안 내준다(10/22 시험)
54
- * 우리 카드(Usercase 질문 카드)는 찍지 않는다 — 사이트 문제가 아니다. 카드가 떠 있는 동안도 안 찍는다(10/22)
55
- * 누른 곳(box)은 사진에 그리지 않고 걸음에 비율(0~1)로 적는다. 대시보드가 위에 그린다
56
- */
57
- const MAX_SCREENS = 6;
58
- /** 진단(도구가 못 했거나 잘못 본 것)마다 찍는 화면 — 우리만 본다. 고객 대시보드에는 안 간다(10/22) */
59
- const MAX_DIAG_SHOTS = 6;
60
- /** 진단에 붙이는 요소 주변 HTML 길이 */
61
- const MAX_DIAG_HTML = 6000;
62
- /** 진단으로 볼 만한 것이 아니다 — 의도한 동작(밀어서 가져옴·사람 같은 헛클릭) */
63
- const DIAG_NO_CONTEXT = new Set(["swiped", "missed"]);
64
- // 방문자가 바로 다음에 하는 말 — 도구 판단과 엇갈리면 도구가 잘못 본 것이다
65
- const SAYS_CHANGED = /\b(saved|bookmarked|liked|went (blue|red|green|grey|gray|dark|bold)|turned (blue|red|green|on|off)|it (worked|opened|changed)|that (worked|opened)|now (shows|says)|popped (up|open)|opened up)\b/i;
66
- const SAYS_NOTHING = /\b(nothing happened|nothing changed|didn.?t (open|work|do anything|respond|change|load|go anywhere)|not (working|responding|loading)|won.?t (open|load|work)|still the same|no response|(it|that|this|button|link)(.?s| is| seems| looks) dead)\b/i;
67
- // 서버 앞 nginx 의 기본 본문 한도가 1MB 다 — base64 로 커지니 원본은 600KB 아래로
68
- const MAX_SCREEN_BYTES = 600 * 1024;
69
-
70
- /**
71
- * 오류 문구뿐인 화면인가 — 404·"not found"·"application error" 등. 글이 짧은 화면만 본다(기사 속 "404" 는 아니다).
72
- * 클라이언트에서 그리는 404(Next.js 등)는 문서가 200 으로 와서 상태로는 모른다
73
- */
74
- async function errorScreen(page) {
75
- return page
76
- .evaluate(() => {
77
- const t = ((document.body && document.body.innerText) || "").replace(/\s+/g, " ").trim();
78
- if (t.length > 600) return false;
79
- return /\b(404|500|502|503)\b|not found|page (doesn.?t|does not|could not|cannot) (exist|be found)|application error|something went wrong|internal server error/i.test(t);
80
- })
81
- .catch(() => false);
82
- }
83
-
84
- /** 같은 사이트 주소의 경로(+쿼리). 다른 사이트면 null */
85
- function sameDocPath(href, base) {
86
- try {
87
- const u = new URL(href, base);
88
- if (u.origin !== new URL(base).origin) return null;
89
- return (u.pathname.replace(/\/+$/, "") || "/") + u.search;
90
- } catch {
91
- return null;
92
- }
93
- }
94
-
95
- /** 요소가 화면에서 차지한 곳(화면 비율). 화면 밖이거나 사라졌으면 null */
96
- async function boxOf(page, el) {
97
- const b = el ? await el.boundingBox().catch(() => null) : null;
98
- const vp = page.viewportSize();
99
- if (!b || !vp || b.width <= 0 || b.height <= 0) return null;
100
- const x0 = Math.max(0, b.x);
101
- const y0 = Math.max(0, b.y);
102
- const x1 = Math.min(vp.width, b.x + b.width);
103
- const y1 = Math.min(vp.height, b.y + b.height);
104
- if (x1 <= x0 || y1 <= y0) return null;
105
- const r = (n) => Math.round(n * 1000) / 1000;
106
- return { x: r(x0 / vp.width), y: r(y0 / vp.height), w: r((x1 - x0) / vp.width), h: r((y1 - y0) / vp.height) };
107
- }
108
-
109
- /**
110
- * 누르기 전후를 견줄 지문 — 주소, 페이지 전체 글(보이는 것만, 굴려도 같다), 보이는 입력칸·그림, 누른 요소와 감싼 버튼의 상태·HTML(색만 바뀌는 좋아요·저장·토글).
111
- * 입력칸은 글(innerText)에 안 잡힌다 — 검색 아이콘을 눌러 검색칸이 열렸는데 "그대로" 로 찍혔다(10/22 실험실 e4)
112
- * 글이 저절로 바뀌는 사이트(넘어가는 배너)에서는 "바뀌었다" 로 본다 — 그대로라고 잘못 찍는 것보다 낫다
113
- */
114
- async function clickPrint(page, el) {
115
- const doc = await page
116
- .evaluate(() => {
117
- const shown = (n) => {
118
- const r = n.getBoundingClientRect();
119
- return r.width > 0 && r.height > 0 && getComputedStyle(n).visibility !== "hidden";
120
- };
121
- const extra = [...document.querySelectorAll("input,textarea,select,[contenteditable=true],img,video,iframe,canvas")]
122
- .filter(shown)
123
- .map((n) => [n.tagName, n.getAttribute("placeholder"), n.getAttribute("aria-label"), n.getAttribute("src")].join(":"))
124
- .join("|");
125
- const t = ((document.body && document.body.innerText) || "") + "|" + extra;
126
- let h = 5381;
127
- for (let i = 0; i < t.length; i++) h = ((h << 5) + h + t.charCodeAt(i)) | 0;
128
- return `${t.length}:${h}`;
129
- })
130
- .catch(() => null);
131
- const own = el
132
- ? await el
133
- .evaluate((n) => {
134
- if (!n.isConnected) return null;
135
- // 색만 바뀌는 아이콘(저장·좋아요)은 속의 svg 나 감싼 버튼의 속성·클래스가 바뀐다 — 그 HTML 까지 본다.
136
- // 누른 요소만 봤더니 북마크가 파랗게 됐는데 "그대로" 로 찍혔다(10/22 bluetags 저장).
137
- // 계산된 색은 안 본다 — 누르기 전에는 커서가 없어서 마우스를 올린 색(hover)만으로 "바뀌었다" 가 된다
138
- const host = n.closest("a,button,[role],label") || n.parentElement || n;
139
- return [
140
- n.className && String(n.className),
141
- ...["aria-pressed", "aria-expanded", "aria-checked", "aria-selected", "aria-current", "data-state"].map((a) => n.getAttribute(a)),
142
- n.checked,
143
- n.value,
144
- (n.textContent || "").slice(0, 200),
145
- host.outerHTML.slice(0, 3000),
146
- ].join("|");
147
- })
148
- .catch(() => null)
149
- : null;
150
- return { url: page.url(), doc, own };
151
- }
152
-
153
- /**
154
- * 클릭 뒤 화면이 자리 잡기를 기다린다. 사람도 눌렀으면 뜰 때까지 본다.
155
- * 안 기다리면 느린 화면이 "눌러도 안 열린다" 로 읽힌다 — 10/13 bluetags 에서 카드가 "안 열린다 → 조금 뒤 열렸다".
156
- */
157
- async function settle(page, before, net) {
158
- // 주소가 바뀔 때까지 보되, 아무것도 안 불러오고 있으면 거기서 끝난 것이다(정말 안 눌린 것).
159
- // Next.js 같은 사이트는 다음 화면 데이터를 다 받은 뒤에야 주소를 바꾼다. 1.5초로 끊었더니 10/13 0.1.8 에서
160
- // "안 열린다" 가 69번 — 그중 38번은 같은 걸 다시 누르자 열렸다. networkidle 은 이미 연 문서에서는 바로 끝나 소용없다
161
- const t0 = Date.now();
162
- let quietSince = null;
163
- let moved = false;
164
- // 화면을 불러오는 중이면 30초까지 본다. 10초로 끊었더니 개발 서버에서 카드가 "안 열렸다" 가 되고,
165
- // 다시 누르자 그새 넘어가 "없어졌다" 가 됐다(10/15 실험실 p16)
166
- while (Date.now() - t0 < (net && net.navPending() ? 30000 : 10000)) {
167
- // 새 탭으로 열린 같은 사이트 화면을 이 탭으로 옮기는 중이다(hub). 그 이동이 끝날 때까지 본다
168
- if (page.__ebPendingNav) {
169
- await Promise.race([page.__ebPendingNav, sleep(30000)]);
170
- moved = page.url() !== before;
171
- break;
172
- }
173
- if (page.url() !== before) {
174
- moved = true;
175
- break;
176
- }
177
- const busy = net ? net.busy() : 0;
178
- if (busy) quietSince = null;
179
- else if (quietSince === null) quietSince = Date.now();
180
- if (Date.now() - t0 > 800 && quietSince !== null && Date.now() - quietSince > 600) break;
181
- await sleep(100);
182
- }
183
- if (moved) {
184
- await page.waitForLoadState("domcontentloaded", { timeout: 10000 }).catch(() => {});
185
- await page.waitForLoadState("networkidle", { timeout: 4000 }).catch(() => {});
186
- // 뒤로 가기는 주소가 먼저 바뀌고, 그다음에 새 화면 데이터를 받아 그린다(Next.js). 위 두 대기는 이미 연 문서라 바로 끝난다 —
187
- // 주소는 "/" 인데 화면은 아직 커뮤니티였다(10/14 bluetags p15: 옛 화면의 버튼을 눌러 "없다"·"안 된다").
188
- // 조작이 시작한 요청이 다 끝날 때까지 본다
189
- if (net) {
190
- const t1 = Date.now();
191
- let quiet = null;
192
- while (Date.now() - t1 < 8000) {
193
- if (net.busy()) quiet = null;
194
- else if (quiet === null) quiet = Date.now();
195
- if (quiet !== null && Date.now() - quiet > 400) break;
196
- await sleep(100);
197
- }
198
- }
199
- }
200
- // 30초를 다 기다렸는데도 화면을 불러오는 중이었다 — 진단으로 남긴다
201
- return { navTimeout: !moved && !!(net && net.navPending()) };
202
- }
203
-
204
- /** 조작 뒤 불러오고 있는 요청 수. 웹소켓·스트림은 끝나지 않으니 뺀다 */
205
- function watchNet(page) {
206
- const live = new Set();
207
- const on = (r) => {
208
- if (!["websocket", "eventsource", "media"].includes(r.resourceType())) live.add(r);
209
- };
210
- const off = (r) => live.delete(r);
211
- page.on("request", on);
212
- page.on("requestfinished", off);
213
- page.on("requestfailed", off);
214
- return {
215
- busy: () => live.size,
216
- /** 이 탭의 화면 자체를 불러오는 중인가(문서 요청). 개발 서버는 처음 여는 화면을 만드느라 10초를 넘긴다 */
217
- navPending: () => [...live].some((r) => {
218
- try {
219
- return r.isNavigationRequest() && r.frame() === page.mainFrame();
220
- } catch {
221
- return false;
222
- }
223
- }),
224
- stop() {
225
- page.off("request", on);
226
- page.off("requestfinished", off);
227
- page.off("requestfailed", off);
228
- },
229
- };
230
- }
231
-
232
- /** 링크가 사이트 밖이면 그 호스트. www 만 다른 것은 같은 사이트다 */
233
- function offsiteHost(href, here) {
234
- if (!href || !/^https?:\/\//i.test(href)) return null;
235
- try {
236
- const bare = (h) => h.replace(/^www\./, "");
237
- const to = new URL(href);
238
- return bare(to.host) === bare(new URL(here).host) ? null : to.host;
239
- } catch {
240
- return null;
241
- }
242
- }
243
-
244
- /** 들어와서 처음 손대기까지. 훑는 사람은 금방, 읽는 사람은 한참 본다 */
245
- /**
246
- * Playwright 의 클릭 오류를 사람이 겪는 말로. "elementHandle.click: Timeout 8000ms exceeded" 를 그대로 주면
247
- * 방문자는 화면이 멈췄다고 읽는다(10/14 bluetags p4)
248
- */
249
- function humanClickError(e) {
250
- const m = String((e && e.message) || "");
251
- if (/intercepts pointer events/.test(m)) return "something else on the screen is on top of it";
252
- if (/not enabled|disabled/.test(m)) return "it is greyed out and does not respond";
253
- if (/not visible|outside of the viewport/.test(m)) return "it is not visible on the screen right now";
254
- if (/detached|not attached/.test(m)) return "no longer there";
255
- return "nothing happened when you clicked it";
256
- }
257
-
258
- /** 파일 고르는 창. 사람에게는 컴퓨터의 파일 창이 뜬다 — 자동 브라우저에서는 조용히 사라져 "안 열린다" 가 된다(10/14 p24) */
259
- function fileWindowNote(fc) {
260
- const accept = fc.accept || "";
261
- const kind = /image/.test(accept) ? "an image" : /video/.test(accept) ? "a video" : /pdf/.test(accept) ? "a PDF" : "a file";
262
- return `A window opened asking you to choose ${kind} from your device. You have nothing ready to pick for this visit, so you closed that window without choosing.`;
263
- }
264
-
265
- function firstLookMs(attention) {
266
- const [lo, hi] = { skims: [800, 6000], reads: [2500, 15000], studies: [5000, 25000] }[attention] || [2500, 15000];
267
- // 짧은 쪽에 몰리고 가끔 길다
268
- return Math.round(lo + (hi - lo) * Math.pow(Math.random(), 1.8));
269
- }
270
-
271
- /** 한 방문 걸음 수의 절대 상한. 판단을 누가 하든 이 이상은 안 걷는다 */
272
- const HARD_STEP_CAP = 60;
273
-
274
- function stepPrompt() {
275
- return fs.readFileSync(STEP_PROMPT_FILE, "utf8");
276
- }
277
-
278
- /** 지금 화면을 글로. 사람 소개 없이 화면만 — MCP 에이전트는 자기가 누구인지 이미 안다 */
279
- function renderScreen(look) {
280
- const { view, text } = look;
281
- return [
282
- "## Where you are",
283
- `- path: ${view.path}`,
284
- `- scrolled ${view.scrollY} of ${Math.max(0, view.height - view.viewport)} px`,
285
- "",
286
- "## On the screen",
287
- "```",
288
- text,
289
- "```",
290
- "",
291
- "## Things you can use (number · what it says · where)",
292
- ...view.items.map((it) => `[${it.i}] ${it.label || "(no text)"}${it.href ? ` → ${it.href}` : ""}${it.dim ? " · looks greyed out" : ""}${it.edge ? " · cut off at the edge of the screen" : ""}${it.behind ? " · dimmed, behind the open panel" : ""} · ${it.where}`),
293
- // 떠 있는 창 뒤가 흐리게 비치는 화면. 글로 옮기면 창과 뒤 페이지가 한 덩어리로 섞인다(10/21 실험실 q3)
294
- ...(view.items.some((it) => it.behind) ? ["(A panel is open on top of the page; the page behind it is dimmed.)"] : []),
295
- // 화면 안에 있는데 목록에 못 넣은 것. "아래에 더 있다" 고 하면 내려 봐도 못 찾는다
296
- // 화면 안에서 빠진 것과 지나온 위쪽에서 빠진 것을 따로 말한다 — 한 숫자로 말하면 "화면에 너무 많다" 가 거짓이 된다
297
- ...(view.droppedInView
298
- ? [view.droppedInView <= (view.droppedIcons || 0)
299
- ? `(${view.droppedInView} more small icons or repeated images on this screen are not listed.)`
300
- : `(${view.droppedInView} more things on this screen are not listed — there is too much in view.)`]
301
- : view.dropped
302
- ? [`(${view.dropped} more things further up the page are not listed — scroll up to use them.)`]
303
- : []),
304
- // 아래에 더 있다는 것만. 무엇인지는 내려 봐야 안다
305
- // 굴릴 데가 없는데 "아래에 더 있다" 고 하면 모순이다 — 창이 뜨며 사이트가 페이지 스크롤을 잠근 때(10/21 실험실 r2·r4)
306
- ...(view.height - view.viewport <= 2
307
- ? ["(Nothing on this screen scrolls right now.)"]
308
- // 끝까지 내려왔으면 "아래에 더" 라고 하지 않는다 — 화면 밖 아래에 걸린 것이 있어도 굴려서 못 온다(10/21 실험실 t3)
309
- : view.below && view.height - view.viewport - view.scrollY > 2
310
- ? [view.inPanel ? "(The open panel goes on below. Scroll down to see the rest of it.)" : `(The page goes on below. Scroll down to see what is there.)`]
311
- : view.height - view.viewport - view.scrollY > Math.max(40, view.viewport * 0.25)
312
- ? [view.inPanel ? "(There is more of the open panel below.)" : "(There is more of the page below.)"]
313
- // 조금 남은 것(화면 맨 아래 버튼의 나머지, 여백)을 "더 있다" 고 하면 굴려도 새것이 없었다(10/21 실험실 s2·u2·w2)
314
- : view.height - view.viewport - view.scrollY > 40
315
- ? [view.inPanel ? "(Nearly the bottom of the open panel — only a little more below.)" : "(Nearly the bottom — only a little more below.)"]
316
- : [view.inPanel ? "(This is the bottom of the open panel.)" : "(This is the bottom of the page.)"]),
317
- ].join("\n");
318
- }
319
-
320
- /**
321
- * 그 사람이 누구인가. **두 길이 같은 글을 쓴다** — 우리 AI 의 걸음 판단(buildStepMessage)과
322
- * MCP 의 `open_visit`(고객 Claude 서브에이전트가 받는 자기소개).
323
- * 9/21 에 MCP 쪽이 이름만 받고 걸었다 — 목적도 상태도 모른 채 이름에 붙은 꼬리표로 짐작해 움직였다.
324
- *
325
- * @param card 카드를 다루는 쪽에도 쓰이나(설문 습관·쓰는 버릇까지). 걸음만 정할 때는 필요 없다
326
- */
327
- const SURVEY_HABIT = {
328
- ignores: "you usually ignore them",
329
- sometimes: "you sometimes answer them",
330
- willing: "you are usually happy to answer them",
331
- };
332
-
333
- function renderPerson(person, { card = false } = {}) {
334
- const lines = [
335
- `- on ${person.device}`,
336
- `- ${person.background}`,
337
- `- what you came for: ${person.goal}`,
338
- `- right now: ${person.moment ?? "(not given)"}`,
339
- `- you take in a page by: ${person.attention ?? "reads"}`,
340
- ];
341
- if (card) {
342
- // 값이 동사 하나(ignores·sometimes·willing)라 그대로 끼우면 "you usually sometimes them" 이 됐다(10/14 실험실)
343
- lines.push(`- survey cards that pop up: ${SURVEY_HABIT[person.surveyHabit] || SURVEY_HABIT.sometimes}`);
344
- lines.push(`- when you type, you are ${person.writing ?? "careful"}`);
345
- }
346
- return lines.join("\n");
347
- }
348
-
349
- /**
350
- * @param visited 이번 방문에 들른 경로와 횟수 [{path, n}]. 최근 8걸음만 보여주면 앞에서 본 것을 잊고 같은 길을 돈다
351
- * (9/21: 39걸음 동안 같은 네 쪽을 세 바퀴 돌았다). 고객 Claude(MCP)는 방문 전체를 기억하니 이 길에만 필요하다
352
- */
353
- function buildStepMessage(person, siteAbout, view, history, pageText, visited = []) {
354
- const lines = [
355
- "## Website",
356
- siteAbout,
357
- "",
358
- "## You",
359
- renderPerson(person),
360
- "",
361
- renderScreen({ view, text: pageText }),
362
- "",
363
- ];
364
- if (visited.length) {
365
- lines.push("## Pages you have already been to on this visit (steps you spent there)");
366
- visited.forEach((v) => lines.push(`- ${v.path} ×${v.n}`));
367
- lines.push("");
368
- }
369
- if (history.length) {
370
- lines.push("## What you have done so far");
371
- if (history.length > 8) lines.push(`(${history.length - 8} earlier steps not shown)`);
372
- history.slice(-8).forEach((h, i) => lines.push(`${i + 1}. ${h}`));
373
- lines.push("");
374
- }
375
- lines.push("What do you do next?");
376
- return lines.join("\n");
377
- }
378
-
379
- /**
380
- * 한 방문의 몸. 사이트가 이미 열린 page 를 받는다.
381
- * 순서: begin() → [ look() → (카드면 answer(), 아니면 act()) ]* → act({action:"leave"}) → (떠날 때 카드면 answer())
382
- */
383
- class Visit {
384
- /**
385
- * @param person people.js PERSON_FIELDS
386
- * @param rhythm persona.Rhythm (조작 사이 간격). 없으면 짧게 쉰다
387
- * @param shape persona.drawShape 가 뽑은 이번 방문 모양(튕김·훑기·깊게). 인내심 표시에 쓴다
388
- * @param offsite 사이트 밖으로 가려던 주소가 쌓이는 배열(guard.js onOffsite 가 채운다)
389
- */
390
- constructor(page, person, { rhythm = null, shape = null, allowAccounts = false, policy = null, offsite = [], notSent = [], external = [], log = () => {} } = {}) {
391
- this.page = page;
392
- this.person = person;
393
- /** 방문 시작 — 걸음마다 몇 초째인지(t) 적는다 */
394
- this.t0 = Date.now();
395
- this.rhythm = rhythm;
396
- this.shape = shape;
397
- this.allowAccounts = allowAccounts;
398
- /** 고객이 정한 금지 구역(browser/policy.js). 누를 것·칠 것을 가린다 */
399
- this.policy = policy;
400
- this.offsite = offsite;
401
- /** 사이트에 쓰려다 방어선이 끊은 요청(hub 가 채운다) */
402
- this.notSent = notSent;
403
- this.external = external;
404
- // 알린 바깥 서버. 소켓은 몇 초마다 다시 붙으려 해서, 한 번만 알린다
405
- this.externalTold = new Set();
406
- /** 도구가 하지 못한 것들 — 방문이 끝나면 hub 가 서버로 보낸다(diag.js). 사이트 글자는 없다 */
407
- this.diag = [];
408
- /** 진단 순간의 화면 { id(진단 id), image } — 우리 쪽으로만 간다 */
409
- this.diagShots = [];
410
- this.log = log;
411
- // 손은 방문 내내 이어진다 — 커서가 있던 자리에서 다음 곳으로 간다
412
- this.handState = { rhythm, mouse: null };
413
- this.steps = [];
414
- /** 이번 조작으로 받은 문서의 상태 코드(본 탭의 화면 이동만). 조작마다 비운다 */
415
- this.docStatus = null;
416
- /** 마지막으로 끊긴 본 탭 이동(주소·이유) — 빈 페이지가 된 까닭을 진단에 남긴다 */
417
- this.lastFailedNav = null;
418
- page.on("requestfailed", (req) => {
419
- try {
420
- if (req.isNavigationRequest() && req.frame() === page.mainFrame()) this.lastFailedNav = `${new URL(req.url()).pathname} ${(req.failure() && req.failure().errorText) || ""}`.trim();
421
- } catch {}
422
- });
423
- page.on("response", (res) => {
424
- try {
425
- if (res.request().isNavigationRequest() && res.frame() === page.mainFrame()) this.docStatus = res.status();
426
- } catch {}
427
- });
428
- /** 문제가 있던 순간의 화면 { step, image(JPEG) } — 방문이 끝나면 hub 가 서버로 올린다 */
429
- this.screens = [];
430
- this.history = [];
431
- this.seenPaths = new Set();
432
- this.card = null; // 처리한 카드(한 방문에 하나)
433
- this.pending = null; // 떴는데 아직 답 안 한 카드
434
- this.lastView = null; // act 의 번호는 마지막 look 의 화면 기준이다
435
- this.lastAction = null;
436
- /**
437
- * 다음 조작을 해도 되는 때. **사람 박자는 여기서 정한다.**
438
- * 전에는 조작 뒤에 쉬고 나서 화면을 돌려줬다 — 그 뒤 AI 가 생각하는 시간이 또 붙어 늘 8~30초 간격이 됐다
439
- * (10/13 bluetags 105명, 첫 조작은 거의 다 3~4초). 이제 화면은 바로 돌려주고, 다음 조작 때 이 시각까지 기다린다.
440
- * AI 가 더 오래 생각했으면 기다리지 않는다 — 간격은 둘 중 긴 쪽이다.
441
- */
442
- this.nextAt = 0;
443
- this.ended = false;
444
- this.sdk = null;
445
- // 파일 고르는 창이 열렸나. 듣고 있으면 Playwright 가 창을 띄우지 않는다 — 고르지 않은 채 닫은 것과 같다
446
- this.fileWindow = null;
447
- page.on("filechooser", (fc) => {
448
- this.fileWindow = { accept: "" };
449
- fc.element()
450
- .getAttribute("accept")
451
- .then((a) => (this.fileWindow = { accept: a || "" }))
452
- .catch(() => {});
453
- });
454
- /**
455
- * 브라우저 창(alert·confirm·prompt). 듣지 않으면 Playwright 가 조용히 "취소" 한다 — bluetags 커뮤니티 나가기는
456
- * "Are you sure you want to leave?" 가 세 번 다 취소돼 "눌러도 그대로" 로 찍히고 고객 화면에 사이트 탓으로 나갔다(10/22 p2038).
457
- * 창이 떠 있는 동안 페이지는 멈추고 누르기도 안 끝난다 — 방문자에게 문구를 보여 주고 고르게 한다(answerDialog)
458
- */
459
- this.dialog = null;
460
- this.dialogWake = null;
461
- this.dialogWork = null;
462
- this.dialogText = null;
463
- this.acting = false;
464
- this.curRecord = null;
465
- page.on("dialog", (d) => this.onDialog(d));
466
- }
467
-
468
- /** 창이 떴다. 걸음 중이면 방문자에게 넘기고, 아니면(화면 보는 중·카드) 사람이 그냥 넘기듯 닫는다 — 열어 두면 페이지가 멈춘다 */
469
- onDialog(d) {
470
- const type = d.type();
471
- const message = String(d.message() || "").slice(0, 500);
472
- // 떠나며 "저장 안 한 내용이 있다" 는 사람도 대개 떠난다
473
- if (type === "beforeunload") return void d.accept().catch(() => {});
474
- if (!this.acting || this.dialog) {
475
- (type === "alert" ? d.accept() : d.dismiss()).catch(() => {});
476
- this.history.push(`A box from the browser popped up saying "${message}" and went away.`);
477
- return;
478
- }
479
- this.dialog = { d, type, message, defaultValue: d.defaultValue() || "", path: this.pathNow() };
480
- this.dialogText = null;
481
- if (this.dialogWake) this.dialogWake();
482
- }
483
-
484
- pathNow() {
485
- try {
486
- const u = new URL(this.page.url());
487
- return u.pathname + u.search;
488
- } catch {
489
- return "";
490
- }
491
- }
492
-
493
- /** 창 안에서 고를 것 — 화면의 번호 목록처럼 준다 */
494
- dialogItems() {
495
- const d = this.dialog;
496
- const ok = { key: "ok", label: "OK", where: "in the box" };
497
- const cancel = { key: "cancel", label: "Cancel", where: "in the box" };
498
- const items = d.type === "alert" ? [ok] : d.type === "prompt" ? [{ key: "field", label: "(text box)", where: "in the box" }, ok, cancel] : [ok, cancel];
499
- return items.map((it, i) => ({ ...it, i }));
500
- }
501
-
502
- /** 창이 떠 있는 화면. 페이지를 읽을 수 없다(멈춰 있다) — 창만 보인다 */
503
- dialogLook() {
504
- const d = this.dialog;
505
- const what = d.type === "alert" ? "A message box from the browser popped up over the page:" : d.type === "prompt" ? "A box from the browser popped up over the page, asking you to type something:" : "A box from the browser popped up over the page, asking:";
506
- const lines = [what, "", d.message || "(no text)", ""];
507
- if (d.type === "prompt") lines.push(this.dialogText !== null ? `You have typed: ${this.dialogText}` : d.defaultValue ? `It already says: ${d.defaultValue}` : "The text box is empty.", "");
508
- lines.push(d.type === "alert" ? "The page behind it is frozen until you close the box." : "The page behind it is frozen until you answer.");
509
- const view = { path: d.path, scrollY: 0, height: 0, viewport: 0, items: this.dialogItems().map(({ i, label, where }) => ({ i, label, where })), dialog: true };
510
- return { view, text: lines.join("\n") };
511
- }
512
-
513
- /**
514
- * 창에 답한다 — OK·Cancel 을 누르거나 prompt 칸에 친다. 원래 걸음(_act)은 창이 닫힌 뒤 마저 끝난다.
515
- * 고른 것은 그 걸음 기록에 붙는다(record.dialogs). 다른 조작은 창이 막는다 — 떠나기는 Cancel 하고 떠난다
516
- */
517
- async answerDialog(decision) {
518
- await this.waitTurn();
519
- const dl = this.dialog;
520
- const item = this.dialogItems()[decision.index];
521
- let choice = null;
522
- if (decision.action === "leave") choice = "cancel";
523
- else if (decision.action === "type" && item && item.key === "field") {
524
- this.dialogText = String(decision.text || "").slice(0, 200);
525
- await sleep(800 + Math.random() * 1500);
526
- this.nextAt = Date.now() + 600;
527
- return { dialog: true };
528
- } else if (decision.action === "click" && item && item.key !== "field") choice = item.key;
529
- else if (decision.action === "click" && item) return { skipped: "you can only type into that text box" };
530
- else return { skipped: decision.action === "click" || decision.action === "type" ? "that number is not on the screen" : "the box from the browser is still open on top of the page — answer it first" };
531
-
532
- this.dialog = null;
533
- const answer = choice === "ok" ? (dl.type === "prompt" ? "typed and pressed OK" : "OK") : "Cancel";
534
- await (choice === "ok" ? dl.d.accept(dl.type === "prompt" ? this.dialogText ?? dl.defaultValue : undefined) : dl.d.dismiss()).catch(() => {});
535
- if (this.curRecord) (this.curRecord.dialogs = this.curRecord.dialogs || []).push({ type: dl.type, message: dl.message, answer, why: decision.why, t: Date.now() - this.t0 });
536
- this.log(` browser box "${dl.message.slice(0, 40)}" → ${answer}`);
537
- // 원래 걸음이 마저 끝나기를 본다. 창이 또 뜨면 다시 방문자에게
538
- const r = await this.untilDialog(this.dialogWork);
539
- if (r && r.dialog) return r;
540
- this.dialogWork = null;
541
- if (decision.action === "leave") return this.act(decision);
542
- return r || { step: this.steps.length };
543
- }
544
-
545
- /** 걸음을 하다가 창이 뜨면 거기서 돌려준다({ dialog: true }) — 걸음은 창이 닫힌 뒤 마저 간다 */
546
- async untilDialog(work) {
547
- let wake;
548
- const seen = new Promise((res) => (wake = res));
549
- this.dialogWake = wake;
550
- this.acting = true;
551
- const first = await Promise.race([work.then((r) => ({ r })), seen.then(() => null)]);
552
- if (first) {
553
- this.acting = false;
554
- this.dialogWake = null;
555
- return first.r;
556
- }
557
- this.dialogWork = work;
558
- // 창 글을 읽는 시간
559
- this.nextAt = Date.now() + 1200 + Math.min(4000, this.dialog.message.length * 40);
560
- return { dialog: true };
561
- }
562
-
563
- /**
564
- * **첫 걸음을 떼기 전에 SDK 가 붙기를 기다린다.**
565
- * 광고·트래커가 많은 사이트는 SDK 가 늦게 붙는데, 그 사이의 조작은 기록에 안 남는다.
566
- * 방문은 성공한 것처럼 끝나고 기록만 앞부분이 잘려서 **조용히 틀린 데이터**가 된다.
567
- * (2026-09-21 사람인: goto 60초 타임아웃 뒤에 붙었다. 블루태그스는 로컬이라 빨라서 안 드러났다.)
568
- */
569
- async begin() {
570
- this.sdk = await waitForSdk(this.page);
571
- if (!this.sdk.ready) {
572
- // 기다려도 안 붙었으면 이 방문의 기록은 못 믿는다. 조용히 넘기지 않는다
573
- this.log(`⚠ The SDK did not start within ${Math.round(this.sdk.ms / 1000)}s — this visit's behaviour record will be empty or partial`);
574
- } else if (this.sdk.ms > 3000) {
575
- this.log(`SDK started after ${(this.sdk.ms / 1000).toFixed(1)}s`);
576
- }
577
- this.nextAt = Date.now() + firstLookMs(this.person.attention);
578
- return this.sdk;
579
- }
580
-
581
- /** 이 화면을 보고 다음에 손대기까지. 새로 보이는 글이 많을수록 길다(persona.js Rhythm.gap) */
582
- gapFor(view, text) {
583
- if (!this.rhythm) return 1200;
584
- const g = this.rhythm.gap({ sig: view.path, chars: text.length, media: 1, top: view.scrollY, vh: view.viewport, cmd: this.lastAction || "look" });
585
- return Math.min(45000, Math.max(250, g.ms));
586
- }
587
-
588
- /** 정해 둔 때까지 기다린다. AI 가 그보다 오래 생각했으면 바로 간다 */
589
- async waitTurn() {
590
- const ms = this.nextAt - Date.now();
591
- if (ms > 0) await sleep(ms);
592
- }
593
-
594
- /** 이번 방문에 걸었던 마음만큼 봤나. 여기서부터는 나갈 결심이 선다 */
595
- get patienceSpent() {
596
- return !!(this.shape && this.seenPaths.size > this.shape.screens && !this.card);
597
- }
598
-
599
- /**
600
- * 지금 무엇이 보이나. 카드가 떠 있으면 카드부터 돌려준다 — 이게 이 방문의 산출물이다.
601
- * **보이는 만큼만.** 글도 조작도 화면 안의 것이다 — 아래는 내려 봐야 안다.
602
- * @returns { card } 또는 { view, text }
603
- */
604
- async look() {
605
- if (this.ended) return { ended: true };
606
- // 브라우저 창이 떠 있다. 페이지는 멈춰 있어 읽으면 걸린다 — 창만 보여 준다
607
- if (this.dialog) return this.dialogLook();
608
- if (!this.card && !this.pending) {
609
- const shown = await waitCard(this.page, 500);
610
- if (shown) this.pending = shown;
611
- }
612
- // 카드 판단에는 페이지 전체를 준다 — 이 방문에서 본 것을 다 기억하는 사람이다
613
- if (this.pending) return { card: this.pending, text: await screenText(this.page) };
614
- const view = await listControls(this.page);
615
- // 위쪽만 빠진 것은 넘침이 아니다 — 굴려 올리면 다시 나온다
616
- if (view.droppedInView > (view.droppedIcons || 0)) this.diag.push({ kind: "controls_overflow", detail: `dropped ${view.droppedInView - (view.droppedIcons || 0)}` });
617
- const text = await visibleText(this.page, 2500);
618
- this.seenPaths.add(view.path);
619
- this.lastView = view;
620
- this.nextAt = Math.max(this.nextAt, Date.now() + this.gapFor(view, text));
621
- return { view, text };
622
- }
623
-
624
- /**
625
- * 카드에 답한다(또는 닫는다). decision 은 answer.js DECISION_SCHEMA 모양.
626
- * 이유를 SDK 에 먼저 남긴 뒤 누른다 — SDK 가 보낼 때 읽는다.
627
- */
628
- async answer(decision) {
629
- const shown = this.pending;
630
- if (!shown) throw new Error("No card is showing");
631
- const act = answer.toAction(decision, shown);
632
- // 카드를 읽고 정하는 시간. 닫는 건 금방, 쓰는 건 치는 만큼
633
- await sleep(act.kind === "close" ? 600 + Math.random() * 1800 : 1500 + Math.random() * 3000);
634
- await noteReason(this.page, decision.bothered ? decision.why : decision.botheredWhy);
635
- await answerCard(this.page, act).catch(() => {});
636
- this.card = {
637
- question: shown.question,
638
- choices: shown.choices,
639
- bothered: decision.bothered,
640
- botheredWhy: decision.botheredWhy,
641
- why: decision.why,
642
- result: act.kind === "close" ? "close" : act.kind,
643
- answer: act.kind === "choose" ? shown.choices[act.index] : act.kind === "other" ? act.text : null,
644
- t: Date.now() - this.t0,
645
- path: (() => {
646
- try {
647
- return new URL(this.page.url()).pathname;
648
- } catch {
649
- return null;
650
- }
651
- })(),
652
- };
653
- this.pending = null;
654
- this.history.push(`A survey card appeared and you ${this.card.result === "close" ? "closed it" : "answered it"}.`);
655
- this.log(`Card: ${this.card.result}${this.card.answer ? ` — ${this.card.answer}` : ""}`);
656
- this.nextAt = Date.now() + 800 + Math.random() * 2500;
657
- return this.card;
658
- }
659
-
660
- /**
661
- * 한 걸음. decision 은 STEP_SCHEMA 모양({ why, action, index, text, done, times }).
662
- * 번호는 마지막 look() 화면 기준이다. 떠나면 끝나고, 떠나는 순간 뜬 카드가 있으면 pending 에 남는다.
663
- * 보고 있던 사이에 카드가 떴으면 하려던 걸 하지 않고 { interrupted: true } 를 돌려준다 — 카드부터 본다.
664
- * 하는 도중 브라우저 창이 뜨면 { dialog: true } — 다음 look() 이 창을 보여 주고, 다음 act() 가 창에 답한다
665
- */
666
- async act(decision) {
667
- if (this.dialog) return this.answerDialog(decision);
668
- return this.untilDialog(this._act(decision));
669
- }
670
-
671
- async _act(decision) {
672
- if (this.ended) throw new Error("This visit has already ended");
673
- if (this.pending) throw new Error("A card is showing — answer or close it first");
674
- await this.waitTurn();
675
- if (!this.card) {
676
- const c = await readCard(this.page);
677
- if (c && c.visible) {
678
- this.pending = c;
679
- return { interrupted: true };
680
- }
681
- }
682
- if (this.steps.length >= HARD_STEP_CAP) decision = { ...decision, action: "leave", why: `(${HARD_STEP_CAP}-step limit) ${decision.why}` };
683
- const page = this.page;
684
- const view = this.lastView || (await listControls(page));
685
- const item = view.items[decision.index];
686
- const record = { step: this.steps.length + 1, t: Date.now() - this.t0, path: view.path, action: decision.action, why: decision.why, label: item?.label ?? null };
687
- this.curRecord = record;
688
- await this.checkLastJudgement(decision);
689
- const diagFrom = this.diag.length;
690
- const times = Math.max(1, Math.min(MAX_TIMES[decision.action] || 1, Math.round(Number(decision.times) || 1)));
691
- if (times > 1) record.times = times;
692
- // 한 번에 할 수 있는 수보다 많이 달라고 했다. 말없이 줄이면 "사이트가 클릭을 삼킨다" 로 읽었다(10/20 실험실 p7: 15·20·30번이 다 4번)
693
- if (Math.round(Number(decision.times) || 1) > times) record.cappedTimes = times;
694
- const before = page.url();
695
- const offsiteBefore = this.offsite.length;
696
- const notSentBefore = this.notSent.length;
697
- const externalBefore = this.external.length;
698
- this.fileWindow = null;
699
- this.lastAction = decision.action;
700
- this.docStatus = null;
701
-
702
- if (decision.action === "leave") {
703
- record.done = !!decision.done;
704
- if (!record.done) await this.shoot(record, "left_unhappy");
705
- this.steps.push(record);
706
- this.log(`${record.step}. left — ${decision.why}`);
707
- // 떠나는 모양도 사람처럼. 데스크톱은 커서가 창 위로 빠져나간다
708
- if (this.person.device !== "mobile") {
709
- await page.mouse.move(600, 120, { steps: 4 }).catch(() => {});
710
- await page.mouse.move(600, 2, { steps: 6 }).catch(() => {});
711
- await page.mouse.move(600, -8, { steps: 2 }).catch(() => {});
712
- }
713
- this.ended = true;
714
- // 떠나려는 순간에 뜨는 카드가 있다. 모바일은 SDK 가 멈춤으로 대신 보므로 더 기다린다
715
- if (!this.card) {
716
- const parting = await waitCard(page, this.person.device === "mobile" ? 16000 : 6000);
717
- if (parting) this.pending = parting;
718
- }
719
- return record;
720
- }
721
-
722
- const net = watchNet(page);
723
- let diagEl = null; // 진단에 생김새를 남길 요소
724
- let probe = null; // 누르기 전 지문 — 눌렀는데 그대로였나
725
- if (decision.action === "read") {
726
- // 가만히 읽는 시간. 사람은 계속 누르지 않는다 — 이게 없으면 "N초 머묾" 같은 조건이 영영 안 걸린다.
727
- // 기다리는 건 다음 조작 때(waitTurn). 그 사이 카드가 뜨면 다음 조작 전에 본다
728
- const base = { skims: 6000, reads: 14000, studies: 24000 }[this.person.attention ?? "reads"] ?? 14000;
729
- record.readMs = Math.round(base * (0.6 + Math.random() * 0.8));
730
- this.nextAt = Date.now() + record.readMs;
731
- } else if (decision.action === "scroll" || decision.action === "scroll_up") {
732
- // 훑으며 연달아 굴린다. 사이는 짧다 — 멈춰서 읽을 거면 따로 read 한다
733
- record.scrolled = 0;
734
- for (let i = 0; i < times; i++) {
735
- if (i) await sleep(300 + Math.random() * 1500);
736
- record.scrolled += await hand.scrollLikePerson(page, this.handState, decision.action === "scroll_up" ? -1 : 1);
737
- }
738
- await sleep(250);
739
- } else if (decision.action === "back") {
740
- await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
741
- if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "back" });
742
- } else if (decision.action === "type") {
743
- // 개인정보가 들어갈 칸(이메일·전화·카드 등)에는 치지 않는다. 고객 정책이 더 막거나 열 수 있다
744
- const fieldOff = this.policy && this.policy.fieldBlocked(item?.label || "");
745
- const el = fieldOff ? null : item ? await handleAt(page, decision.index) : null;
746
- diagEl = el;
747
- // 방문자에게는 사람의 선택으로 알린다. "field blocked by policy" 를 그대로 주면 방문자가 "칸이 입력을 안 받는다" 고
748
- // 적어 사이트 결함처럼 읽혔다(10/14 실험실 p26, 비밀번호 찾기 이메일 칸)
749
- if (fieldOff) record.personalField = item?.label ?? "";
750
- else if (!el) record.skipped = "no longer there";
751
- else {
752
- // 칸을 먼저 누르고 친다. 칸에 남는 글자는 언제나 원래 치려던 것이다(오타는 스스로 고친다)
753
- await hand.clickLikePerson(page, this.handState, el).catch((e) => (record.skipped = e.human ? `you could not get into that field: ${e.human}` : "you could not get into that field"));
754
- // 칸에 전에 친 글이 남아 있으면 지우고 친다 — 사람은 새 검색어를 옛 것 뒤에 잇지 않는다.
755
- // 안 지우면 "PolygonPolygon" 이 되고 그게 사이트 문제로 보고된다(10/13 bluetags 3명)
756
- const multiline = await el.evaluate((n) => n.tagName === "TEXTAREA" || n.isContentEditable).catch(() => false);
757
- // 한 줄 칸(검색)은 짧게, 여러 줄 칸(댓글·답글·채팅)은 문장 몇 개까지 — 80 자로 자르면 답글이 낱말 중간에서 끊겨 올라갔다(10/21 실험실 m1)
758
- const typed = String(decision.text || "").slice(0, multiline ? 280 : 80);
759
- const value = await el.evaluate((n) => String(n.value ?? n.textContent ?? "")).catch(() => "");
760
- /**
761
- * Enter 는 사람이 누를 때만. 여러 줄 칸(댓글·답글)에서 Enter 는 줄바꿈이고, 사람은 Post 를 누른다.
762
- * 치는 대로 결과가 걸러지는 칸에서는 사람은 Enter 없이 결과를 본다 — 그 칸이 <form> 안이면 Enter 가 폼을 보내
763
- * 페이지가 다시 열리고 검색이 사라졌다(10/15 실험실 x40, bluetags 커뮤니티 검색).
764
- * 그때는 Enter 를 안 눌렀다고 알리고, 같은 글을 또 치면 Enter 만 누른다(추천 목록이 뜨는 검색칸에서 결과 화면으로 가려는 사람)
765
- */
766
- if (!multiline && typed && value === typed && this.heldEnter === typed) {
767
- await page.keyboard.press("Enter").catch(() => {});
768
- this.heldEnter = null;
769
- } else {
770
- if (value.length) {
771
- // 칸에 전에 친 글이 남아 있으면 지우고 친다 — 사람은 새 검색어를 옛 것 뒤에 잇지 않는다.
772
- // 안 지우면 "PolygonPolygon" 이 되고 그게 사이트 문제로 보고된다(10/13 bluetags 3명)
773
- await page.keyboard.press("ControlOrMeta+A").catch(() => {});
774
- await sleep(120 + Math.random() * 300);
775
- await page.keyboard.press("Backspace").catch(() => {});
776
- await sleep(150 + Math.random() * 350);
777
- }
778
- // 치기 전 화면. 치는 대로 걸러 주는 칸이면 친 뒤 화면이 이미 바뀌어 있다
779
- const shownBefore = multiline ? "" : await visibleText(page, 2500);
780
- // 채팅칸인지는 치기 **전** 높이로 본다 — 글 길이에 따라 커지는 칸은 긴 질문을 치면 여러 줄이 되어 채팅칸으로 안 보였다(10/21 bluetags 82fd)
781
- const oneLine = multiline && (await el.evaluate((n) => n.getBoundingClientRect().height <= 64).catch(() => false));
782
- record.typos = await hand.typeLikePerson(page, this.handState, typed).catch(() => 0);
783
- let changed = false;
784
- if (!multiline) {
785
- await sleep(500 + Math.random() * 400);
786
- // 친 글과 이어진 변화만 친다 — 새로 나타난 줄에 친 말이 들어 있어야 추천·걸러진 목록이다.
787
- // 그냥 달라진 걸로 보면 저절로 넘어가는 배너가 "치는 대로 바뀌었다" 가 되어 Enter 를 안 눌렀다(10/21 실험실 i3)
788
- // 걸러지는 목록(10/15 x40 커뮤니티 검색)은 새 줄 없이 줄이 빠지고 남은 것에 친 말이 있다
789
- const word = typed.trim().toLowerCase();
790
- const beforeLines = shownBefore.split("\n");
791
- const afterText = await visibleText(page, 2500);
792
- const afterLines = afterText.split("\n");
793
- const before = new Set(beforeLines);
794
- const after = new Set(afterLines);
795
- const added = afterLines.filter((l) => !before.has(l));
796
- const removed = beforeLines.filter((l) => !after.has(l));
797
- const suggested = added.some((l) => l.toLowerCase().includes(word));
798
- const filtered = removed.length >= 3 && added.length <= 1 && afterText.toLowerCase().includes(word);
799
- changed = !!word && (suggested || filtered);
800
- }
801
- if (!multiline && !changed) await page.keyboard.press("Enter").catch(() => {});
802
- /**
803
- * 한 줄짜리 여러 줄 칸 = 채팅 입력칸. 사람은 메신저처럼 Enter 로 보낸다 — 안 누르면 이름 없는 보내기 아이콘을
804
- * 못 찾아 메시지를 못 보냈다(10/21 bluetags 2b72, 커뮤니티 채팅). Enter 가 줄만 바꿨으면 Enter 로 안 보내는 칸이니 지운다
805
- */
806
- if (oneLine && typed) {
807
- await sleep(300 + Math.random() * 500);
808
- await page.keyboard.press("Enter").catch(() => {});
809
- await sleep(250);
810
- const after = await el.evaluate((n) => String(n.value ?? n.innerText ?? "")).catch(() => "");
811
- if (/\n\s*$/.test(after) && after.trim() === typed.trim()) await page.keyboard.press("Backspace").catch(() => {});
812
- // 칸이 비었으면 Enter 로 보낸 것 — 방문자에게 말한다. 안 하면 "보내지도 않았는데 올라갔다" 가 됐다(10/21 실험실 g2)
813
- else if (!after.trim()) record.sentWithEnter = true;
814
- }
815
- this.heldEnter = !multiline && changed ? typed : null;
816
- if (this.heldEnter) record.heldEnter = true;
817
- }
818
- if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "type" });
819
- diagEl = el;
820
- }
821
- } else {
822
- const blocked = item ? clickBlocked(item, { allowAccounts: this.allowAccounts, policy: this.policy }) : "that number is not on the screen";
823
- if (blocked) record.skipped = blocked;
824
- else {
825
- const el = await handleAt(page, decision.index);
826
- diagEl = el;
827
- // 보는 사이 사이트가 저절로 다른 화면으로 보냈다. "없어졌다" 만 주면 방문자가 영문을 몰랐다(10/22 bluetags 홈 → /signup, p2037)
828
- const movedTo = !el && this.pathNow().split("?")[0] !== String(view.path || "").split("?")[0] ? this.pathNow() : null;
829
- if (!el) record.skipped = movedTo ? `before you could click it, the site moved you to another page by itself (${movedTo})` : "no longer there";
830
- else {
831
- // 같은 사이트를 새 탭으로 여는 링크는 이 탭에서 열게 한다. 사람은 열린 탭에서 이어 보고, 방문은 한 탭이다.
832
- // 새 탭을 잡아 옮기는 건(hub) 브라우저가 새 탭을 늦게, 주소 없이 알려 줘서 놓쳤다 —
833
- // 방문자는 "안 열린다" 를 보고 다시 누르면 "없어졌다" 가 됐다(10/14 실험실 p3, 첫 화면 카드)
834
- probe = { el, before: await clickPrint(page, el) };
835
- await el
836
- .evaluate((n) => {
837
- const a = n.closest("a[target]");
838
- if (a && a.target !== "_self" && a.href && new URL(a.href, location.href).origin === location.origin) a.target = "_self";
839
- })
840
- .catch(() => {});
841
- // 사람 손이라 빗나갈 수 있다. 빈 곳을 누르면 아무 일도 안 일어난다 — 그게 헛클릭이다
842
- let ours = false;
843
- const clicked = await hand
844
- .clickLikePerson(page, this.handState, el)
845
- .catch((e) => ((record.skipped = e.human || humanClickError(e)), (ours = /eb-insight|eb-plugin/.test(String(e.message))), { missed: false }));
846
- // 사이트의 무언가가 덮어 못 눌렀다. 우리 카드·위젯이 덮은 것은 사이트 탓이 아니다
847
- if (record.skipped && /on top of it/.test(record.skipped) && !/survey card/.test(record.skipped) && !ours)
848
- await this.shoot(record, "covered", await boxOf(page, el));
849
- if (clicked.missed) {
850
- record.missed = true;
851
- this.history.push("Your click landed just outside it and nothing happened.");
852
- }
853
- // 잘려 있던 것을 줄째 옆으로 밀어 가져와 눌렀다
854
- if (clicked.swiped) record.swiped = true;
855
- // 떠 있던 창 바깥(막)을 눌러 창이 닫혔다. 버튼에는 안 닿았다
856
- if (clicked.closedOverlay && page.url() === before) {
857
- record.closedOverlay = true;
858
- this.history.push("Your click closed what was open on top of the page; it did not reach the thing you clicked.");
859
- }
860
- // 안 눌린다 싶어 거듭 누른다. 화면이 바뀌면 멈춘다
861
- for (let i = 1; i < times && !record.skipped && this.handState.mouse; i++) {
862
- await sleep(150 + Math.random() * 450);
863
- // 창이 떴었다 — 화면이 반응한 것이다. 또 누르면 같은 창이 또 뜬다
864
- if (page.url() !== before || this.offsite.length > offsiteBefore || record.dialogs) break;
865
- await page.mouse.click(this.handState.mouse.x, this.handState.mouse.y).catch(() => {});
866
- }
867
- if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "click", shape: await diag.shapeOf(el) });
868
- }
869
- }
870
- }
871
-
872
- net.stop();
873
-
874
- // 조작 뒤 탭이 빈 페이지(about:blank 등)가 됐다 — 앱을 여는 링크(kakaolink:, intent:, tel:)를 브라우저가 못 열 때 그렇다.
875
- // 뒤로 가기로도 안 돌아와 방문자가 빈 화면에 갇혔다(10/15 실험실 p28, 모바일 공유 창). 원래 화면으로 돌려놓는다
876
- if (/^https?:/i.test(before) && !/^https?:/i.test(page.url())) {
877
- const blankAt = page.url().slice(0, 40);
878
- await page.goto(before, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
879
- await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
880
- // 뒤로 가기가 빈 페이지로 간 것은 앱이 아니라 방문의 시작보다 앞으로 간 것이다(바로 들어온 첫 페이지).
881
- // "다른 앱을 열려 했다" 고 알리면 방문자가 사이트 탓으로 적었다(10/22 실험실 q4)
882
- // 누르지도 않았는데 빈 페이지가 됐다 — 앱 링크가 아니다. "앱을 열려 했다" 를 "없어졌다" 와 같이 들으면 모순이다(10/23 실험실 O2·P1).
883
- // 원인을 아직 못 찾았다 — 그때 주소와 끊긴 이동을 남긴다
884
- // 서버는 설명에 영숫자·_:.- 와 빈칸만 받는다 — 주소의 / 가 있으면 통째로 버린다
885
- const detail = `${blankAt}${this.lastFailedNav ? ` after ${this.lastFailedNav}` : ""}`.replace(/[^a-z0-9_:.\- ]/gi, "_").slice(0, 200);
886
- if (decision.action === "back") {
887
- record.backOut = true;
888
- } else if (record.skipped) {
889
- this.diag.push({ kind: "blank_unprompted", action: decision.action, shape: null, detail });
890
- } else {
891
- record.openedApp = true;
892
- this.diag.push({ kind: "left_to_blank", action: decision.action, shape: null, detail });
893
- }
894
- }
895
-
896
- // 사이트 밖으로 가는 링크였다. 끊었으니 방문자는 그대로 이 화면에 있다.
897
- // 새 탭으로 여는 링크(target=_blank)는 방어선을 안 지나고 열린다 — 누른 링크 주소로도 본다(탭은 hub 가 닫는다)
898
- const hrefOff = decision.action === "click" && !record.skipped ? offsiteHost(item?.href, before) : null;
899
- if (this.offsite.length > offsiteBefore) {
900
- try {
901
- record.offsite = new URL(this.offsite[this.offsite.length - 1]).host;
902
- } catch {
903
- record.offsite = "another site";
904
- }
905
- } else if (hrefOff) record.offsite = hrefOff;
906
-
907
- // 쓰려다 끊겼다. 방문자가 이걸 사이트가 안 된다로 읽지 않게 알린다
908
- // 다른 화면으로 넘어갔으면 그 사이 끊긴 것은 화면을 열며 저절로 나간 것이다(읽음 표시·조회수) — 방문자가 보낸 게 아니다.
909
- // 그걸 "저장·게시를 안 보냈다" 로 알리면 카드를 열었을 뿐인데 무엇을 보내려 한 줄 안다(10/14 실험실 p6, POST /api/bluecards/user-read).
910
- // 기록에는 남긴다
911
- if (this.notSent.length > notSentBefore) {
912
- if (page.url() !== before) record.notSentQuiet = this.notSent.slice(notSentBefore);
913
- else record.notSent = this.notSent.slice(notSentBefore);
914
- }
915
- const ext = [...new Set(this.external.slice(externalBefore))].filter((h) => !this.externalTold.has(h));
916
- if (ext.length) {
917
- ext.forEach((h) => this.externalTold.add(h));
918
- record.external = ext;
919
- }
920
- if (this.fileWindow) record.fileWindow = fileWindowNote(this.fileWindow);
921
- await this.noteDiag(record, decision, diagEl);
922
-
923
- // 화면이 깨졌다 — 오류로 온 문서, 오류 문구뿐인 화면. 바깥 사이트·앱·빈 페이지로 간 것은 뺀다
924
- if (
925
- !record.offsite &&
926
- !record.openedApp &&
927
- !record.backOut &&
928
- ((this.docStatus && this.docStatus >= 400) || (page.url() !== before && (await errorScreen(page))))
929
- )
930
- await this.shoot(record, "error");
931
-
932
- // 눌렀는데 그대로. 우리 쪽 사정(빗나감·막은 요청·바깥 사이트·파일 창)으로 그대로인 것은 빼고, 같은 것은 한 번만.
933
- // 브라우저 창이 떴으면 반응한 것이다 — Cancel 을 골라 그대로인 것은 사이트 탓이 아니다
934
- if (
935
- probe &&
936
- !record.skipped &&
937
- !record.dialogs &&
938
- !record.missed &&
939
- !record.closedOverlay &&
940
- !record.offsite &&
941
- !record.notSent &&
942
- !record.notSentQuiet &&
943
- !record.external &&
944
- !record.fileWindow &&
945
- !record.openedApp &&
946
- !record.backOut &&
947
- !this.steps.some((s) => s.screen && s.screen.kind === "no_change" && s.label === record.label && s.path === record.path)
948
- ) {
949
- const after = await clickPrint(page, probe.el);
950
- if (after.url === probe.before.url && after.doc && after.doc === probe.before.doc && after.own !== null && after.own === probe.before.own)
951
- await this.shoot(record, "no_change", await boxOf(page, probe.el));
952
- }
953
-
954
- // 누른 링크와 다른 곳으로 왔다 — 움직이는 줄에서 다른 카드를 눌렀을 수 있다(10/22 실험실 k5: OKX 를 눌렀는데 Uniswap).
955
- // 사이트가 돌려보낸 것(로그인 등)일 수도 있다 — 우리가 보고 가린다
956
- if (decision.action === "click" && !record.skipped && !record.offsite && item?.href && page.url() !== before) {
957
- const want = sameDocPath(item.href, before);
958
- const got = sameDocPath(page.url(), before);
959
- if (want && got && want !== got) this.diag.push({ kind: "landed_elsewhere", action: "click", shape: await diag.shapeOf(probe && probe.el) });
960
- }
961
- await this.diagContext(record, diagFrom, probe ? probe.el : diagEl);
962
-
963
- this.steps.push(record);
964
- this.history.push(
965
- `${decision.action}${times > 1 ? ` ×${times}` : ""}${item ? ` "${item.label}"` : ""} — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}${record.cappedTimes ? " (that is the most in one go)" : ""}${record.offsite ? ` (it leads to another website, ${record.offsite})` : ""}${record.notSent ? " (it would have sent something to the site; it was not sent)" : ""}${record.fileWindow ? " (a window to choose a file opened; you closed it without choosing)" : ""}${record.personalField !== undefined ? " (you chose not to enter personal details there)" : ""}${record.openedApp ? " (it tried to open another app; nothing opened)" : ""}${record.backOut ? " (going back from here would have taken you off the site; you stayed)" : ""}${(record.dialogs || []).map((d) => ` (a box from the browser said "${d.message.slice(0, 120)}"; you pressed ${d.answer === "Cancel" ? "Cancel" : "OK"})`).join("")}`,
966
- );
967
- this.log(`${record.step}. ${decision.action}${item ? ` "${String(item.label).slice(0, 30)}"` : ""}${record.skipped ? ` · ${record.skipped}` : ""} — ${decision.why}`);
968
- this.lastView = null;
969
- return record;
970
- }
971
-
972
- /**
973
- * 이 걸음에서 도구가 하지 못한 것을 진단에 남긴다. 글자는 넣지 않는다 — 종류와 요소의 생김새만
974
- * 같은 것을 같은 자리에서 바로 또 눌렀다(화면이 안 바뀌었다)도 남긴다 — 사이트 탓일 수도, 우리가 못 본 것일 수도 있다
975
- */
976
- async noteDiag(record, decision, el) {
977
- const kind = diag.kindOf(record.skipped);
978
- const action = decision.action;
979
- if (kind) this.diag.push({ kind, action, shape: await diag.shapeOf(el) });
980
- if (record.swiped) this.diag.push({ kind: "swiped", action, shape: await diag.shapeOf(el) });
981
- if (record.closedOverlay) this.diag.push({ kind: "popup_backdrop", action });
982
- if (record.missed) this.diag.push({ kind: "missed", action });
983
- const prev = this.steps[this.steps.length - 1];
984
- if (action === "click" && !record.skipped && prev && prev.action === "click" && prev.label === record.label && prev.path === record.path && !prev.skipped)
985
- this.diag.push({ kind: "reclick_same", action, shape: await diag.shapeOf(el) });
986
- }
987
-
988
- /**
989
- * 바로 앞 걸음에 대한 도구 판단이 방문자의 다음 말과 엇갈리나. 엇갈리면 도구가 잘못 본 것이다 — 진단으로 남긴다.
990
- * - "눌렀는데 그대로" 로 찍었는데 방문자는 "저장됐다·파랗게 됐다·열렸다" 고 한다 → 그 사진은 고객에게 안 보낸다
991
- * (10/22 bluetags 저장: 북마크가 파랗게 됐는데 그대로로 찍혔다)
992
- * - 화면이 바뀌었다고 봤는데 방문자는 "아무 일 없다·안 열린다" 고 한다
993
- * 방문자의 말은 사이트 글자가 아니라 우리 AI 가 쓴 말이다
994
- */
995
- async checkLastJudgement(decision) {
996
- const last = this.steps[this.steps.length - 1];
997
- // 떠나며 하는 말은 방문 전체를 돌아본다 — 앞의 다른 것("그 링크가 안 열렸다")을 바로 앞 걸음 일로 읽었다(10/22 p2018)
998
- if (!last || last.action !== "click" || last.skipped || !decision.why || decision.action === "leave") return;
999
- const from = this.diag.length;
1000
- if (last.screen && last.screen.kind === "no_change" && SAYS_CHANGED.test(decision.why)) {
1001
- this.diag.push({ kind: "no_change_doubt", action: "click", detail: `step ${last.step}` });
1002
- this.screens = this.screens.filter((x) => x.step !== last.step);
1003
- delete last.screen;
1004
- } else if (!last.screen && !last.missed && !last.closedOverlay && SAYS_NOTHING.test(decision.why)) {
1005
- this.diag.push({ kind: "visitor_saw_nothing", action: "click", detail: `step ${last.step}` });
1006
- }
1007
- // 지금 화면이 그 걸음 바로 뒤의 화면이다
1008
- if (this.diag.length > from) await this.diagContext(last, from, null);
1009
- }
1010
-
1011
- /**
1012
- * 이번 걸음에 생긴 진단에 그 순간을 붙인다 — 화면(우리만 본다), 요소 주변 HTML, 페이지 경로.
1013
- * 종류와 생김새만으로는 못 고친다: 10/22 무한 배너는 "잘림·회전 배너" 까지만 알았고, 같은 카드 복사본 때문인 건 열어 봐야 알았다
1014
- */
1015
- async diagContext(record, from, el) {
1016
- const events = this.diag.slice(from).filter((e) => !e.id && !DIAG_NO_CONTEXT.has(e.kind));
1017
- if (!events.length) return;
1018
- let pagePath = null;
1019
- try {
1020
- const u = new URL(this.page.url());
1021
- pagePath = (u.pathname + u.search).slice(0, 300);
1022
- } catch {}
1023
- const html = el
1024
- ? await el
1025
- .evaluate((n, max) => {
1026
- if (!n.isConnected) return null;
1027
- let best = n;
1028
- for (let p = n.parentElement, d = 0; p && p !== document.body && d < 4; p = p.parentElement, d++) if (p.outerHTML.length <= max) best = p;
1029
- return best.outerHTML.slice(0, max);
1030
- }, MAX_DIAG_HTML)
1031
- .catch(() => null)
1032
- : null;
1033
- let shotId = null;
1034
- if (this.diagShots.length < MAX_DIAG_SHOTS) {
1035
- const image = await this.snap();
1036
- if (image) shotId = randomUUID();
1037
- if (image) this.diagShots.push({ id: shotId, image });
1038
- }
1039
- events.forEach((e, i) => {
1040
- e.id = i === 0 && shotId ? shotId : randomUUID();
1041
- e.step = record.step;
1042
- if (pagePath) e.path = pagePath;
1043
- if (html) e.html = html;
1044
- });
1045
- }
1046
-
1047
- /**
1048
- * 문제가 있던 화면을 찍어 이 걸음에 붙인다(record.screen). 보이는 만큼만(뷰포트). 크면 한 번 더 작게, 그래도 크면 안 찍는다
1049
- * 실패해도 방문은 그대로 간다
1050
- */
1051
- async shoot(record, kind, box = null) {
1052
- if (!record || record.screen || this.screens.length >= MAX_SCREENS) return;
1053
- // 우리 카드가 떠 있다. 눌렀는데 그대로인 것도 카드가 가로챈 것일 수 있다 — 사이트 탓으로 찍지 않는다
1054
- const card = await readCard(this.page).catch(() => null);
1055
- if (card && card.visible) return;
1056
- const image = await this.snap();
1057
- if (!image) return;
1058
- record.screen = { kind, box };
1059
- this.screens.push({ step: record.step, image });
1060
- }
1061
-
1062
- /** 뷰포트 JPEG. 크면 한 번 더 작게, 그래도 크면 null */
1063
- async snap() {
1064
- let image = null;
1065
- for (const quality of [70, 45]) {
1066
- image = await this.page.screenshot({ type: "jpeg", quality, scale: "css", timeout: 5000 }).catch(() => null);
1067
- if (!image || image.length <= MAX_SCREEN_BYTES) break;
1068
- }
1069
- return image && image.length <= MAX_SCREEN_BYTES ? image : null;
1070
- }
1071
-
1072
- /** 카드 판단에 넘길 "이번 방문에 한 일" */
1073
- pathText() {
1074
- return this.history.length ? this.history.slice(-6).join(" / ") : "just arrived";
1075
- }
1076
-
1077
- result() {
1078
- return { steps: this.steps, card: this.card, shape: this.shape };
1079
- }
1080
- }
1081
-
1082
- /**
1083
- * 우리가 AI 에 물어 가며 한 방문을 끝까지 돈다(자동 실행).
1084
- * @param opts.ask async (systemPrompt, jsonSchema, message) => object
1085
- * @returns { steps: [...], card: {...}|null, shape }
1086
- */
1087
- async function runVisit(page, person, { siteAbout, ask, rhythm = null, shape = null, maxStepsCap = 40, allowAccounts = false, log = () => {} } = {}) {
1088
- /**
1089
- * 몇 쪽이나 볼지는 그 사람이 정한다. 방문 모양(shape)은 **인내심 표시**로만 쓰고 걸음 수로 자르지 않는다.
1090
- * 전에는 모양에서 걸음 상한을 뽑아(튕김이면 3걸음) 잘랐다 — 9/21 MCP 비교에서 8명 중 3명이
1091
- * "더 내려 볼게요" 하는 중에 잘려 떠나지도 못한 채 끝났다. MCP 길(고객 Claude)은 표시만 받으니 두 길의 규칙이 달랐다.
1092
- * 이제 둘 다 같다: 인내심 표시(`patienceSpent`) + 절대 상한(maxStepsCap, HARD_STEP_CAP).
1093
- */
1094
- const maxSteps = Math.min(maxStepsCap, HARD_STEP_CAP);
1095
- const v = new Visit(page, person, { rhythm, shape, allowAccounts, log });
1096
- await v.begin();
1097
-
1098
- const decideCard = async (shown) => {
1099
- const screen = await screenText(page);
1100
- return ask(answer.systemPrompt(), answer.DECISION_SCHEMA, answer.buildMessage(person, siteAbout, v.pathText(), screen, shown));
1101
- };
1102
-
1103
- for (let step = 1; step <= maxSteps && !v.ended; step++) {
1104
- const look = await v.look();
1105
- if (look.card) {
1106
- await v.answer(await decideCard(look.card));
1107
- continue;
1108
- }
1109
- if (v.patienceSpent) v.history.push("Your patience is running low. Unless what is in front of you is getting you closer to what you came for, this is about where you would give up.");
1110
- const counts = {};
1111
- for (const s of v.steps) counts[s.path] = (counts[s.path] || 0) + 1;
1112
- const visited = Object.entries(counts).map(([path, n]) => ({ path, n }));
1113
- const decision = await ask(stepPrompt(), STEP_SCHEMA, buildStepMessage(person, siteAbout, look.view, v.history, look.text, visited));
1114
- await v.act(decision);
1115
- }
1116
- // 떠나는 순간 뜬 카드
1117
- if (v.pending) {
1118
- const c = await v.answer(await decideCard(v.pending));
1119
- log(`Card (on leaving): ${c.result}${c.answer ? ` — ${c.answer}` : ""}`);
1120
- }
1121
- return v.result();
1122
- }
1123
-
1124
- module.exports = { STEP_SCHEMA, STEP_PROMPT_FILE, HARD_STEP_CAP, stepPrompt, buildStepMessage, renderScreen, renderPerson, Visit, runVisit, settle, watchNet };
1
+ "use strict";
2
+ /**
3
+ * 가상 사용자 한 명의 한 방문 — 자유 탐방.
4
+ *
5
+ * 길을 정해주지 않는다. 사람과 목적만 주고, 화면에 보이는 것만 보고 다음 행동을 스스로 고른다.
6
+ * 길을 박아두면 30명이 같은 화면만 보고 사람도 그 화면을 열 만한 부류로만 뽑힌다(2026-09-18 확인).
7
+ *
8
+ * 도중에 질문 카드가 뜨면 그 사람으로서 답하거나 닫는다(answer.js). 그게 이 방문의 산출물이고,
9
+ * 걸음마다 남긴 이유가 나중에 사람이 확인할 근거다.
10
+ *
11
+ * **판단과 몸을 가른다.** `Visit` 이 몸이다 — 화면 보기·사람 손으로 누르기·쉬기·카드 누르기·떠나기.
12
+ * 판단(무엇을 누를까·답할까)은 밖에서 준다. 누가 주느냐가 두 가지다:
13
+ * - `runVisit`: 우리가 AI(`ask`)에 물어 준다. 우리 내부 테스트·자동 실행
14
+ * - MCP 한 걸음 도구: 고객의 AI 도구(Claude Code 서브에이전트)가 그 사람이 되어 준다(PLAN.md)
15
+ * 둘이 같은 몸을 써야 사람처럼 움직이는 방식·막는 선이 갈라지지 않는다.
16
+ *
17
+ * 의존성 없음. Playwright page 를 받는다.
18
+ */
19
+ const fs = require("fs");
20
+ const path = require("path");
21
+ const { randomUUID } = require("crypto");
22
+ const { listControls, visibleText, handleAt, clickBlocked } = require("../browser/page.js");
23
+ const { readCard, waitCard, screenText, answerCard, noteReason, sleep, waitForSdk } = require("../browser/card.js");
24
+ const hand = require("../browser/hand.js");
25
+ const answer = require("./answer.js");
26
+ const diag = require("./diag.js");
27
+
28
+ const STEP_PROMPT_FILE = path.join(__dirname, "..", "prompts", "step.md");
29
+
30
+ const STEP_SCHEMA = {
31
+ type: "object",
32
+ properties: {
33
+ why: { type: "string" },
34
+ action: { type: "string", enum: ["click", "type", "scroll", "scroll_up", "read", "back", "leave"] },
35
+ index: { type: "integer" },
36
+ text: { type: "string" },
37
+ done: { type: "boolean" },
38
+ times: { type: "integer" },
39
+ },
40
+ required: ["why", "action", "index", "text", "done", "times"],
41
+ };
42
+
43
+ /** 같은 조작을 연달아 몇 번까지. 굴리며 훑기·안 눌려서 거듭 누르기 */
44
+ const MAX_TIMES = { click: 4, scroll: 5, scroll_up: 5 };
45
+
46
+ /**
47
+ * 문제가 있던 순간의 화면 — 대시보드가 그 걸음 아래에 보인다(서버 /agent/screens). **방문자(AI)에게는 안 보인다.**
48
+ * 걸음마다 찍지 않는다. 영상(세션 리플레이)은 봇의 움직임이 드러나 쓰지 않기로 했다(10/21)
49
+ * no_change 눌렀는데 화면이 그대로
50
+ * left_unhappy 원하던 걸 못 이루고 떠남(leave, done=false) — 마지막 화면
51
+ * covered 사이트의 무언가(팝업 등)가 덮어 못 누름 — 우리 카드·위젯이 덮은 것은 뺀다
52
+ * error 화면이 깨짐 — 문서가 오류로 옴(4xx·5xx), 오류 문구뿐인 화면.
53
+ * 끝내 안 열리는 화면은 못 찍는다 — 불러오는 중에는 브라우저가 화면을 안 내준다(10/22 시험)
54
+ * 우리 카드(Usercase 질문 카드)는 찍지 않는다 — 사이트 문제가 아니다. 카드가 떠 있는 동안도 안 찍는다(10/22)
55
+ * 누른 곳(box)은 사진에 그리지 않고 걸음에 비율(0~1)로 적는다. 대시보드가 위에 그린다
56
+ */
57
+ const MAX_SCREENS = 6;
58
+ /** 진단(도구가 못 했거나 잘못 본 것)마다 찍는 화면 — 우리만 본다. 고객 대시보드에는 안 간다(10/22) */
59
+ const MAX_DIAG_SHOTS = 6;
60
+ /** 진단에 붙이는 요소 주변 HTML 길이 */
61
+ const MAX_DIAG_HTML = 6000;
62
+ /** 진단으로 볼 만한 것이 아니다 — 의도한 동작(밀어서 가져옴·사람 같은 헛클릭) */
63
+ const DIAG_NO_CONTEXT = new Set(["swiped", "missed"]);
64
+ // 방문자가 바로 다음에 하는 말 — 도구 판단과 엇갈리면 도구가 잘못 본 것이다
65
+ const SAYS_CHANGED = /\b(saved|bookmarked|liked|went (blue|red|green|grey|gray|dark|bold)|turned (blue|red|green|on|off)|it (worked|opened|changed)|that (worked|opened)|now (shows|says)|popped (up|open)|opened up)\b/i;
66
+ const SAYS_NOTHING = /\b(nothing happened|nothing changed|didn.?t (open|work|do anything|respond|change|load|go anywhere)|not (working|responding|loading)|won.?t (open|load|work)|still the same|no response|(it|that|this|button|link)(.?s| is| seems| looks) dead)\b/i;
67
+ // 서버 앞 nginx 의 기본 본문 한도가 1MB 다 — base64 로 커지니 원본은 600KB 아래로
68
+ const MAX_SCREEN_BYTES = 600 * 1024;
69
+
70
+ /**
71
+ * 오류 문구뿐인 화면인가 — 404·"not found"·"application error" 등. 글이 짧은 화면만 본다(기사 속 "404" 는 아니다).
72
+ * 클라이언트에서 그리는 404(Next.js 등)는 문서가 200 으로 와서 상태로는 모른다
73
+ */
74
+ async function errorScreen(page) {
75
+ return page
76
+ .evaluate(() => {
77
+ const t = ((document.body && document.body.innerText) || "").replace(/\s+/g, " ").trim();
78
+ if (t.length > 600) return false;
79
+ return /\b(404|500|502|503)\b|not found|page (doesn.?t|does not|could not|cannot) (exist|be found)|application error|something went wrong|internal server error/i.test(t);
80
+ })
81
+ .catch(() => false);
82
+ }
83
+
84
+ /** 같은 사이트 주소의 경로(+쿼리). 다른 사이트면 null */
85
+ function sameDocPath(href, base) {
86
+ try {
87
+ const u = new URL(href, base);
88
+ if (u.origin !== new URL(base).origin) return null;
89
+ return (u.pathname.replace(/\/+$/, "") || "/") + u.search;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /** 요소가 화면에서 차지한 곳(화면 비율). 화면 밖이거나 사라졌으면 null */
96
+ async function boxOf(page, el) {
97
+ const b = el ? await el.boundingBox().catch(() => null) : null;
98
+ const vp = page.viewportSize();
99
+ if (!b || !vp || b.width <= 0 || b.height <= 0) return null;
100
+ const x0 = Math.max(0, b.x);
101
+ const y0 = Math.max(0, b.y);
102
+ const x1 = Math.min(vp.width, b.x + b.width);
103
+ const y1 = Math.min(vp.height, b.y + b.height);
104
+ if (x1 <= x0 || y1 <= y0) return null;
105
+ const r = (n) => Math.round(n * 1000) / 1000;
106
+ return { x: r(x0 / vp.width), y: r(y0 / vp.height), w: r((x1 - x0) / vp.width), h: r((y1 - y0) / vp.height) };
107
+ }
108
+
109
+ /**
110
+ * 누르기 전후를 견줄 지문 — 주소, 페이지 전체 글(보이는 것만, 굴려도 같다), 보이는 입력칸·그림, 누른 요소와 감싼 버튼의 상태·HTML(색만 바뀌는 좋아요·저장·토글).
111
+ * 입력칸은 글(innerText)에 안 잡힌다 — 검색 아이콘을 눌러 검색칸이 열렸는데 "그대로" 로 찍혔다(10/22 실험실 e4)
112
+ * 글이 저절로 바뀌는 사이트(넘어가는 배너)에서는 "바뀌었다" 로 본다 — 그대로라고 잘못 찍는 것보다 낫다
113
+ */
114
+ async function clickPrint(page, el) {
115
+ const doc = await page
116
+ .evaluate(() => {
117
+ const shown = (n) => {
118
+ const r = n.getBoundingClientRect();
119
+ return r.width > 0 && r.height > 0 && getComputedStyle(n).visibility !== "hidden";
120
+ };
121
+ const extra = [...document.querySelectorAll("input,textarea,select,[contenteditable=true],img,video,iframe,canvas")]
122
+ .filter(shown)
123
+ .map((n) => [n.tagName, n.getAttribute("placeholder"), n.getAttribute("aria-label"), n.getAttribute("src")].join(":"))
124
+ .join("|");
125
+ const t = ((document.body && document.body.innerText) || "") + "|" + extra;
126
+ let h = 5381;
127
+ for (let i = 0; i < t.length; i++) h = ((h << 5) + h + t.charCodeAt(i)) | 0;
128
+ return `${t.length}:${h}`;
129
+ })
130
+ .catch(() => null);
131
+ const own = el
132
+ ? await el
133
+ .evaluate((n) => {
134
+ if (!n.isConnected) return null;
135
+ // 색만 바뀌는 아이콘(저장·좋아요)은 속의 svg 나 감싼 버튼의 속성·클래스가 바뀐다 — 그 HTML 까지 본다.
136
+ // 누른 요소만 봤더니 북마크가 파랗게 됐는데 "그대로" 로 찍혔다(10/22 bluetags 저장).
137
+ // 계산된 색은 안 본다 — 누르기 전에는 커서가 없어서 마우스를 올린 색(hover)만으로 "바뀌었다" 가 된다
138
+ const host = n.closest("a,button,[role],label") || n.parentElement || n;
139
+ return [
140
+ n.className && String(n.className),
141
+ ...["aria-pressed", "aria-expanded", "aria-checked", "aria-selected", "aria-current", "data-state"].map((a) => n.getAttribute(a)),
142
+ n.checked,
143
+ n.value,
144
+ (n.textContent || "").slice(0, 200),
145
+ host.outerHTML.slice(0, 3000),
146
+ ].join("|");
147
+ })
148
+ .catch(() => null)
149
+ : null;
150
+ return { url: page.url(), doc, own };
151
+ }
152
+
153
+ /**
154
+ * 클릭 뒤 화면이 자리 잡기를 기다린다. 사람도 눌렀으면 뜰 때까지 본다.
155
+ * 안 기다리면 느린 화면이 "눌러도 안 열린다" 로 읽힌다 — 10/13 bluetags 에서 카드가 "안 열린다 → 조금 뒤 열렸다".
156
+ */
157
+ async function settle(page, before, net) {
158
+ // 주소가 바뀔 때까지 보되, 아무것도 안 불러오고 있으면 거기서 끝난 것이다(정말 안 눌린 것).
159
+ // Next.js 같은 사이트는 다음 화면 데이터를 다 받은 뒤에야 주소를 바꾼다. 1.5초로 끊었더니 10/13 0.1.8 에서
160
+ // "안 열린다" 가 69번 — 그중 38번은 같은 걸 다시 누르자 열렸다. networkidle 은 이미 연 문서에서는 바로 끝나 소용없다
161
+ const t0 = Date.now();
162
+ let quietSince = null;
163
+ let moved = false;
164
+ // 화면을 불러오는 중이면 30초까지 본다. 10초로 끊었더니 개발 서버에서 카드가 "안 열렸다" 가 되고,
165
+ // 다시 누르자 그새 넘어가 "없어졌다" 가 됐다(10/15 실험실 p16)
166
+ while (Date.now() - t0 < (net && net.navPending() ? 30000 : 10000)) {
167
+ // 새 탭으로 열린 같은 사이트 화면을 이 탭으로 옮기는 중이다(hub). 그 이동이 끝날 때까지 본다
168
+ if (page.__ebPendingNav) {
169
+ await Promise.race([page.__ebPendingNav, sleep(30000)]);
170
+ moved = page.url() !== before;
171
+ break;
172
+ }
173
+ if (page.url() !== before) {
174
+ moved = true;
175
+ break;
176
+ }
177
+ const busy = net ? net.busy() : 0;
178
+ if (busy) quietSince = null;
179
+ else if (quietSince === null) quietSince = Date.now();
180
+ if (Date.now() - t0 > 800 && quietSince !== null && Date.now() - quietSince > 600) break;
181
+ await sleep(100);
182
+ }
183
+ if (moved) {
184
+ await page.waitForLoadState("domcontentloaded", { timeout: 10000 }).catch(() => {});
185
+ await page.waitForLoadState("networkidle", { timeout: 4000 }).catch(() => {});
186
+ // 뒤로 가기는 주소가 먼저 바뀌고, 그다음에 새 화면 데이터를 받아 그린다(Next.js). 위 두 대기는 이미 연 문서라 바로 끝난다 —
187
+ // 주소는 "/" 인데 화면은 아직 커뮤니티였다(10/14 bluetags p15: 옛 화면의 버튼을 눌러 "없다"·"안 된다").
188
+ // 조작이 시작한 요청이 다 끝날 때까지 본다
189
+ if (net) {
190
+ const t1 = Date.now();
191
+ let quiet = null;
192
+ while (Date.now() - t1 < 8000) {
193
+ if (net.busy()) quiet = null;
194
+ else if (quiet === null) quiet = Date.now();
195
+ if (quiet !== null && Date.now() - quiet > 400) break;
196
+ await sleep(100);
197
+ }
198
+ }
199
+ }
200
+ // 30초를 다 기다렸는데도 화면을 불러오는 중이었다 — 진단으로 남긴다
201
+ return { navTimeout: !moved && !!(net && net.navPending()) };
202
+ }
203
+
204
+ /** 조작 뒤 불러오고 있는 요청 수. 웹소켓·스트림은 끝나지 않으니 뺀다 */
205
+ function watchNet(page) {
206
+ const live = new Set();
207
+ const on = (r) => {
208
+ if (!["websocket", "eventsource", "media"].includes(r.resourceType())) live.add(r);
209
+ };
210
+ const off = (r) => live.delete(r);
211
+ page.on("request", on);
212
+ page.on("requestfinished", off);
213
+ page.on("requestfailed", off);
214
+ return {
215
+ busy: () => live.size,
216
+ /** 이 탭의 화면 자체를 불러오는 중인가(문서 요청). 개발 서버는 처음 여는 화면을 만드느라 10초를 넘긴다 */
217
+ navPending: () => [...live].some((r) => {
218
+ try {
219
+ return r.isNavigationRequest() && r.frame() === page.mainFrame();
220
+ } catch {
221
+ return false;
222
+ }
223
+ }),
224
+ stop() {
225
+ page.off("request", on);
226
+ page.off("requestfinished", off);
227
+ page.off("requestfailed", off);
228
+ },
229
+ };
230
+ }
231
+
232
+ /** 링크가 사이트 밖이면 그 호스트. www 만 다른 것은 같은 사이트다 */
233
+ function offsiteHost(href, here) {
234
+ if (!href || !/^https?:\/\//i.test(href)) return null;
235
+ try {
236
+ const bare = (h) => h.replace(/^www\./, "");
237
+ const to = new URL(href);
238
+ return bare(to.host) === bare(new URL(here).host) ? null : to.host;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ /** 들어와서 처음 손대기까지. 훑는 사람은 금방, 읽는 사람은 한참 본다 */
245
+ /**
246
+ * Playwright 의 클릭 오류를 사람이 겪는 말로. "elementHandle.click: Timeout 8000ms exceeded" 를 그대로 주면
247
+ * 방문자는 화면이 멈췄다고 읽는다(10/14 bluetags p4)
248
+ */
249
+ function humanClickError(e) {
250
+ const m = String((e && e.message) || "");
251
+ if (/intercepts pointer events/.test(m)) return "something else on the screen is on top of it";
252
+ if (/not enabled|disabled/.test(m)) return "it is greyed out and does not respond";
253
+ if (/not visible|outside of the viewport/.test(m)) return "it is not visible on the screen right now";
254
+ if (/detached|not attached/.test(m)) return "no longer there";
255
+ return "nothing happened when you clicked it";
256
+ }
257
+
258
+ /** 파일 고르는 창. 사람에게는 컴퓨터의 파일 창이 뜬다 — 자동 브라우저에서는 조용히 사라져 "안 열린다" 가 된다(10/14 p24) */
259
+ function fileWindowNote(fc) {
260
+ const accept = fc.accept || "";
261
+ const kind = /image/.test(accept) ? "an image" : /video/.test(accept) ? "a video" : /pdf/.test(accept) ? "a PDF" : "a file";
262
+ return `A window opened asking you to choose ${kind} from your device. You have nothing ready to pick for this visit, so you closed that window without choosing.`;
263
+ }
264
+
265
+ function firstLookMs(attention) {
266
+ const [lo, hi] = { skims: [800, 6000], reads: [2500, 15000], studies: [5000, 25000] }[attention] || [2500, 15000];
267
+ // 짧은 쪽에 몰리고 가끔 길다
268
+ return Math.round(lo + (hi - lo) * Math.pow(Math.random(), 1.8));
269
+ }
270
+
271
+ /** 한 방문 걸음 수의 절대 상한. 판단을 누가 하든 이 이상은 안 걷는다 */
272
+ const HARD_STEP_CAP = 60;
273
+
274
+ function stepPrompt() {
275
+ return fs.readFileSync(STEP_PROMPT_FILE, "utf8");
276
+ }
277
+
278
+ /** 지금 화면을 글로. 사람 소개 없이 화면만 — MCP 에이전트는 자기가 누구인지 이미 안다 */
279
+ function renderScreen(look) {
280
+ const { view, text } = look;
281
+ return [
282
+ "## Where you are",
283
+ `- path: ${view.path}`,
284
+ `- scrolled ${view.scrollY} of ${Math.max(0, view.height - view.viewport)} px`,
285
+ "",
286
+ "## On the screen",
287
+ "```",
288
+ text,
289
+ "```",
290
+ "",
291
+ "## Things you can use (number · what it says · where)",
292
+ ...view.items.map((it) => `[${it.i}] ${it.label || "(no text)"}${it.href ? ` → ${it.href}` : ""}${it.dim ? " · looks greyed out" : ""}${it.edge ? " · cut off at the edge of the screen" : ""}${it.behind ? " · dimmed, behind the open panel" : ""} · ${it.where}`),
293
+ // 떠 있는 창 뒤가 흐리게 비치는 화면. 글로 옮기면 창과 뒤 페이지가 한 덩어리로 섞인다(10/21 실험실 q3)
294
+ ...(view.items.some((it) => it.behind) ? ["(A panel is open on top of the page; the page behind it is dimmed.)"] : []),
295
+ // 화면 안에 있는데 목록에 못 넣은 것. "아래에 더 있다" 고 하면 내려 봐도 못 찾는다
296
+ // 화면 안에서 빠진 것과 지나온 위쪽에서 빠진 것을 따로 말한다 — 한 숫자로 말하면 "화면에 너무 많다" 가 거짓이 된다
297
+ ...(view.droppedInView
298
+ ? [view.droppedInView <= (view.droppedIcons || 0)
299
+ ? `(${view.droppedInView} more small icons or repeated images on this screen are not listed.)`
300
+ : `(${view.droppedInView} more things on this screen are not listed — there is too much in view.)`]
301
+ : view.dropped
302
+ ? [`(${view.dropped} more things further up the page are not listed — scroll up to use them.)`]
303
+ : []),
304
+ // 아래에 더 있다는 것만. 무엇인지는 내려 봐야 안다
305
+ // 굴릴 데가 없는데 "아래에 더 있다" 고 하면 모순이다 — 창이 뜨며 사이트가 페이지 스크롤을 잠근 때(10/21 실험실 r2·r4)
306
+ ...(view.height - view.viewport <= 2
307
+ ? ["(Nothing on this screen scrolls right now.)"]
308
+ // 끝까지 내려왔으면 "아래에 더" 라고 하지 않는다 — 화면 밖 아래에 걸린 것이 있어도 굴려서 못 온다(10/21 실험실 t3)
309
+ : view.below && view.height - view.viewport - view.scrollY > 2
310
+ ? [view.inPanel ? "(The open panel goes on below. Scroll down to see the rest of it.)" : `(The page goes on below. Scroll down to see what is there.)`]
311
+ : view.height - view.viewport - view.scrollY > Math.max(40, view.viewport * 0.25)
312
+ ? [view.inPanel ? "(There is more of the open panel below.)" : "(There is more of the page below.)"]
313
+ // 조금 남은 것(화면 맨 아래 버튼의 나머지, 여백)을 "더 있다" 고 하면 굴려도 새것이 없었다(10/21 실험실 s2·u2·w2)
314
+ : view.height - view.viewport - view.scrollY > 40
315
+ ? [view.inPanel ? "(Nearly the bottom of the open panel — only a little more below.)" : "(Nearly the bottom — only a little more below.)"]
316
+ : [view.inPanel ? "(This is the bottom of the open panel.)" : "(This is the bottom of the page.)"]),
317
+ ].join("\n");
318
+ }
319
+
320
+ /**
321
+ * 그 사람이 누구인가. **두 길이 같은 글을 쓴다** — 우리 AI 의 걸음 판단(buildStepMessage)과
322
+ * MCP 의 `open_visit`(고객 Claude 서브에이전트가 받는 자기소개).
323
+ * 9/21 에 MCP 쪽이 이름만 받고 걸었다 — 목적도 상태도 모른 채 이름에 붙은 꼬리표로 짐작해 움직였다.
324
+ *
325
+ * @param card 카드를 다루는 쪽에도 쓰이나(설문 습관·쓰는 버릇까지). 걸음만 정할 때는 필요 없다
326
+ */
327
+ const SURVEY_HABIT = {
328
+ ignores: "you usually ignore them",
329
+ sometimes: "you sometimes answer them",
330
+ willing: "you are usually happy to answer them",
331
+ };
332
+
333
+ function renderPerson(person, { card = false } = {}) {
334
+ const lines = [
335
+ `- on ${person.device}`,
336
+ `- ${person.background}`,
337
+ `- what you came for: ${person.goal}`,
338
+ `- right now: ${person.moment ?? "(not given)"}`,
339
+ `- you take in a page by: ${person.attention ?? "reads"}`,
340
+ ];
341
+ if (card) {
342
+ // 값이 동사 하나(ignores·sometimes·willing)라 그대로 끼우면 "you usually sometimes them" 이 됐다(10/14 실험실)
343
+ lines.push(`- survey cards that pop up: ${SURVEY_HABIT[person.surveyHabit] || SURVEY_HABIT.sometimes}`);
344
+ lines.push(`- when you type, you are ${person.writing ?? "careful"}`);
345
+ }
346
+ return lines.join("\n");
347
+ }
348
+
349
+ /**
350
+ * @param visited 이번 방문에 들른 경로와 횟수 [{path, n}]. 최근 8걸음만 보여주면 앞에서 본 것을 잊고 같은 길을 돈다
351
+ * (9/21: 39걸음 동안 같은 네 쪽을 세 바퀴 돌았다). 고객 Claude(MCP)는 방문 전체를 기억하니 이 길에만 필요하다
352
+ */
353
+ function buildStepMessage(person, siteAbout, view, history, pageText, visited = []) {
354
+ const lines = [
355
+ "## Website",
356
+ siteAbout,
357
+ "",
358
+ "## You",
359
+ renderPerson(person),
360
+ "",
361
+ renderScreen({ view, text: pageText }),
362
+ "",
363
+ ];
364
+ if (visited.length) {
365
+ lines.push("## Pages you have already been to on this visit (steps you spent there)");
366
+ visited.forEach((v) => lines.push(`- ${v.path} ×${v.n}`));
367
+ lines.push("");
368
+ }
369
+ if (history.length) {
370
+ lines.push("## What you have done so far");
371
+ if (history.length > 8) lines.push(`(${history.length - 8} earlier steps not shown)`);
372
+ history.slice(-8).forEach((h, i) => lines.push(`${i + 1}. ${h}`));
373
+ lines.push("");
374
+ }
375
+ lines.push("What do you do next?");
376
+ return lines.join("\n");
377
+ }
378
+
379
+ /**
380
+ * 한 방문의 몸. 사이트가 이미 열린 page 를 받는다.
381
+ * 순서: begin() → [ look() → (카드면 answer(), 아니면 act()) ]* → act({action:"leave"}) → (떠날 때 카드면 answer())
382
+ */
383
+ class Visit {
384
+ /**
385
+ * @param person people.js PERSON_FIELDS
386
+ * @param rhythm persona.Rhythm (조작 사이 간격). 없으면 짧게 쉰다
387
+ * @param shape persona.drawShape 가 뽑은 이번 방문 모양(튕김·훑기·깊게). 인내심 표시에 쓴다
388
+ * @param offsite 사이트 밖으로 가려던 주소가 쌓이는 배열(guard.js onOffsite 가 채운다)
389
+ */
390
+ constructor(page, person, { rhythm = null, shape = null, allowAccounts = false, policy = null, offsite = [], notSent = [], external = [], log = () => {} } = {}) {
391
+ this.page = page;
392
+ this.person = person;
393
+ /** 방문 시작 — 걸음마다 몇 초째인지(t) 적는다 */
394
+ this.t0 = Date.now();
395
+ this.rhythm = rhythm;
396
+ this.shape = shape;
397
+ this.allowAccounts = allowAccounts;
398
+ /** 고객이 정한 금지 구역(browser/policy.js). 누를 것·칠 것을 가린다 */
399
+ this.policy = policy;
400
+ this.offsite = offsite;
401
+ /** 사이트에 쓰려다 방어선이 끊은 요청(hub 가 채운다) */
402
+ this.notSent = notSent;
403
+ this.external = external;
404
+ // 알린 바깥 서버. 소켓은 몇 초마다 다시 붙으려 해서, 한 번만 알린다
405
+ this.externalTold = new Set();
406
+ /** 도구가 하지 못한 것들 — 방문이 끝나면 hub 가 서버로 보낸다(diag.js). 사이트 글자는 없다 */
407
+ this.diag = [];
408
+ /** 진단 순간의 화면 { id(진단 id), image } — 우리 쪽으로만 간다 */
409
+ this.diagShots = [];
410
+ this.log = log;
411
+ // 손은 방문 내내 이어진다 — 커서가 있던 자리에서 다음 곳으로 간다
412
+ this.handState = { rhythm, mouse: null };
413
+ this.steps = [];
414
+ /** 이번 조작으로 받은 문서의 상태 코드(본 탭의 화면 이동만). 조작마다 비운다 */
415
+ this.docStatus = null;
416
+ /** 마지막으로 끊긴 본 탭 이동(주소·이유) — 빈 페이지가 된 까닭을 진단에 남긴다 */
417
+ this.lastFailedNav = null;
418
+ page.on("requestfailed", (req) => {
419
+ try {
420
+ if (req.isNavigationRequest() && req.frame() === page.mainFrame()) this.lastFailedNav = `${new URL(req.url()).pathname} ${(req.failure() && req.failure().errorText) || ""}`.trim();
421
+ } catch {}
422
+ });
423
+ page.on("response", (res) => {
424
+ try {
425
+ if (res.request().isNavigationRequest() && res.frame() === page.mainFrame()) this.docStatus = res.status();
426
+ } catch {}
427
+ });
428
+ /** 문제가 있던 순간의 화면 { step, image(JPEG) } — 방문이 끝나면 hub 가 서버로 올린다 */
429
+ this.screens = [];
430
+ this.history = [];
431
+ this.seenPaths = new Set();
432
+ this.card = null; // 처리한 카드(한 방문에 하나)
433
+ this.pending = null; // 떴는데 아직 답 안 한 카드
434
+ this.lastView = null; // act 의 번호는 마지막 look 의 화면 기준이다
435
+ this.lastAction = null;
436
+ /**
437
+ * 다음 조작을 해도 되는 때. **사람 박자는 여기서 정한다.**
438
+ * 전에는 조작 뒤에 쉬고 나서 화면을 돌려줬다 — 그 뒤 AI 가 생각하는 시간이 또 붙어 늘 8~30초 간격이 됐다
439
+ * (10/13 bluetags 105명, 첫 조작은 거의 다 3~4초). 이제 화면은 바로 돌려주고, 다음 조작 때 이 시각까지 기다린다.
440
+ * AI 가 더 오래 생각했으면 기다리지 않는다 — 간격은 둘 중 긴 쪽이다.
441
+ */
442
+ this.nextAt = 0;
443
+ this.ended = false;
444
+ this.sdk = null;
445
+ // 파일 고르는 창이 열렸나. 듣고 있으면 Playwright 가 창을 띄우지 않는다 — 고르지 않은 채 닫은 것과 같다
446
+ this.fileWindow = null;
447
+ page.on("filechooser", (fc) => {
448
+ this.fileWindow = { accept: "" };
449
+ fc.element()
450
+ .getAttribute("accept")
451
+ .then((a) => (this.fileWindow = { accept: a || "" }))
452
+ .catch(() => {});
453
+ });
454
+ /**
455
+ * 브라우저 창(alert·confirm·prompt). 듣지 않으면 Playwright 가 조용히 "취소" 한다 — bluetags 커뮤니티 나가기는
456
+ * "Are you sure you want to leave?" 가 세 번 다 취소돼 "눌러도 그대로" 로 찍히고 고객 화면에 사이트 탓으로 나갔다(10/22 p2038).
457
+ * 창이 떠 있는 동안 페이지는 멈추고 누르기도 안 끝난다 — 방문자에게 문구를 보여 주고 고르게 한다(answerDialog)
458
+ */
459
+ this.dialog = null;
460
+ this.dialogWake = null;
461
+ this.dialogWork = null;
462
+ this.dialogText = null;
463
+ this.acting = false;
464
+ this.curRecord = null;
465
+ page.on("dialog", (d) => this.onDialog(d));
466
+ }
467
+
468
+ /** 창이 떴다. 걸음 중이면 방문자에게 넘기고, 아니면(화면 보는 중·카드) 사람이 그냥 넘기듯 닫는다 — 열어 두면 페이지가 멈춘다 */
469
+ onDialog(d) {
470
+ const type = d.type();
471
+ const message = String(d.message() || "").slice(0, 500);
472
+ // 떠나며 "저장 안 한 내용이 있다" 는 사람도 대개 떠난다
473
+ if (type === "beforeunload") return void d.accept().catch(() => {});
474
+ if (!this.acting || this.dialog) {
475
+ (type === "alert" ? d.accept() : d.dismiss()).catch(() => {});
476
+ this.history.push(`A box from the browser popped up saying "${message}" and went away.`);
477
+ return;
478
+ }
479
+ this.dialog = { d, type, message, defaultValue: d.defaultValue() || "", path: this.pathNow() };
480
+ this.dialogText = null;
481
+ if (this.dialogWake) this.dialogWake();
482
+ }
483
+
484
+ pathNow() {
485
+ try {
486
+ const u = new URL(this.page.url());
487
+ return u.pathname + u.search;
488
+ } catch {
489
+ return "";
490
+ }
491
+ }
492
+
493
+ /** 창 안에서 고를 것 — 화면의 번호 목록처럼 준다 */
494
+ dialogItems() {
495
+ const d = this.dialog;
496
+ const ok = { key: "ok", label: "OK", where: "in the box" };
497
+ const cancel = { key: "cancel", label: "Cancel", where: "in the box" };
498
+ const items = d.type === "alert" ? [ok] : d.type === "prompt" ? [{ key: "field", label: "(text box)", where: "in the box" }, ok, cancel] : [ok, cancel];
499
+ return items.map((it, i) => ({ ...it, i }));
500
+ }
501
+
502
+ /** 창이 떠 있는 화면. 페이지를 읽을 수 없다(멈춰 있다) — 창만 보인다 */
503
+ dialogLook() {
504
+ const d = this.dialog;
505
+ const what = d.type === "alert" ? "A message box from the browser popped up over the page:" : d.type === "prompt" ? "A box from the browser popped up over the page, asking you to type something:" : "A box from the browser popped up over the page, asking:";
506
+ const lines = [what, "", d.message || "(no text)", ""];
507
+ if (d.type === "prompt") lines.push(this.dialogText !== null ? `You have typed: ${this.dialogText}` : d.defaultValue ? `It already says: ${d.defaultValue}` : "The text box is empty.", "");
508
+ lines.push(d.type === "alert" ? "The page behind it is frozen until you close the box." : "The page behind it is frozen until you answer.");
509
+ const view = { path: d.path, scrollY: 0, height: 0, viewport: 0, items: this.dialogItems().map(({ i, label, where }) => ({ i, label, where })), dialog: true };
510
+ return { view, text: lines.join("\n") };
511
+ }
512
+
513
+ /**
514
+ * 창에 답한다 — OK·Cancel 을 누르거나 prompt 칸에 친다. 원래 걸음(_act)은 창이 닫힌 뒤 마저 끝난다.
515
+ * 고른 것은 그 걸음 기록에 붙는다(record.dialogs). 다른 조작은 창이 막는다 — 떠나기는 Cancel 하고 떠난다
516
+ */
517
+ async answerDialog(decision) {
518
+ await this.waitTurn();
519
+ const dl = this.dialog;
520
+ const item = this.dialogItems()[decision.index];
521
+ let choice = null;
522
+ if (decision.action === "leave") choice = "cancel";
523
+ else if (decision.action === "type" && item && item.key === "field") {
524
+ this.dialogText = String(decision.text || "").slice(0, 200);
525
+ await sleep(800 + Math.random() * 1500);
526
+ this.nextAt = Date.now() + 600;
527
+ return { dialog: true };
528
+ } else if (decision.action === "click" && item && item.key !== "field") choice = item.key;
529
+ else if (decision.action === "click" && item) return { skipped: "you can only type into that text box" };
530
+ else return { skipped: decision.action === "click" || decision.action === "type" ? "that number is not on the screen" : "the box from the browser is still open on top of the page — answer it first" };
531
+
532
+ this.dialog = null;
533
+ const answer = choice === "ok" ? (dl.type === "prompt" ? "typed and pressed OK" : "OK") : "Cancel";
534
+ await (choice === "ok" ? dl.d.accept(dl.type === "prompt" ? this.dialogText ?? dl.defaultValue : undefined) : dl.d.dismiss()).catch(() => {});
535
+ if (this.curRecord) (this.curRecord.dialogs = this.curRecord.dialogs || []).push({ type: dl.type, message: dl.message, answer, why: decision.why, t: Date.now() - this.t0 });
536
+ this.log(` browser box "${dl.message.slice(0, 40)}" → ${answer}`);
537
+ // 원래 걸음이 마저 끝나기를 본다. 창이 또 뜨면 다시 방문자에게
538
+ const r = await this.untilDialog(this.dialogWork);
539
+ if (r && r.dialog) return r;
540
+ this.dialogWork = null;
541
+ if (decision.action === "leave") return this.act(decision);
542
+ return r || { step: this.steps.length };
543
+ }
544
+
545
+ /** 걸음을 하다가 창이 뜨면 거기서 돌려준다({ dialog: true }) — 걸음은 창이 닫힌 뒤 마저 간다 */
546
+ async untilDialog(work) {
547
+ let wake;
548
+ const seen = new Promise((res) => (wake = res));
549
+ this.dialogWake = wake;
550
+ this.acting = true;
551
+ const first = await Promise.race([work.then((r) => ({ r })), seen.then(() => null)]);
552
+ if (first) {
553
+ this.acting = false;
554
+ this.dialogWake = null;
555
+ return first.r;
556
+ }
557
+ this.dialogWork = work;
558
+ // 창 글을 읽는 시간
559
+ this.nextAt = Date.now() + 1200 + Math.min(4000, this.dialog.message.length * 40);
560
+ return { dialog: true };
561
+ }
562
+
563
+ /**
564
+ * **첫 걸음을 떼기 전에 SDK 가 붙기를 기다린다.**
565
+ * 광고·트래커가 많은 사이트는 SDK 가 늦게 붙는데, 그 사이의 조작은 기록에 안 남는다.
566
+ * 방문은 성공한 것처럼 끝나고 기록만 앞부분이 잘려서 **조용히 틀린 데이터**가 된다.
567
+ * (2026-09-21 사람인: goto 60초 타임아웃 뒤에 붙었다. 블루태그스는 로컬이라 빨라서 안 드러났다.)
568
+ */
569
+ async begin() {
570
+ this.sdk = await waitForSdk(this.page);
571
+ if (!this.sdk.ready) {
572
+ // 기다려도 안 붙었으면 이 방문의 기록은 못 믿는다. 조용히 넘기지 않는다
573
+ this.log(`⚠ The SDK did not start within ${Math.round(this.sdk.ms / 1000)}s — this visit's behaviour record will be empty or partial`);
574
+ } else if (this.sdk.ms > 3000) {
575
+ this.log(`SDK started after ${(this.sdk.ms / 1000).toFixed(1)}s`);
576
+ }
577
+ this.nextAt = Date.now() + firstLookMs(this.person.attention);
578
+ return this.sdk;
579
+ }
580
+
581
+ /** 이 화면을 보고 다음에 손대기까지. 새로 보이는 글이 많을수록 길다(persona.js Rhythm.gap) */
582
+ gapFor(view, text) {
583
+ if (!this.rhythm) return 1200;
584
+ const g = this.rhythm.gap({ sig: view.path, chars: text.length, media: 1, top: view.scrollY, vh: view.viewport, cmd: this.lastAction || "look" });
585
+ return Math.min(45000, Math.max(250, g.ms));
586
+ }
587
+
588
+ /** 정해 둔 때까지 기다린다. AI 가 그보다 오래 생각했으면 바로 간다 */
589
+ async waitTurn() {
590
+ const ms = this.nextAt - Date.now();
591
+ if (ms > 0) await sleep(ms);
592
+ }
593
+
594
+ /** 이번 방문에 걸었던 마음만큼 봤나. 여기서부터는 나갈 결심이 선다 */
595
+ get patienceSpent() {
596
+ return !!(this.shape && this.seenPaths.size > this.shape.screens && !this.card);
597
+ }
598
+
599
+ /**
600
+ * 지금 무엇이 보이나. 카드가 떠 있으면 카드부터 돌려준다 — 이게 이 방문의 산출물이다.
601
+ * **보이는 만큼만.** 글도 조작도 화면 안의 것이다 — 아래는 내려 봐야 안다.
602
+ * @returns { card } 또는 { view, text }
603
+ */
604
+ async look() {
605
+ if (this.ended) return { ended: true };
606
+ // 브라우저 창이 떠 있다. 페이지는 멈춰 있어 읽으면 걸린다 — 창만 보여 준다
607
+ if (this.dialog) return this.dialogLook();
608
+ if (!this.card && !this.pending) {
609
+ const shown = await waitCard(this.page, 500);
610
+ if (shown) this.pending = shown;
611
+ }
612
+ // 카드 판단에는 페이지 전체를 준다 — 이 방문에서 본 것을 다 기억하는 사람이다
613
+ if (this.pending) return { card: this.pending, text: await screenText(this.page) };
614
+ const view = await listControls(this.page);
615
+ // 위쪽만 빠진 것은 넘침이 아니다 — 굴려 올리면 다시 나온다
616
+ if (view.droppedInView > (view.droppedIcons || 0)) this.diag.push({ kind: "controls_overflow", detail: `dropped ${view.droppedInView - (view.droppedIcons || 0)}` });
617
+ const text = await visibleText(this.page, 2500);
618
+ this.seenPaths.add(view.path);
619
+ this.lastView = view;
620
+ this.nextAt = Math.max(this.nextAt, Date.now() + this.gapFor(view, text));
621
+ return { view, text };
622
+ }
623
+
624
+ /**
625
+ * 카드에 답한다(또는 닫는다). decision 은 answer.js DECISION_SCHEMA 모양.
626
+ * 이유를 SDK 에 먼저 남긴 뒤 누른다 — SDK 가 보낼 때 읽는다.
627
+ */
628
+ async answer(decision) {
629
+ const shown = this.pending;
630
+ if (!shown) throw new Error("No card is showing");
631
+ const act = answer.toAction(decision, shown);
632
+ // 카드를 읽고 정하는 시간. 닫는 건 금방, 쓰는 건 치는 만큼
633
+ await sleep(act.kind === "close" ? 600 + Math.random() * 1800 : 1500 + Math.random() * 3000);
634
+ await noteReason(this.page, decision.bothered ? decision.why : decision.botheredWhy);
635
+ await answerCard(this.page, act).catch(() => {});
636
+ this.card = {
637
+ question: shown.question,
638
+ choices: shown.choices,
639
+ bothered: decision.bothered,
640
+ botheredWhy: decision.botheredWhy,
641
+ why: decision.why,
642
+ result: act.kind === "close" ? "close" : act.kind,
643
+ answer: act.kind === "choose" ? shown.choices[act.index] : act.kind === "other" ? act.text : null,
644
+ t: Date.now() - this.t0,
645
+ path: (() => {
646
+ try {
647
+ return new URL(this.page.url()).pathname;
648
+ } catch {
649
+ return null;
650
+ }
651
+ })(),
652
+ };
653
+ this.pending = null;
654
+ this.history.push(`A survey card appeared and you ${this.card.result === "close" ? "closed it" : "answered it"}.`);
655
+ this.log(`Card: ${this.card.result}${this.card.answer ? ` — ${this.card.answer}` : ""}`);
656
+ this.nextAt = Date.now() + 800 + Math.random() * 2500;
657
+ return this.card;
658
+ }
659
+
660
+ /**
661
+ * 한 걸음. decision 은 STEP_SCHEMA 모양({ why, action, index, text, done, times }).
662
+ * 번호는 마지막 look() 화면 기준이다. 떠나면 끝나고, 떠나는 순간 뜬 카드가 있으면 pending 에 남는다.
663
+ * 보고 있던 사이에 카드가 떴으면 하려던 걸 하지 않고 { interrupted: true } 를 돌려준다 — 카드부터 본다.
664
+ * 하는 도중 브라우저 창이 뜨면 { dialog: true } — 다음 look() 이 창을 보여 주고, 다음 act() 가 창에 답한다
665
+ */
666
+ async act(decision) {
667
+ if (this.dialog) return this.answerDialog(decision);
668
+ return this.untilDialog(this._act(decision));
669
+ }
670
+
671
+ async _act(decision) {
672
+ if (this.ended) throw new Error("This visit has already ended");
673
+ if (this.pending) throw new Error("A card is showing — answer or close it first");
674
+ await this.waitTurn();
675
+ if (!this.card) {
676
+ const c = await readCard(this.page);
677
+ if (c && c.visible) {
678
+ this.pending = c;
679
+ return { interrupted: true };
680
+ }
681
+ }
682
+ if (this.steps.length >= HARD_STEP_CAP) decision = { ...decision, action: "leave", why: `(${HARD_STEP_CAP}-step limit) ${decision.why}` };
683
+ const page = this.page;
684
+ const view = this.lastView || (await listControls(page));
685
+ const item = view.items[decision.index];
686
+ const record = { step: this.steps.length + 1, t: Date.now() - this.t0, path: view.path, action: decision.action, why: decision.why, label: item?.label ?? null };
687
+ this.curRecord = record;
688
+ await this.checkLastJudgement(decision);
689
+ const diagFrom = this.diag.length;
690
+ const times = Math.max(1, Math.min(MAX_TIMES[decision.action] || 1, Math.round(Number(decision.times) || 1)));
691
+ if (times > 1) record.times = times;
692
+ // 한 번에 할 수 있는 수보다 많이 달라고 했다. 말없이 줄이면 "사이트가 클릭을 삼킨다" 로 읽었다(10/20 실험실 p7: 15·20·30번이 다 4번)
693
+ if (Math.round(Number(decision.times) || 1) > times) record.cappedTimes = times;
694
+ const before = page.url();
695
+ const offsiteBefore = this.offsite.length;
696
+ const notSentBefore = this.notSent.length;
697
+ const externalBefore = this.external.length;
698
+ this.fileWindow = null;
699
+ this.lastAction = decision.action;
700
+ this.docStatus = null;
701
+
702
+ if (decision.action === "leave") {
703
+ record.done = !!decision.done;
704
+ if (!record.done) await this.shoot(record, "left_unhappy");
705
+ this.steps.push(record);
706
+ this.log(`${record.step}. left — ${decision.why}`);
707
+ // 떠나는 모양도 사람처럼. 데스크톱은 커서가 창 위로 빠져나간다
708
+ if (this.person.device !== "mobile") {
709
+ await page.mouse.move(600, 120, { steps: 4 }).catch(() => {});
710
+ await page.mouse.move(600, 2, { steps: 6 }).catch(() => {});
711
+ await page.mouse.move(600, -8, { steps: 2 }).catch(() => {});
712
+ }
713
+ this.ended = true;
714
+ // 떠나려는 순간에 뜨는 카드가 있다. 모바일은 SDK 가 멈춤으로 대신 보므로 더 기다린다
715
+ if (!this.card) {
716
+ const parting = await waitCard(page, this.person.device === "mobile" ? 16000 : 6000);
717
+ if (parting) this.pending = parting;
718
+ }
719
+ return record;
720
+ }
721
+
722
+ const net = watchNet(page);
723
+ let diagEl = null; // 진단에 생김새를 남길 요소
724
+ let probe = null; // 누르기 전 지문 — 눌렀는데 그대로였나
725
+ if (decision.action === "read") {
726
+ // 가만히 읽는 시간. 사람은 계속 누르지 않는다 — 이게 없으면 "N초 머묾" 같은 조건이 영영 안 걸린다.
727
+ // 기다리는 건 다음 조작 때(waitTurn). 그 사이 카드가 뜨면 다음 조작 전에 본다
728
+ const base = { skims: 6000, reads: 14000, studies: 24000 }[this.person.attention ?? "reads"] ?? 14000;
729
+ record.readMs = Math.round(base * (0.6 + Math.random() * 0.8));
730
+ this.nextAt = Date.now() + record.readMs;
731
+ } else if (decision.action === "scroll" || decision.action === "scroll_up") {
732
+ // 훑으며 연달아 굴린다. 사이는 짧다 — 멈춰서 읽을 거면 따로 read 한다
733
+ record.scrolled = 0;
734
+ for (let i = 0; i < times; i++) {
735
+ if (i) await sleep(300 + Math.random() * 1500);
736
+ record.scrolled += await hand.scrollLikePerson(page, this.handState, decision.action === "scroll_up" ? -1 : 1);
737
+ }
738
+ await sleep(250);
739
+ } else if (decision.action === "back") {
740
+ await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
741
+ if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "back" });
742
+ } else if (decision.action === "type") {
743
+ // 개인정보가 들어갈 칸(이메일·전화·카드 등)에는 치지 않는다. 고객 정책이 더 막거나 열 수 있다
744
+ const fieldOff = this.policy && this.policy.fieldBlocked(item?.label || "");
745
+ const el = fieldOff ? null : item ? await handleAt(page, decision.index) : null;
746
+ diagEl = el;
747
+ // 방문자에게는 사람의 선택으로 알린다. "field blocked by policy" 를 그대로 주면 방문자가 "칸이 입력을 안 받는다" 고
748
+ // 적어 사이트 결함처럼 읽혔다(10/14 실험실 p26, 비밀번호 찾기 이메일 칸)
749
+ if (fieldOff) record.personalField = item?.label ?? "";
750
+ else if (!el) record.skipped = "no longer there";
751
+ else {
752
+ // 칸을 먼저 누르고 친다. 칸에 남는 글자는 언제나 원래 치려던 것이다(오타는 스스로 고친다)
753
+ await hand.clickLikePerson(page, this.handState, el).catch((e) => (record.skipped = e.human ? `you could not get into that field: ${e.human}` : "you could not get into that field"));
754
+ // 칸에 전에 친 글이 남아 있으면 지우고 친다 — 사람은 새 검색어를 옛 것 뒤에 잇지 않는다.
755
+ // 안 지우면 "PolygonPolygon" 이 되고 그게 사이트 문제로 보고된다(10/13 bluetags 3명)
756
+ const multiline = await el.evaluate((n) => n.tagName === "TEXTAREA" || n.isContentEditable).catch(() => false);
757
+ // 한 줄 칸(검색)은 짧게, 여러 줄 칸(댓글·답글·채팅)은 문장 몇 개까지 — 80 자로 자르면 답글이 낱말 중간에서 끊겨 올라갔다(10/21 실험실 m1)
758
+ const typed = String(decision.text || "").slice(0, multiline ? 280 : 80);
759
+ const value = await el.evaluate((n) => String(n.value ?? n.textContent ?? "")).catch(() => "");
760
+ /**
761
+ * Enter 는 사람이 누를 때만. 여러 줄 칸(댓글·답글)에서 Enter 는 줄바꿈이고, 사람은 Post 를 누른다.
762
+ * 치는 대로 결과가 걸러지는 칸에서는 사람은 Enter 없이 결과를 본다 — 그 칸이 <form> 안이면 Enter 가 폼을 보내
763
+ * 페이지가 다시 열리고 검색이 사라졌다(10/15 실험실 x40, bluetags 커뮤니티 검색).
764
+ * 그때는 Enter 를 안 눌렀다고 알리고, 같은 글을 또 치면 Enter 만 누른다(추천 목록이 뜨는 검색칸에서 결과 화면으로 가려는 사람)
765
+ */
766
+ if (!multiline && typed && value === typed && this.heldEnter === typed) {
767
+ await page.keyboard.press("Enter").catch(() => {});
768
+ this.heldEnter = null;
769
+ } else {
770
+ if (value.length) {
771
+ // 칸에 전에 친 글이 남아 있으면 지우고 친다 — 사람은 새 검색어를 옛 것 뒤에 잇지 않는다.
772
+ // 안 지우면 "PolygonPolygon" 이 되고 그게 사이트 문제로 보고된다(10/13 bluetags 3명)
773
+ await page.keyboard.press("ControlOrMeta+A").catch(() => {});
774
+ await sleep(120 + Math.random() * 300);
775
+ await page.keyboard.press("Backspace").catch(() => {});
776
+ await sleep(150 + Math.random() * 350);
777
+ }
778
+ // 치기 전 화면. 치는 대로 걸러 주는 칸이면 친 뒤 화면이 이미 바뀌어 있다
779
+ const shownBefore = multiline ? "" : await visibleText(page, 2500);
780
+ // 채팅칸인지는 치기 **전** 높이로 본다 — 글 길이에 따라 커지는 칸은 긴 질문을 치면 여러 줄이 되어 채팅칸으로 안 보였다(10/21 bluetags 82fd)
781
+ const oneLine = multiline && (await el.evaluate((n) => n.getBoundingClientRect().height <= 64).catch(() => false));
782
+ record.typos = await hand.typeLikePerson(page, this.handState, typed).catch(() => 0);
783
+ let changed = false;
784
+ if (!multiline) {
785
+ await sleep(500 + Math.random() * 400);
786
+ // 친 글과 이어진 변화만 친다 — 새로 나타난 줄에 친 말이 들어 있어야 추천·걸러진 목록이다.
787
+ // 그냥 달라진 걸로 보면 저절로 넘어가는 배너가 "치는 대로 바뀌었다" 가 되어 Enter 를 안 눌렀다(10/21 실험실 i3)
788
+ // 걸러지는 목록(10/15 x40 커뮤니티 검색)은 새 줄 없이 줄이 빠지고 남은 것에 친 말이 있다
789
+ const word = typed.trim().toLowerCase();
790
+ const beforeLines = shownBefore.split("\n");
791
+ const afterText = await visibleText(page, 2500);
792
+ const afterLines = afterText.split("\n");
793
+ const before = new Set(beforeLines);
794
+ const after = new Set(afterLines);
795
+ const added = afterLines.filter((l) => !before.has(l));
796
+ const removed = beforeLines.filter((l) => !after.has(l));
797
+ const suggested = added.some((l) => l.toLowerCase().includes(word));
798
+ const filtered = removed.length >= 3 && added.length <= 1 && afterText.toLowerCase().includes(word);
799
+ changed = !!word && (suggested || filtered);
800
+ }
801
+ if (!multiline && !changed) await page.keyboard.press("Enter").catch(() => {});
802
+ /**
803
+ * 한 줄짜리 여러 줄 칸 = 채팅 입력칸. 사람은 메신저처럼 Enter 로 보낸다 — 안 누르면 이름 없는 보내기 아이콘을
804
+ * 못 찾아 메시지를 못 보냈다(10/21 bluetags 2b72, 커뮤니티 채팅). Enter 가 줄만 바꿨으면 Enter 로 안 보내는 칸이니 지운다
805
+ */
806
+ if (oneLine && typed) {
807
+ await sleep(300 + Math.random() * 500);
808
+ await page.keyboard.press("Enter").catch(() => {});
809
+ await sleep(250);
810
+ const after = await el.evaluate((n) => String(n.value ?? n.innerText ?? "")).catch(() => "");
811
+ if (/\n\s*$/.test(after) && after.trim() === typed.trim()) await page.keyboard.press("Backspace").catch(() => {});
812
+ // 칸이 비었으면 Enter 로 보낸 것 — 방문자에게 말한다. 안 하면 "보내지도 않았는데 올라갔다" 가 됐다(10/21 실험실 g2)
813
+ else if (!after.trim()) record.sentWithEnter = true;
814
+ }
815
+ this.heldEnter = !multiline && changed ? typed : null;
816
+ if (this.heldEnter) record.heldEnter = true;
817
+ }
818
+ if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "type" });
819
+ diagEl = el;
820
+ }
821
+ } else {
822
+ const blocked = item ? clickBlocked(item, { allowAccounts: this.allowAccounts, policy: this.policy }) : "that number is not on the screen";
823
+ if (blocked) record.skipped = blocked;
824
+ else {
825
+ const el = await handleAt(page, decision.index);
826
+ diagEl = el;
827
+ // 보는 사이 사이트가 저절로 다른 화면으로 보냈다. "없어졌다" 만 주면 방문자가 영문을 몰랐다(10/22 bluetags 홈 → /signup, p2037)
828
+ const movedTo = !el && this.pathNow().split("?")[0] !== String(view.path || "").split("?")[0] ? this.pathNow() : null;
829
+ if (!el) record.skipped = movedTo ? `before you could click it, the site moved you to another page by itself (${movedTo})` : "no longer there";
830
+ else {
831
+ // 같은 사이트를 새 탭으로 여는 링크는 이 탭에서 열게 한다. 사람은 열린 탭에서 이어 보고, 방문은 한 탭이다.
832
+ // 새 탭을 잡아 옮기는 건(hub) 브라우저가 새 탭을 늦게, 주소 없이 알려 줘서 놓쳤다 —
833
+ // 방문자는 "안 열린다" 를 보고 다시 누르면 "없어졌다" 가 됐다(10/14 실험실 p3, 첫 화면 카드)
834
+ probe = { el, before: await clickPrint(page, el) };
835
+ await el
836
+ .evaluate((n) => {
837
+ const a = n.closest("a[target]");
838
+ if (a && a.target !== "_self" && a.href && new URL(a.href, location.href).origin === location.origin) a.target = "_self";
839
+ })
840
+ .catch(() => {});
841
+ // 사람 손이라 빗나갈 수 있다. 빈 곳을 누르면 아무 일도 안 일어난다 — 그게 헛클릭이다
842
+ let ours = false;
843
+ const clicked = await hand
844
+ .clickLikePerson(page, this.handState, el)
845
+ .catch((e) => ((record.skipped = e.human || humanClickError(e)), (ours = /eb-insight|eb-plugin/.test(String(e.message))), { missed: false }));
846
+ // 사이트의 무언가가 덮어 못 눌렀다. 우리 카드·위젯이 덮은 것은 사이트 탓이 아니다
847
+ if (record.skipped && /on top of it/.test(record.skipped) && !/survey card/.test(record.skipped) && !ours)
848
+ await this.shoot(record, "covered", await boxOf(page, el));
849
+ if (clicked.missed) {
850
+ record.missed = true;
851
+ this.history.push("Your click landed just outside it and nothing happened.");
852
+ }
853
+ // 잘려 있던 것을 줄째 옆으로 밀어 가져와 눌렀다
854
+ if (clicked.swiped) record.swiped = true;
855
+ // 떠 있던 창 바깥(막)을 눌러 창이 닫혔다. 버튼에는 안 닿았다
856
+ if (clicked.closedOverlay && page.url() === before) {
857
+ record.closedOverlay = true;
858
+ this.history.push("Your click closed what was open on top of the page; it did not reach the thing you clicked.");
859
+ }
860
+ // 안 눌린다 싶어 거듭 누른다. 화면이 바뀌면 멈춘다
861
+ for (let i = 1; i < times && !record.skipped && this.handState.mouse; i++) {
862
+ await sleep(150 + Math.random() * 450);
863
+ // 창이 떴었다 — 화면이 반응한 것이다. 또 누르면 같은 창이 또 뜬다
864
+ if (page.url() !== before || this.offsite.length > offsiteBefore || record.dialogs) break;
865
+ await page.mouse.click(this.handState.mouse.x, this.handState.mouse.y).catch(() => {});
866
+ }
867
+ if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "click", shape: await diag.shapeOf(el) });
868
+ }
869
+ }
870
+ }
871
+
872
+ net.stop();
873
+
874
+ // 조작 뒤 탭이 빈 페이지(about:blank 등)가 됐다 — 앱을 여는 링크(kakaolink:, intent:, tel:)를 브라우저가 못 열 때 그렇다.
875
+ // 뒤로 가기로도 안 돌아와 방문자가 빈 화면에 갇혔다(10/15 실험실 p28, 모바일 공유 창). 원래 화면으로 돌려놓는다
876
+ if (/^https?:/i.test(before) && !/^https?:/i.test(page.url())) {
877
+ const blankAt = page.url().slice(0, 40);
878
+ await page.goto(before, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
879
+ await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
880
+ // 뒤로 가기가 빈 페이지로 간 것은 앱이 아니라 방문의 시작보다 앞으로 간 것이다(바로 들어온 첫 페이지).
881
+ // "다른 앱을 열려 했다" 고 알리면 방문자가 사이트 탓으로 적었다(10/22 실험실 q4)
882
+ // 누르지도 않았는데 빈 페이지가 됐다 — 앱 링크가 아니다. "앱을 열려 했다" 를 "없어졌다" 와 같이 들으면 모순이다(10/23 실험실 O2·P1).
883
+ // 원인을 아직 못 찾았다 — 그때 주소와 끊긴 이동을 남긴다
884
+ // 서버는 설명에 영숫자·_:.- 와 빈칸만 받는다 — 주소의 / 가 있으면 통째로 버린다
885
+ const detail = `${blankAt}${this.lastFailedNav ? ` after ${this.lastFailedNav}` : ""}`.replace(/[^a-z0-9_:.\- ]/gi, "_").slice(0, 200);
886
+ if (decision.action === "back") {
887
+ record.backOut = true;
888
+ } else if (record.skipped) {
889
+ this.diag.push({ kind: "blank_unprompted", action: decision.action, shape: null, detail });
890
+ } else {
891
+ record.openedApp = true;
892
+ this.diag.push({ kind: "left_to_blank", action: decision.action, shape: null, detail });
893
+ }
894
+ }
895
+
896
+ // 사이트 밖으로 가는 링크였다. 끊었으니 방문자는 그대로 이 화면에 있다.
897
+ // 새 탭으로 여는 링크(target=_blank)는 방어선을 안 지나고 열린다 — 누른 링크 주소로도 본다(탭은 hub 가 닫는다)
898
+ const hrefOff = decision.action === "click" && !record.skipped ? offsiteHost(item?.href, before) : null;
899
+ if (this.offsite.length > offsiteBefore) {
900
+ try {
901
+ record.offsite = new URL(this.offsite[this.offsite.length - 1]).host;
902
+ } catch {
903
+ record.offsite = "another site";
904
+ }
905
+ } else if (hrefOff) record.offsite = hrefOff;
906
+
907
+ // 쓰려다 끊겼다. 방문자가 이걸 사이트가 안 된다로 읽지 않게 알린다
908
+ // 다른 화면으로 넘어갔으면 그 사이 끊긴 것은 화면을 열며 저절로 나간 것이다(읽음 표시·조회수) — 방문자가 보낸 게 아니다.
909
+ // 그걸 "저장·게시를 안 보냈다" 로 알리면 카드를 열었을 뿐인데 무엇을 보내려 한 줄 안다(10/14 실험실 p6, POST /api/bluecards/user-read).
910
+ // 기록에는 남긴다
911
+ if (this.notSent.length > notSentBefore) {
912
+ if (page.url() !== before) record.notSentQuiet = this.notSent.slice(notSentBefore);
913
+ else record.notSent = this.notSent.slice(notSentBefore);
914
+ }
915
+ const ext = [...new Set(this.external.slice(externalBefore))].filter((h) => !this.externalTold.has(h));
916
+ if (ext.length) {
917
+ ext.forEach((h) => this.externalTold.add(h));
918
+ record.external = ext;
919
+ }
920
+ if (this.fileWindow) record.fileWindow = fileWindowNote(this.fileWindow);
921
+ await this.noteDiag(record, decision, diagEl);
922
+
923
+ // 화면이 깨졌다 — 오류로 온 문서, 오류 문구뿐인 화면. 바깥 사이트·앱·빈 페이지로 간 것은 뺀다
924
+ if (
925
+ !record.offsite &&
926
+ !record.openedApp &&
927
+ !record.backOut &&
928
+ ((this.docStatus && this.docStatus >= 400) || (page.url() !== before && (await errorScreen(page))))
929
+ )
930
+ await this.shoot(record, "error");
931
+
932
+ // 눌렀는데 그대로. 우리 쪽 사정(빗나감·막은 요청·바깥 사이트·파일 창)으로 그대로인 것은 빼고, 같은 것은 한 번만.
933
+ // 브라우저 창이 떴으면 반응한 것이다 — Cancel 을 골라 그대로인 것은 사이트 탓이 아니다
934
+ if (
935
+ probe &&
936
+ !record.skipped &&
937
+ !record.dialogs &&
938
+ !record.missed &&
939
+ !record.closedOverlay &&
940
+ !record.offsite &&
941
+ !record.notSent &&
942
+ !record.notSentQuiet &&
943
+ !record.external &&
944
+ !record.fileWindow &&
945
+ !record.openedApp &&
946
+ !record.backOut &&
947
+ !this.steps.some((s) => s.screen && s.screen.kind === "no_change" && s.label === record.label && s.path === record.path)
948
+ ) {
949
+ const after = await clickPrint(page, probe.el);
950
+ if (after.url === probe.before.url && after.doc && after.doc === probe.before.doc && after.own !== null && after.own === probe.before.own)
951
+ await this.shoot(record, "no_change", await boxOf(page, probe.el));
952
+ }
953
+
954
+ // 누른 링크와 다른 곳으로 왔다 — 움직이는 줄에서 다른 카드를 눌렀을 수 있다(10/22 실험실 k5: OKX 를 눌렀는데 Uniswap).
955
+ // 사이트가 돌려보낸 것(로그인 등)일 수도 있다 — 우리가 보고 가린다
956
+ if (decision.action === "click" && !record.skipped && !record.offsite && item?.href && page.url() !== before) {
957
+ const want = sameDocPath(item.href, before);
958
+ const got = sameDocPath(page.url(), before);
959
+ if (want && got && want !== got) this.diag.push({ kind: "landed_elsewhere", action: "click", shape: await diag.shapeOf(probe && probe.el) });
960
+ }
961
+ await this.diagContext(record, diagFrom, probe ? probe.el : diagEl);
962
+
963
+ this.steps.push(record);
964
+ this.history.push(
965
+ `${decision.action}${times > 1 ? ` ×${times}` : ""}${item ? ` "${item.label}"` : ""} — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}${record.cappedTimes ? " (that is the most in one go)" : ""}${record.offsite ? ` (it leads to another website, ${record.offsite})` : ""}${record.notSent ? " (it would have sent something to the site; it was not sent)" : ""}${record.fileWindow ? " (a window to choose a file opened; you closed it without choosing)" : ""}${record.personalField !== undefined ? " (you chose not to enter personal details there)" : ""}${record.openedApp ? " (it tried to open another app; nothing opened)" : ""}${record.backOut ? " (going back from here would have taken you off the site; you stayed)" : ""}${(record.dialogs || []).map((d) => ` (a box from the browser said "${d.message.slice(0, 120)}"; you pressed ${d.answer === "Cancel" ? "Cancel" : "OK"})`).join("")}`,
966
+ );
967
+ this.log(`${record.step}. ${decision.action}${item ? ` "${String(item.label).slice(0, 30)}"` : ""}${record.skipped ? ` · ${record.skipped}` : ""} — ${decision.why}`);
968
+ this.lastView = null;
969
+ return record;
970
+ }
971
+
972
+ /**
973
+ * 이 걸음에서 도구가 하지 못한 것을 진단에 남긴다. 글자는 넣지 않는다 — 종류와 요소의 생김새만
974
+ * 같은 것을 같은 자리에서 바로 또 눌렀다(화면이 안 바뀌었다)도 남긴다 — 사이트 탓일 수도, 우리가 못 본 것일 수도 있다
975
+ */
976
+ async noteDiag(record, decision, el) {
977
+ const kind = diag.kindOf(record.skipped);
978
+ const action = decision.action;
979
+ if (kind) this.diag.push({ kind, action, shape: await diag.shapeOf(el) });
980
+ if (record.swiped) this.diag.push({ kind: "swiped", action, shape: await diag.shapeOf(el) });
981
+ if (record.closedOverlay) this.diag.push({ kind: "popup_backdrop", action });
982
+ if (record.missed) this.diag.push({ kind: "missed", action });
983
+ const prev = this.steps[this.steps.length - 1];
984
+ if (action === "click" && !record.skipped && prev && prev.action === "click" && prev.label === record.label && prev.path === record.path && !prev.skipped)
985
+ this.diag.push({ kind: "reclick_same", action, shape: await diag.shapeOf(el) });
986
+ }
987
+
988
+ /**
989
+ * 바로 앞 걸음에 대한 도구 판단이 방문자의 다음 말과 엇갈리나. 엇갈리면 도구가 잘못 본 것이다 — 진단으로 남긴다.
990
+ * - "눌렀는데 그대로" 로 찍었는데 방문자는 "저장됐다·파랗게 됐다·열렸다" 고 한다 → 그 사진은 고객에게 안 보낸다
991
+ * (10/22 bluetags 저장: 북마크가 파랗게 됐는데 그대로로 찍혔다)
992
+ * - 화면이 바뀌었다고 봤는데 방문자는 "아무 일 없다·안 열린다" 고 한다
993
+ * 방문자의 말은 사이트 글자가 아니라 우리 AI 가 쓴 말이다
994
+ */
995
+ async checkLastJudgement(decision) {
996
+ const last = this.steps[this.steps.length - 1];
997
+ // 떠나며 하는 말은 방문 전체를 돌아본다 — 앞의 다른 것("그 링크가 안 열렸다")을 바로 앞 걸음 일로 읽었다(10/22 p2018)
998
+ if (!last || last.action !== "click" || last.skipped || !decision.why || decision.action === "leave") return;
999
+ const from = this.diag.length;
1000
+ if (last.screen && last.screen.kind === "no_change" && SAYS_CHANGED.test(decision.why)) {
1001
+ this.diag.push({ kind: "no_change_doubt", action: "click", detail: `step ${last.step}` });
1002
+ this.screens = this.screens.filter((x) => x.step !== last.step);
1003
+ delete last.screen;
1004
+ } else if (!last.screen && !last.missed && !last.closedOverlay && SAYS_NOTHING.test(decision.why)) {
1005
+ this.diag.push({ kind: "visitor_saw_nothing", action: "click", detail: `step ${last.step}` });
1006
+ }
1007
+ // 지금 화면이 그 걸음 바로 뒤의 화면이다
1008
+ if (this.diag.length > from) await this.diagContext(last, from, null);
1009
+ }
1010
+
1011
+ /**
1012
+ * 이번 걸음에 생긴 진단에 그 순간을 붙인다 — 화면(우리만 본다), 요소 주변 HTML, 페이지 경로.
1013
+ * 종류와 생김새만으로는 못 고친다: 10/22 무한 배너는 "잘림·회전 배너" 까지만 알았고, 같은 카드 복사본 때문인 건 열어 봐야 알았다
1014
+ */
1015
+ async diagContext(record, from, el) {
1016
+ const events = this.diag.slice(from).filter((e) => !e.id && !DIAG_NO_CONTEXT.has(e.kind));
1017
+ if (!events.length) return;
1018
+ let pagePath = null;
1019
+ try {
1020
+ const u = new URL(this.page.url());
1021
+ pagePath = (u.pathname + u.search).slice(0, 300);
1022
+ } catch {}
1023
+ const html = el
1024
+ ? await el
1025
+ .evaluate((n, max) => {
1026
+ if (!n.isConnected) return null;
1027
+ let best = n;
1028
+ for (let p = n.parentElement, d = 0; p && p !== document.body && d < 4; p = p.parentElement, d++) if (p.outerHTML.length <= max) best = p;
1029
+ return best.outerHTML.slice(0, max);
1030
+ }, MAX_DIAG_HTML)
1031
+ .catch(() => null)
1032
+ : null;
1033
+ let shotId = null;
1034
+ if (this.diagShots.length < MAX_DIAG_SHOTS) {
1035
+ const image = await this.snap();
1036
+ if (image) shotId = randomUUID();
1037
+ if (image) this.diagShots.push({ id: shotId, image });
1038
+ }
1039
+ events.forEach((e, i) => {
1040
+ e.id = i === 0 && shotId ? shotId : randomUUID();
1041
+ e.step = record.step;
1042
+ if (pagePath) e.path = pagePath;
1043
+ if (html) e.html = html;
1044
+ });
1045
+ }
1046
+
1047
+ /**
1048
+ * 문제가 있던 화면을 찍어 이 걸음에 붙인다(record.screen). 보이는 만큼만(뷰포트). 크면 한 번 더 작게, 그래도 크면 안 찍는다
1049
+ * 실패해도 방문은 그대로 간다
1050
+ */
1051
+ async shoot(record, kind, box = null) {
1052
+ if (!record || record.screen || this.screens.length >= MAX_SCREENS) return;
1053
+ // 우리 카드가 떠 있다. 눌렀는데 그대로인 것도 카드가 가로챈 것일 수 있다 — 사이트 탓으로 찍지 않는다
1054
+ const card = await readCard(this.page).catch(() => null);
1055
+ if (card && card.visible) return;
1056
+ const image = await this.snap();
1057
+ if (!image) return;
1058
+ record.screen = { kind, box };
1059
+ this.screens.push({ step: record.step, image });
1060
+ }
1061
+
1062
+ /** 뷰포트 JPEG. 크면 한 번 더 작게, 그래도 크면 null */
1063
+ async snap() {
1064
+ let image = null;
1065
+ for (const quality of [70, 45]) {
1066
+ image = await this.page.screenshot({ type: "jpeg", quality, scale: "css", timeout: 5000 }).catch(() => null);
1067
+ if (!image || image.length <= MAX_SCREEN_BYTES) break;
1068
+ }
1069
+ return image && image.length <= MAX_SCREEN_BYTES ? image : null;
1070
+ }
1071
+
1072
+ /** 카드 판단에 넘길 "이번 방문에 한 일" */
1073
+ pathText() {
1074
+ return this.history.length ? this.history.slice(-6).join(" / ") : "just arrived";
1075
+ }
1076
+
1077
+ result() {
1078
+ return { steps: this.steps, card: this.card, shape: this.shape };
1079
+ }
1080
+ }
1081
+
1082
+ /**
1083
+ * 우리가 AI 에 물어 가며 한 방문을 끝까지 돈다(자동 실행).
1084
+ * @param opts.ask async (systemPrompt, jsonSchema, message) => object
1085
+ * @returns { steps: [...], card: {...}|null, shape }
1086
+ */
1087
+ async function runVisit(page, person, { siteAbout, ask, rhythm = null, shape = null, maxStepsCap = 40, allowAccounts = false, log = () => {} } = {}) {
1088
+ /**
1089
+ * 몇 쪽이나 볼지는 그 사람이 정한다. 방문 모양(shape)은 **인내심 표시**로만 쓰고 걸음 수로 자르지 않는다.
1090
+ * 전에는 모양에서 걸음 상한을 뽑아(튕김이면 3걸음) 잘랐다 — 9/21 MCP 비교에서 8명 중 3명이
1091
+ * "더 내려 볼게요" 하는 중에 잘려 떠나지도 못한 채 끝났다. MCP 길(고객 Claude)은 표시만 받으니 두 길의 규칙이 달랐다.
1092
+ * 이제 둘 다 같다: 인내심 표시(`patienceSpent`) + 절대 상한(maxStepsCap, HARD_STEP_CAP).
1093
+ */
1094
+ const maxSteps = Math.min(maxStepsCap, HARD_STEP_CAP);
1095
+ const v = new Visit(page, person, { rhythm, shape, allowAccounts, log });
1096
+ await v.begin();
1097
+
1098
+ const decideCard = async (shown) => {
1099
+ const screen = await screenText(page);
1100
+ return ask(answer.systemPrompt(), answer.DECISION_SCHEMA, answer.buildMessage(person, siteAbout, v.pathText(), screen, shown));
1101
+ };
1102
+
1103
+ for (let step = 1; step <= maxSteps && !v.ended; step++) {
1104
+ const look = await v.look();
1105
+ if (look.card) {
1106
+ await v.answer(await decideCard(look.card));
1107
+ continue;
1108
+ }
1109
+ if (v.patienceSpent) v.history.push("Your patience is running low. Unless what is in front of you is getting you closer to what you came for, this is about where you would give up.");
1110
+ const counts = {};
1111
+ for (const s of v.steps) counts[s.path] = (counts[s.path] || 0) + 1;
1112
+ const visited = Object.entries(counts).map(([path, n]) => ({ path, n }));
1113
+ const decision = await ask(stepPrompt(), STEP_SCHEMA, buildStepMessage(person, siteAbout, look.view, v.history, look.text, visited));
1114
+ await v.act(decision);
1115
+ }
1116
+ // 떠나는 순간 뜬 카드
1117
+ if (v.pending) {
1118
+ const c = await v.answer(await decideCard(v.pending));
1119
+ log(`Card (on leaving): ${c.result}${c.answer ? ` — ${c.answer}` : ""}`);
1120
+ }
1121
+ return v.result();
1122
+ }
1123
+
1124
+ module.exports = { STEP_SCHEMA, STEP_PROMPT_FILE, HARD_STEP_CAP, stepPrompt, buildStepMessage, renderScreen, renderPerson, Visit, runVisit, settle, watchNet };