@usercase/mcp 0.0.0-stage → 0.2.1-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -2
- package/agents/install.md +26 -0
- package/agents/path.md +49 -0
- package/agents/question.md +62 -0
- package/agents/research.md +113 -0
- package/agents/usercase-visitor.md +112 -0
- package/mcp/hub.js +760 -0
- package/mcp/path.js +394 -0
- package/mcp/report.js +207 -0
- package/mcp/server.js +679 -0
- package/mcp/workspace.js +182 -0
- package/package.json +41 -4
- package/src/browser/blocked.js +180 -0
- package/src/browser/card.js +269 -0
- package/src/browser/guard.js +211 -0
- package/src/browser/hand.js +368 -0
- package/src/browser/open.js +59 -0
- package/src/browser/page.js +353 -0
- package/src/browser/policy.js +154 -0
- package/src/browser/real-chrome.js +242 -0
- package/src/browser/robots.js +73 -0
- package/src/browser/signin.js +66 -0
- package/src/persona/people.js +104 -0
- package/src/persona/persona.js +469 -0
- package/src/prompts/answer.md +50 -0
- package/src/prompts/people.md +38 -0
- package/src/prompts/step.md +36 -0
- package/src/run/answer.js +94 -0
- package/src/run/diag.js +64 -0
- package/src/run/entry.js +76 -0
- package/src/run/runner.js +205 -0
- package/src/run/visit.js +693 -0
package/src/run/visit.js
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
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 { listControls, visibleText, handleAt, clickBlocked } = require("../browser/page.js");
|
|
22
|
+
const { readCard, waitCard, screenText, answerCard, noteReason, sleep, waitForSdk } = require("../browser/card.js");
|
|
23
|
+
const hand = require("../browser/hand.js");
|
|
24
|
+
const answer = require("./answer.js");
|
|
25
|
+
const diag = require("./diag.js");
|
|
26
|
+
|
|
27
|
+
const STEP_PROMPT_FILE = path.join(__dirname, "..", "prompts", "step.md");
|
|
28
|
+
|
|
29
|
+
const STEP_SCHEMA = {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
why: { type: "string" },
|
|
33
|
+
action: { type: "string", enum: ["click", "type", "scroll", "scroll_up", "read", "back", "leave"] },
|
|
34
|
+
index: { type: "integer" },
|
|
35
|
+
text: { type: "string" },
|
|
36
|
+
done: { type: "boolean" },
|
|
37
|
+
times: { type: "integer" },
|
|
38
|
+
},
|
|
39
|
+
required: ["why", "action", "index", "text", "done", "times"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** 같은 조작을 연달아 몇 번까지. 굴리며 훑기·안 눌려서 거듭 누르기 */
|
|
43
|
+
const MAX_TIMES = { click: 4, scroll: 5, scroll_up: 5 };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 클릭 뒤 화면이 자리 잡기를 기다린다. 사람도 눌렀으면 뜰 때까지 본다.
|
|
47
|
+
* 안 기다리면 느린 화면이 "눌러도 안 열린다" 로 읽힌다 — 10/13 bluetags 에서 카드가 "안 열린다 → 조금 뒤 열렸다".
|
|
48
|
+
*/
|
|
49
|
+
async function settle(page, before, net) {
|
|
50
|
+
// 주소가 바뀔 때까지 보되, 아무것도 안 불러오고 있으면 거기서 끝난 것이다(정말 안 눌린 것).
|
|
51
|
+
// Next.js 같은 사이트는 다음 화면 데이터를 다 받은 뒤에야 주소를 바꾼다. 1.5초로 끊었더니 10/13 0.1.8 에서
|
|
52
|
+
// "안 열린다" 가 69번 — 그중 38번은 같은 걸 다시 누르자 열렸다. networkidle 은 이미 연 문서에서는 바로 끝나 소용없다
|
|
53
|
+
const t0 = Date.now();
|
|
54
|
+
let quietSince = null;
|
|
55
|
+
let moved = false;
|
|
56
|
+
// 화면을 불러오는 중이면 30초까지 본다. 10초로 끊었더니 개발 서버에서 카드가 "안 열렸다" 가 되고,
|
|
57
|
+
// 다시 누르자 그새 넘어가 "없어졌다" 가 됐다(10/15 실험실 p16)
|
|
58
|
+
while (Date.now() - t0 < (net && net.navPending() ? 30000 : 10000)) {
|
|
59
|
+
// 새 탭으로 열린 같은 사이트 화면을 이 탭으로 옮기는 중이다(hub). 그 이동이 끝날 때까지 본다
|
|
60
|
+
if (page.__ebPendingNav) {
|
|
61
|
+
await Promise.race([page.__ebPendingNav, sleep(30000)]);
|
|
62
|
+
moved = page.url() !== before;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
if (page.url() !== before) {
|
|
66
|
+
moved = true;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
const busy = net ? net.busy() : 0;
|
|
70
|
+
if (busy) quietSince = null;
|
|
71
|
+
else if (quietSince === null) quietSince = Date.now();
|
|
72
|
+
if (Date.now() - t0 > 800 && quietSince !== null && Date.now() - quietSince > 600) break;
|
|
73
|
+
await sleep(100);
|
|
74
|
+
}
|
|
75
|
+
if (moved) {
|
|
76
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 10000 }).catch(() => {});
|
|
77
|
+
await page.waitForLoadState("networkidle", { timeout: 4000 }).catch(() => {});
|
|
78
|
+
// 뒤로 가기는 주소가 먼저 바뀌고, 그다음에 새 화면 데이터를 받아 그린다(Next.js). 위 두 대기는 이미 연 문서라 바로 끝난다 —
|
|
79
|
+
// 주소는 "/" 인데 화면은 아직 커뮤니티였다(10/14 bluetags p15: 옛 화면의 버튼을 눌러 "없다"·"안 된다").
|
|
80
|
+
// 조작이 시작한 요청이 다 끝날 때까지 본다
|
|
81
|
+
if (net) {
|
|
82
|
+
const t1 = Date.now();
|
|
83
|
+
let quiet = null;
|
|
84
|
+
while (Date.now() - t1 < 8000) {
|
|
85
|
+
if (net.busy()) quiet = null;
|
|
86
|
+
else if (quiet === null) quiet = Date.now();
|
|
87
|
+
if (quiet !== null && Date.now() - quiet > 400) break;
|
|
88
|
+
await sleep(100);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// 30초를 다 기다렸는데도 화면을 불러오는 중이었다 — 진단으로 남긴다
|
|
93
|
+
return { navTimeout: !moved && !!(net && net.navPending()) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 조작 뒤 불러오고 있는 요청 수. 웹소켓·스트림은 끝나지 않으니 뺀다 */
|
|
97
|
+
function watchNet(page) {
|
|
98
|
+
const live = new Set();
|
|
99
|
+
const on = (r) => {
|
|
100
|
+
if (!["websocket", "eventsource", "media"].includes(r.resourceType())) live.add(r);
|
|
101
|
+
};
|
|
102
|
+
const off = (r) => live.delete(r);
|
|
103
|
+
page.on("request", on);
|
|
104
|
+
page.on("requestfinished", off);
|
|
105
|
+
page.on("requestfailed", off);
|
|
106
|
+
return {
|
|
107
|
+
busy: () => live.size,
|
|
108
|
+
/** 이 탭의 화면 자체를 불러오는 중인가(문서 요청). 개발 서버는 처음 여는 화면을 만드느라 10초를 넘긴다 */
|
|
109
|
+
navPending: () => [...live].some((r) => {
|
|
110
|
+
try {
|
|
111
|
+
return r.isNavigationRequest() && r.frame() === page.mainFrame();
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}),
|
|
116
|
+
stop() {
|
|
117
|
+
page.off("request", on);
|
|
118
|
+
page.off("requestfinished", off);
|
|
119
|
+
page.off("requestfailed", off);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 링크가 사이트 밖이면 그 호스트. www 만 다른 것은 같은 사이트다 */
|
|
125
|
+
function offsiteHost(href, here) {
|
|
126
|
+
if (!href || !/^https?:\/\//i.test(href)) return null;
|
|
127
|
+
try {
|
|
128
|
+
const bare = (h) => h.replace(/^www\./, "");
|
|
129
|
+
const to = new URL(href);
|
|
130
|
+
return bare(to.host) === bare(new URL(here).host) ? null : to.host;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 들어와서 처음 손대기까지. 훑는 사람은 금방, 읽는 사람은 한참 본다 */
|
|
137
|
+
/**
|
|
138
|
+
* Playwright 의 클릭 오류를 사람이 겪는 말로. "elementHandle.click: Timeout 8000ms exceeded" 를 그대로 주면
|
|
139
|
+
* 방문자는 화면이 멈췄다고 읽는다(10/14 bluetags p4)
|
|
140
|
+
*/
|
|
141
|
+
function humanClickError(e) {
|
|
142
|
+
const m = String((e && e.message) || "");
|
|
143
|
+
if (/intercepts pointer events/.test(m)) return "something else on the screen is on top of it";
|
|
144
|
+
if (/not enabled|disabled/.test(m)) return "it is greyed out and does not respond";
|
|
145
|
+
if (/not visible|outside of the viewport/.test(m)) return "it is not visible on the screen right now";
|
|
146
|
+
if (/detached|not attached/.test(m)) return "no longer there";
|
|
147
|
+
return "nothing happened when you clicked it";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 파일 고르는 창. 사람에게는 컴퓨터의 파일 창이 뜬다 — 자동 브라우저에서는 조용히 사라져 "안 열린다" 가 된다(10/14 p24) */
|
|
151
|
+
function fileWindowNote(fc) {
|
|
152
|
+
const accept = fc.accept || "";
|
|
153
|
+
const kind = /image/.test(accept) ? "an image" : /video/.test(accept) ? "a video" : /pdf/.test(accept) ? "a PDF" : "a file";
|
|
154
|
+
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.`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function firstLookMs(attention) {
|
|
158
|
+
const [lo, hi] = { skims: [800, 6000], reads: [2500, 15000], studies: [5000, 25000] }[attention] || [2500, 15000];
|
|
159
|
+
// 짧은 쪽에 몰리고 가끔 길다
|
|
160
|
+
return Math.round(lo + (hi - lo) * Math.pow(Math.random(), 1.8));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 한 방문 걸음 수의 절대 상한. 판단을 누가 하든 이 이상은 안 걷는다 */
|
|
164
|
+
const HARD_STEP_CAP = 60;
|
|
165
|
+
|
|
166
|
+
function stepPrompt() {
|
|
167
|
+
return fs.readFileSync(STEP_PROMPT_FILE, "utf8");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 지금 화면을 글로. 사람 소개 없이 화면만 — MCP 에이전트는 자기가 누구인지 이미 안다 */
|
|
171
|
+
function renderScreen(look) {
|
|
172
|
+
const { view, text } = look;
|
|
173
|
+
return [
|
|
174
|
+
"## Where you are",
|
|
175
|
+
`- path: ${view.path}`,
|
|
176
|
+
`- scrolled ${view.scrollY} of ${Math.max(0, view.height - view.viewport)} px`,
|
|
177
|
+
"",
|
|
178
|
+
"## On the screen",
|
|
179
|
+
"```",
|
|
180
|
+
text,
|
|
181
|
+
"```",
|
|
182
|
+
"",
|
|
183
|
+
"## Things you can use (number · what it says · where)",
|
|
184
|
+
...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.where}`),
|
|
185
|
+
// 화면 안에 있는데 목록에 못 넣은 것. "아래에 더 있다" 고 하면 내려 봐도 못 찾는다
|
|
186
|
+
// 화면 안에서 빠진 것과 지나온 위쪽에서 빠진 것을 따로 말한다 — 한 숫자로 말하면 "화면에 너무 많다" 가 거짓이 된다
|
|
187
|
+
...(view.droppedInView
|
|
188
|
+
? [view.droppedInView <= (view.droppedIcons || 0)
|
|
189
|
+
? `(${view.droppedInView} more small icons or repeated images on this screen are not listed.)`
|
|
190
|
+
: `(${view.droppedInView} more things on this screen are not listed — there is too much in view.)`]
|
|
191
|
+
: view.dropped
|
|
192
|
+
? [`(${view.dropped} more things further up the page are not listed — scroll up to use them.)`]
|
|
193
|
+
: []),
|
|
194
|
+
// 아래에 더 있다는 것만. 무엇인지는 내려 봐야 안다
|
|
195
|
+
...(view.below
|
|
196
|
+
? [`(The page goes on below. Scroll down to see what is there.)`]
|
|
197
|
+
: view.height - view.viewport - view.scrollY > 40
|
|
198
|
+
? ["(There is more of the page below.)"]
|
|
199
|
+
: ["(This is the bottom of the page.)"]),
|
|
200
|
+
].join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 그 사람이 누구인가. **두 길이 같은 글을 쓴다** — 우리 AI 의 걸음 판단(buildStepMessage)과
|
|
205
|
+
* MCP 의 `open_visit`(고객 Claude 서브에이전트가 받는 자기소개).
|
|
206
|
+
* 9/21 에 MCP 쪽이 이름만 받고 걸었다 — 목적도 상태도 모른 채 이름에 붙은 꼬리표로 짐작해 움직였다.
|
|
207
|
+
*
|
|
208
|
+
* @param card 카드를 다루는 쪽에도 쓰이나(설문 습관·쓰는 버릇까지). 걸음만 정할 때는 필요 없다
|
|
209
|
+
*/
|
|
210
|
+
const SURVEY_HABIT = {
|
|
211
|
+
ignores: "you usually ignore them",
|
|
212
|
+
sometimes: "you sometimes answer them",
|
|
213
|
+
willing: "you are usually happy to answer them",
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function renderPerson(person, { card = false } = {}) {
|
|
217
|
+
const lines = [
|
|
218
|
+
`- on ${person.device}`,
|
|
219
|
+
`- ${person.background}`,
|
|
220
|
+
`- what you came for: ${person.goal}`,
|
|
221
|
+
`- right now: ${person.moment ?? "(not given)"}`,
|
|
222
|
+
`- you take in a page by: ${person.attention ?? "reads"}`,
|
|
223
|
+
];
|
|
224
|
+
if (card) {
|
|
225
|
+
// 값이 동사 하나(ignores·sometimes·willing)라 그대로 끼우면 "you usually sometimes them" 이 됐다(10/14 실험실)
|
|
226
|
+
lines.push(`- survey cards that pop up: ${SURVEY_HABIT[person.surveyHabit] || SURVEY_HABIT.sometimes}`);
|
|
227
|
+
lines.push(`- when you type, you are ${person.writing ?? "careful"}`);
|
|
228
|
+
}
|
|
229
|
+
return lines.join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param visited 이번 방문에 들른 경로와 횟수 [{path, n}]. 최근 8걸음만 보여주면 앞에서 본 것을 잊고 같은 길을 돈다
|
|
234
|
+
* (9/21: 39걸음 동안 같은 네 쪽을 세 바퀴 돌았다). 고객 Claude(MCP)는 방문 전체를 기억하니 이 길에만 필요하다
|
|
235
|
+
*/
|
|
236
|
+
function buildStepMessage(person, siteAbout, view, history, pageText, visited = []) {
|
|
237
|
+
const lines = [
|
|
238
|
+
"## Website",
|
|
239
|
+
siteAbout,
|
|
240
|
+
"",
|
|
241
|
+
"## You",
|
|
242
|
+
renderPerson(person),
|
|
243
|
+
"",
|
|
244
|
+
renderScreen({ view, text: pageText }),
|
|
245
|
+
"",
|
|
246
|
+
];
|
|
247
|
+
if (visited.length) {
|
|
248
|
+
lines.push("## Pages you have already been to on this visit (steps you spent there)");
|
|
249
|
+
visited.forEach((v) => lines.push(`- ${v.path} ×${v.n}`));
|
|
250
|
+
lines.push("");
|
|
251
|
+
}
|
|
252
|
+
if (history.length) {
|
|
253
|
+
lines.push("## What you have done so far");
|
|
254
|
+
if (history.length > 8) lines.push(`(${history.length - 8} earlier steps not shown)`);
|
|
255
|
+
history.slice(-8).forEach((h, i) => lines.push(`${i + 1}. ${h}`));
|
|
256
|
+
lines.push("");
|
|
257
|
+
}
|
|
258
|
+
lines.push("What do you do next?");
|
|
259
|
+
return lines.join("\n");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 한 방문의 몸. 사이트가 이미 열린 page 를 받는다.
|
|
264
|
+
* 순서: begin() → [ look() → (카드면 answer(), 아니면 act()) ]* → act({action:"leave"}) → (떠날 때 카드면 answer())
|
|
265
|
+
*/
|
|
266
|
+
class Visit {
|
|
267
|
+
/**
|
|
268
|
+
* @param person people.js PERSON_FIELDS
|
|
269
|
+
* @param rhythm persona.Rhythm (조작 사이 간격). 없으면 짧게 쉰다
|
|
270
|
+
* @param shape persona.drawShape 가 뽑은 이번 방문 모양(튕김·훑기·깊게). 인내심 표시에 쓴다
|
|
271
|
+
* @param offsite 사이트 밖으로 가려던 주소가 쌓이는 배열(guard.js onOffsite 가 채운다)
|
|
272
|
+
*/
|
|
273
|
+
constructor(page, person, { rhythm = null, shape = null, allowAccounts = false, policy = null, offsite = [], notSent = [], external = [], log = () => {} } = {}) {
|
|
274
|
+
this.page = page;
|
|
275
|
+
this.person = person;
|
|
276
|
+
/** 방문 시작 — 걸음마다 몇 초째인지(t) 적는다 */
|
|
277
|
+
this.t0 = Date.now();
|
|
278
|
+
this.rhythm = rhythm;
|
|
279
|
+
this.shape = shape;
|
|
280
|
+
this.allowAccounts = allowAccounts;
|
|
281
|
+
/** 고객이 정한 금지 구역(browser/policy.js). 누를 것·칠 것을 가린다 */
|
|
282
|
+
this.policy = policy;
|
|
283
|
+
this.offsite = offsite;
|
|
284
|
+
/** 사이트에 쓰려다 방어선이 끊은 요청(hub 가 채운다) */
|
|
285
|
+
this.notSent = notSent;
|
|
286
|
+
this.external = external;
|
|
287
|
+
// 알린 바깥 서버. 소켓은 몇 초마다 다시 붙으려 해서, 한 번만 알린다
|
|
288
|
+
this.externalTold = new Set();
|
|
289
|
+
/** 도구가 하지 못한 것들 — 방문이 끝나면 hub 가 서버로 보낸다(diag.js). 사이트 글자는 없다 */
|
|
290
|
+
this.diag = [];
|
|
291
|
+
this.log = log;
|
|
292
|
+
// 손은 방문 내내 이어진다 — 커서가 있던 자리에서 다음 곳으로 간다
|
|
293
|
+
this.handState = { rhythm, mouse: null };
|
|
294
|
+
this.steps = [];
|
|
295
|
+
this.history = [];
|
|
296
|
+
this.seenPaths = new Set();
|
|
297
|
+
this.card = null; // 처리한 카드(한 방문에 하나)
|
|
298
|
+
this.pending = null; // 떴는데 아직 답 안 한 카드
|
|
299
|
+
this.lastView = null; // act 의 번호는 마지막 look 의 화면 기준이다
|
|
300
|
+
this.lastAction = null;
|
|
301
|
+
/**
|
|
302
|
+
* 다음 조작을 해도 되는 때. **사람 박자는 여기서 정한다.**
|
|
303
|
+
* 전에는 조작 뒤에 쉬고 나서 화면을 돌려줬다 — 그 뒤 AI 가 생각하는 시간이 또 붙어 늘 8~30초 간격이 됐다
|
|
304
|
+
* (10/13 bluetags 105명, 첫 조작은 거의 다 3~4초). 이제 화면은 바로 돌려주고, 다음 조작 때 이 시각까지 기다린다.
|
|
305
|
+
* AI 가 더 오래 생각했으면 기다리지 않는다 — 간격은 둘 중 긴 쪽이다.
|
|
306
|
+
*/
|
|
307
|
+
this.nextAt = 0;
|
|
308
|
+
this.ended = false;
|
|
309
|
+
this.sdk = null;
|
|
310
|
+
// 파일 고르는 창이 열렸나. 듣고 있으면 Playwright 가 창을 띄우지 않는다 — 고르지 않은 채 닫은 것과 같다
|
|
311
|
+
this.fileWindow = null;
|
|
312
|
+
page.on("filechooser", (fc) => {
|
|
313
|
+
this.fileWindow = { accept: "" };
|
|
314
|
+
fc.element()
|
|
315
|
+
.getAttribute("accept")
|
|
316
|
+
.then((a) => (this.fileWindow = { accept: a || "" }))
|
|
317
|
+
.catch(() => {});
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* **첫 걸음을 떼기 전에 SDK 가 붙기를 기다린다.**
|
|
323
|
+
* 광고·트래커가 많은 사이트는 SDK 가 늦게 붙는데, 그 사이의 조작은 기록에 안 남는다.
|
|
324
|
+
* 방문은 성공한 것처럼 끝나고 기록만 앞부분이 잘려서 **조용히 틀린 데이터**가 된다.
|
|
325
|
+
* (2026-09-21 사람인: goto 60초 타임아웃 뒤에 붙었다. 블루태그스는 로컬이라 빨라서 안 드러났다.)
|
|
326
|
+
*/
|
|
327
|
+
async begin() {
|
|
328
|
+
this.sdk = await waitForSdk(this.page);
|
|
329
|
+
if (!this.sdk.ready) {
|
|
330
|
+
// 기다려도 안 붙었으면 이 방문의 기록은 못 믿는다. 조용히 넘기지 않는다
|
|
331
|
+
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`);
|
|
332
|
+
} else if (this.sdk.ms > 3000) {
|
|
333
|
+
this.log(`SDK started after ${(this.sdk.ms / 1000).toFixed(1)}s`);
|
|
334
|
+
}
|
|
335
|
+
this.nextAt = Date.now() + firstLookMs(this.person.attention);
|
|
336
|
+
return this.sdk;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 이 화면을 보고 다음에 손대기까지. 새로 보이는 글이 많을수록 길다(persona.js Rhythm.gap) */
|
|
340
|
+
gapFor(view, text) {
|
|
341
|
+
if (!this.rhythm) return 1200;
|
|
342
|
+
const g = this.rhythm.gap({ sig: view.path, chars: text.length, media: 1, top: view.scrollY, vh: view.viewport, cmd: this.lastAction || "look" });
|
|
343
|
+
return Math.min(45000, Math.max(250, g.ms));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 정해 둔 때까지 기다린다. AI 가 그보다 오래 생각했으면 바로 간다 */
|
|
347
|
+
async waitTurn() {
|
|
348
|
+
const ms = this.nextAt - Date.now();
|
|
349
|
+
if (ms > 0) await sleep(ms);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 이번 방문에 걸었던 마음만큼 봤나. 여기서부터는 나갈 결심이 선다 */
|
|
353
|
+
get patienceSpent() {
|
|
354
|
+
return !!(this.shape && this.seenPaths.size > this.shape.screens && !this.card);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 지금 무엇이 보이나. 카드가 떠 있으면 카드부터 돌려준다 — 이게 이 방문의 산출물이다.
|
|
359
|
+
* **보이는 만큼만.** 글도 조작도 화면 안의 것이다 — 아래는 내려 봐야 안다.
|
|
360
|
+
* @returns { card } 또는 { view, text }
|
|
361
|
+
*/
|
|
362
|
+
async look() {
|
|
363
|
+
if (this.ended) return { ended: true };
|
|
364
|
+
if (!this.card && !this.pending) {
|
|
365
|
+
const shown = await waitCard(this.page, 500);
|
|
366
|
+
if (shown) this.pending = shown;
|
|
367
|
+
}
|
|
368
|
+
// 카드 판단에는 페이지 전체를 준다 — 이 방문에서 본 것을 다 기억하는 사람이다
|
|
369
|
+
if (this.pending) return { card: this.pending, text: await screenText(this.page) };
|
|
370
|
+
const view = await listControls(this.page);
|
|
371
|
+
// 위쪽만 빠진 것은 넘침이 아니다 — 굴려 올리면 다시 나온다
|
|
372
|
+
if (view.droppedInView > (view.droppedIcons || 0)) this.diag.push({ kind: "controls_overflow", detail: `dropped ${view.droppedInView - (view.droppedIcons || 0)}` });
|
|
373
|
+
const text = await visibleText(this.page, 2500);
|
|
374
|
+
this.seenPaths.add(view.path);
|
|
375
|
+
this.lastView = view;
|
|
376
|
+
this.nextAt = Math.max(this.nextAt, Date.now() + this.gapFor(view, text));
|
|
377
|
+
return { view, text };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 카드에 답한다(또는 닫는다). decision 은 answer.js DECISION_SCHEMA 모양.
|
|
382
|
+
* 이유를 SDK 에 먼저 남긴 뒤 누른다 — SDK 가 보낼 때 읽는다.
|
|
383
|
+
*/
|
|
384
|
+
async answer(decision) {
|
|
385
|
+
const shown = this.pending;
|
|
386
|
+
if (!shown) throw new Error("No card is showing");
|
|
387
|
+
const act = answer.toAction(decision, shown);
|
|
388
|
+
// 카드를 읽고 정하는 시간. 닫는 건 금방, 쓰는 건 치는 만큼
|
|
389
|
+
await sleep(act.kind === "close" ? 600 + Math.random() * 1800 : 1500 + Math.random() * 3000);
|
|
390
|
+
await noteReason(this.page, decision.bothered ? decision.why : decision.botheredWhy);
|
|
391
|
+
await answerCard(this.page, act).catch(() => {});
|
|
392
|
+
this.card = {
|
|
393
|
+
question: shown.question,
|
|
394
|
+
choices: shown.choices,
|
|
395
|
+
bothered: decision.bothered,
|
|
396
|
+
botheredWhy: decision.botheredWhy,
|
|
397
|
+
why: decision.why,
|
|
398
|
+
result: act.kind === "close" ? "close" : act.kind,
|
|
399
|
+
answer: act.kind === "choose" ? shown.choices[act.index] : act.kind === "other" ? act.text : null,
|
|
400
|
+
t: Date.now() - this.t0,
|
|
401
|
+
path: (() => {
|
|
402
|
+
try {
|
|
403
|
+
return new URL(this.page.url()).pathname;
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
})(),
|
|
408
|
+
};
|
|
409
|
+
this.pending = null;
|
|
410
|
+
this.history.push(`A survey card appeared and you ${this.card.result === "close" ? "closed it" : "answered it"}.`);
|
|
411
|
+
this.log(`Card: ${this.card.result}${this.card.answer ? ` — ${this.card.answer}` : ""}`);
|
|
412
|
+
this.nextAt = Date.now() + 800 + Math.random() * 2500;
|
|
413
|
+
return this.card;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 한 걸음. decision 은 STEP_SCHEMA 모양({ why, action, index, text, done, times }).
|
|
418
|
+
* 번호는 마지막 look() 화면 기준이다. 떠나면 끝나고, 떠나는 순간 뜬 카드가 있으면 pending 에 남는다.
|
|
419
|
+
* 보고 있던 사이에 카드가 떴으면 하려던 걸 하지 않고 { interrupted: true } 를 돌려준다 — 카드부터 본다.
|
|
420
|
+
*/
|
|
421
|
+
async act(decision) {
|
|
422
|
+
if (this.ended) throw new Error("This visit has already ended");
|
|
423
|
+
if (this.pending) throw new Error("A card is showing — answer or close it first");
|
|
424
|
+
await this.waitTurn();
|
|
425
|
+
if (!this.card) {
|
|
426
|
+
const c = await readCard(this.page);
|
|
427
|
+
if (c && c.visible) {
|
|
428
|
+
this.pending = c;
|
|
429
|
+
return { interrupted: true };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (this.steps.length >= HARD_STEP_CAP) decision = { ...decision, action: "leave", why: `(${HARD_STEP_CAP}-step limit) ${decision.why}` };
|
|
433
|
+
const page = this.page;
|
|
434
|
+
const view = this.lastView || (await listControls(page));
|
|
435
|
+
const item = view.items[decision.index];
|
|
436
|
+
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 };
|
|
437
|
+
const times = Math.max(1, Math.min(MAX_TIMES[decision.action] || 1, Math.round(Number(decision.times) || 1)));
|
|
438
|
+
if (times > 1) record.times = times;
|
|
439
|
+
const before = page.url();
|
|
440
|
+
const offsiteBefore = this.offsite.length;
|
|
441
|
+
const notSentBefore = this.notSent.length;
|
|
442
|
+
const externalBefore = this.external.length;
|
|
443
|
+
this.fileWindow = null;
|
|
444
|
+
this.lastAction = decision.action;
|
|
445
|
+
|
|
446
|
+
if (decision.action === "leave") {
|
|
447
|
+
record.done = !!decision.done;
|
|
448
|
+
this.steps.push(record);
|
|
449
|
+
this.log(`${record.step}. left — ${decision.why}`);
|
|
450
|
+
// 떠나는 모양도 사람처럼. 데스크톱은 커서가 창 위로 빠져나간다
|
|
451
|
+
if (this.person.device !== "mobile") {
|
|
452
|
+
await page.mouse.move(600, 120, { steps: 4 }).catch(() => {});
|
|
453
|
+
await page.mouse.move(600, 2, { steps: 6 }).catch(() => {});
|
|
454
|
+
await page.mouse.move(600, -8, { steps: 2 }).catch(() => {});
|
|
455
|
+
}
|
|
456
|
+
this.ended = true;
|
|
457
|
+
// 떠나려는 순간에 뜨는 카드가 있다. 모바일은 SDK 가 멈춤으로 대신 보므로 더 기다린다
|
|
458
|
+
if (!this.card) {
|
|
459
|
+
const parting = await waitCard(page, this.person.device === "mobile" ? 16000 : 6000);
|
|
460
|
+
if (parting) this.pending = parting;
|
|
461
|
+
}
|
|
462
|
+
return record;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const net = watchNet(page);
|
|
466
|
+
let diagEl = null; // 진단에 생김새를 남길 요소
|
|
467
|
+
if (decision.action === "read") {
|
|
468
|
+
// 가만히 읽는 시간. 사람은 계속 누르지 않는다 — 이게 없으면 "N초 머묾" 같은 조건이 영영 안 걸린다.
|
|
469
|
+
// 기다리는 건 다음 조작 때(waitTurn). 그 사이 카드가 뜨면 다음 조작 전에 본다
|
|
470
|
+
const base = { skims: 6000, reads: 14000, studies: 24000 }[this.person.attention ?? "reads"] ?? 14000;
|
|
471
|
+
record.readMs = Math.round(base * (0.6 + Math.random() * 0.8));
|
|
472
|
+
this.nextAt = Date.now() + record.readMs;
|
|
473
|
+
} else if (decision.action === "scroll" || decision.action === "scroll_up") {
|
|
474
|
+
// 훑으며 연달아 굴린다. 사이는 짧다 — 멈춰서 읽을 거면 따로 read 한다
|
|
475
|
+
record.scrolled = 0;
|
|
476
|
+
for (let i = 0; i < times; i++) {
|
|
477
|
+
if (i) await sleep(300 + Math.random() * 1500);
|
|
478
|
+
record.scrolled += await hand.scrollLikePerson(page, this.handState, decision.action === "scroll_up" ? -1 : 1);
|
|
479
|
+
}
|
|
480
|
+
await sleep(250);
|
|
481
|
+
} else if (decision.action === "back") {
|
|
482
|
+
await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
|
|
483
|
+
if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "back" });
|
|
484
|
+
} else if (decision.action === "type") {
|
|
485
|
+
// 개인정보가 들어갈 칸(이메일·전화·카드 등)에는 치지 않는다. 고객 정책이 더 막거나 열 수 있다
|
|
486
|
+
const fieldOff = this.policy && this.policy.fieldBlocked(item?.label || "");
|
|
487
|
+
const el = fieldOff ? null : item ? await handleAt(page, decision.index) : null;
|
|
488
|
+
diagEl = el;
|
|
489
|
+
// 방문자에게는 사람의 선택으로 알린다. "field blocked by policy" 를 그대로 주면 방문자가 "칸이 입력을 안 받는다" 고
|
|
490
|
+
// 적어 사이트 결함처럼 읽혔다(10/14 실험실 p26, 비밀번호 찾기 이메일 칸)
|
|
491
|
+
if (fieldOff) record.personalField = item?.label ?? "";
|
|
492
|
+
else if (!el) record.skipped = "no longer there";
|
|
493
|
+
else {
|
|
494
|
+
// 칸을 먼저 누르고 친다. 칸에 남는 글자는 언제나 원래 치려던 것이다(오타는 스스로 고친다)
|
|
495
|
+
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"));
|
|
496
|
+
// 칸에 전에 친 글이 남아 있으면 지우고 친다 — 사람은 새 검색어를 옛 것 뒤에 잇지 않는다.
|
|
497
|
+
// 안 지우면 "PolygonPolygon" 이 되고 그게 사이트 문제로 보고된다(10/13 bluetags 3명)
|
|
498
|
+
const typed = String(decision.text || "").slice(0, 80);
|
|
499
|
+
const value = await el.evaluate((n) => String(n.value ?? n.textContent ?? "")).catch(() => "");
|
|
500
|
+
const multiline = await el.evaluate((n) => n.tagName === "TEXTAREA" || n.isContentEditable).catch(() => false);
|
|
501
|
+
/**
|
|
502
|
+
* Enter 는 사람이 누를 때만. 여러 줄 칸(댓글·답글)에서 Enter 는 줄바꿈이고, 사람은 Post 를 누른다.
|
|
503
|
+
* 치는 대로 결과가 걸러지는 칸에서는 사람은 Enter 없이 결과를 본다 — 그 칸이 <form> 안이면 Enter 가 폼을 보내
|
|
504
|
+
* 페이지가 다시 열리고 검색이 사라졌다(10/15 실험실 x40, bluetags 커뮤니티 검색).
|
|
505
|
+
* 그때는 Enter 를 안 눌렀다고 알리고, 같은 글을 또 치면 Enter 만 누른다(추천 목록이 뜨는 검색칸에서 결과 화면으로 가려는 사람)
|
|
506
|
+
*/
|
|
507
|
+
if (!multiline && typed && value === typed && this.heldEnter === typed) {
|
|
508
|
+
await page.keyboard.press("Enter").catch(() => {});
|
|
509
|
+
this.heldEnter = null;
|
|
510
|
+
} else {
|
|
511
|
+
if (value.length) {
|
|
512
|
+
// 칸에 전에 친 글이 남아 있으면 지우고 친다 — 사람은 새 검색어를 옛 것 뒤에 잇지 않는다.
|
|
513
|
+
// 안 지우면 "PolygonPolygon" 이 되고 그게 사이트 문제로 보고된다(10/13 bluetags 3명)
|
|
514
|
+
await page.keyboard.press("ControlOrMeta+A").catch(() => {});
|
|
515
|
+
await sleep(120 + Math.random() * 300);
|
|
516
|
+
await page.keyboard.press("Backspace").catch(() => {});
|
|
517
|
+
await sleep(150 + Math.random() * 350);
|
|
518
|
+
}
|
|
519
|
+
// 치기 전 화면. 치는 대로 걸러 주는 칸이면 친 뒤 화면이 이미 바뀌어 있다
|
|
520
|
+
const shownBefore = multiline ? "" : await visibleText(page, 2500);
|
|
521
|
+
record.typos = await hand.typeLikePerson(page, this.handState, typed).catch(() => 0);
|
|
522
|
+
let changed = false;
|
|
523
|
+
if (!multiline) {
|
|
524
|
+
await sleep(500 + Math.random() * 400);
|
|
525
|
+
const strip = (t) => t.split(typed).join("");
|
|
526
|
+
changed = strip(await visibleText(page, 2500)) !== strip(shownBefore);
|
|
527
|
+
}
|
|
528
|
+
if (!multiline && !changed) await page.keyboard.press("Enter").catch(() => {});
|
|
529
|
+
this.heldEnter = !multiline && changed ? typed : null;
|
|
530
|
+
if (this.heldEnter) record.heldEnter = true;
|
|
531
|
+
}
|
|
532
|
+
if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "type" });
|
|
533
|
+
diagEl = el;
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
const blocked = item ? clickBlocked(item, { allowAccounts: this.allowAccounts, policy: this.policy }) : "that number is not on the screen";
|
|
537
|
+
if (blocked) record.skipped = blocked;
|
|
538
|
+
else {
|
|
539
|
+
const el = await handleAt(page, decision.index);
|
|
540
|
+
diagEl = el;
|
|
541
|
+
if (!el) record.skipped = "no longer there";
|
|
542
|
+
else {
|
|
543
|
+
// 같은 사이트를 새 탭으로 여는 링크는 이 탭에서 열게 한다. 사람은 열린 탭에서 이어 보고, 방문은 한 탭이다.
|
|
544
|
+
// 새 탭을 잡아 옮기는 건(hub) 브라우저가 새 탭을 늦게, 주소 없이 알려 줘서 놓쳤다 —
|
|
545
|
+
// 방문자는 "안 열린다" 를 보고 다시 누르면 "없어졌다" 가 됐다(10/14 실험실 p3, 첫 화면 카드)
|
|
546
|
+
await el
|
|
547
|
+
.evaluate((n) => {
|
|
548
|
+
const a = n.closest("a[target]");
|
|
549
|
+
if (a && a.target !== "_self" && a.href && new URL(a.href, location.href).origin === location.origin) a.target = "_self";
|
|
550
|
+
})
|
|
551
|
+
.catch(() => {});
|
|
552
|
+
// 사람 손이라 빗나갈 수 있다. 빈 곳을 누르면 아무 일도 안 일어난다 — 그게 헛클릭이다
|
|
553
|
+
const clicked = await hand
|
|
554
|
+
.clickLikePerson(page, this.handState, el)
|
|
555
|
+
.catch((e) => ((record.skipped = e.human || humanClickError(e)), { missed: false }));
|
|
556
|
+
if (clicked.missed) {
|
|
557
|
+
record.missed = true;
|
|
558
|
+
this.history.push("Your click landed just outside it and nothing happened.");
|
|
559
|
+
}
|
|
560
|
+
// 잘려 있던 것을 줄째 옆으로 밀어 가져와 눌렀다
|
|
561
|
+
if (clicked.swiped) record.swiped = true;
|
|
562
|
+
// 떠 있던 창 바깥(막)을 눌러 창이 닫혔다. 버튼에는 안 닿았다
|
|
563
|
+
if (clicked.closedOverlay && page.url() === before) {
|
|
564
|
+
record.closedOverlay = true;
|
|
565
|
+
this.history.push("Your click closed what was open on top of the page; it did not reach the thing you clicked.");
|
|
566
|
+
}
|
|
567
|
+
// 안 눌린다 싶어 거듭 누른다. 화면이 바뀌면 멈춘다
|
|
568
|
+
for (let i = 1; i < times && !record.skipped && this.handState.mouse; i++) {
|
|
569
|
+
await sleep(150 + Math.random() * 450);
|
|
570
|
+
if (page.url() !== before || this.offsite.length > offsiteBefore) break;
|
|
571
|
+
await page.mouse.click(this.handState.mouse.x, this.handState.mouse.y).catch(() => {});
|
|
572
|
+
}
|
|
573
|
+
if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "click", shape: await diag.shapeOf(el) });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
net.stop();
|
|
579
|
+
|
|
580
|
+
// 조작 뒤 탭이 빈 페이지(about:blank 등)가 됐다 — 앱을 여는 링크(kakaolink:, intent:, tel:)를 브라우저가 못 열 때 그렇다.
|
|
581
|
+
// 뒤로 가기로도 안 돌아와 방문자가 빈 화면에 갇혔다(10/15 실험실 p28, 모바일 공유 창). 원래 화면으로 돌려놓는다
|
|
582
|
+
if (/^https?:/i.test(before) && !/^https?:/i.test(page.url())) {
|
|
583
|
+
await page.goto(before, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
|
|
584
|
+
await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
|
|
585
|
+
record.openedApp = true;
|
|
586
|
+
this.diag.push({ kind: "left_to_blank", action: decision.action, shape: null });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 사이트 밖으로 가는 링크였다. 끊었으니 방문자는 그대로 이 화면에 있다.
|
|
590
|
+
// 새 탭으로 여는 링크(target=_blank)는 방어선을 안 지나고 열린다 — 누른 링크 주소로도 본다(탭은 hub 가 닫는다)
|
|
591
|
+
const hrefOff = decision.action === "click" && !record.skipped ? offsiteHost(item?.href, before) : null;
|
|
592
|
+
if (this.offsite.length > offsiteBefore) {
|
|
593
|
+
try {
|
|
594
|
+
record.offsite = new URL(this.offsite[this.offsite.length - 1]).host;
|
|
595
|
+
} catch {
|
|
596
|
+
record.offsite = "another site";
|
|
597
|
+
}
|
|
598
|
+
} else if (hrefOff) record.offsite = hrefOff;
|
|
599
|
+
|
|
600
|
+
// 쓰려다 끊겼다. 방문자가 이걸 사이트가 안 된다로 읽지 않게 알린다
|
|
601
|
+
// 다른 화면으로 넘어갔으면 그 사이 끊긴 것은 화면을 열며 저절로 나간 것이다(읽음 표시·조회수) — 방문자가 보낸 게 아니다.
|
|
602
|
+
// 그걸 "저장·게시를 안 보냈다" 로 알리면 카드를 열었을 뿐인데 무엇을 보내려 한 줄 안다(10/14 실험실 p6, POST /api/bluecards/user-read).
|
|
603
|
+
// 기록에는 남긴다
|
|
604
|
+
if (this.notSent.length > notSentBefore) {
|
|
605
|
+
if (page.url() !== before) record.notSentQuiet = this.notSent.slice(notSentBefore);
|
|
606
|
+
else record.notSent = this.notSent.slice(notSentBefore);
|
|
607
|
+
}
|
|
608
|
+
const ext = [...new Set(this.external.slice(externalBefore))].filter((h) => !this.externalTold.has(h));
|
|
609
|
+
if (ext.length) {
|
|
610
|
+
ext.forEach((h) => this.externalTold.add(h));
|
|
611
|
+
record.external = ext;
|
|
612
|
+
}
|
|
613
|
+
if (this.fileWindow) record.fileWindow = fileWindowNote(this.fileWindow);
|
|
614
|
+
await this.noteDiag(record, decision, diagEl);
|
|
615
|
+
|
|
616
|
+
this.steps.push(record);
|
|
617
|
+
this.history.push(
|
|
618
|
+
`${decision.action}${times > 1 ? ` ×${times}` : ""}${item ? ` "${item.label}"` : ""} — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}${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)" : ""}`,
|
|
619
|
+
);
|
|
620
|
+
this.log(`${record.step}. ${decision.action}${item ? ` "${String(item.label).slice(0, 30)}"` : ""}${record.skipped ? ` · ${record.skipped}` : ""} — ${decision.why}`);
|
|
621
|
+
this.lastView = null;
|
|
622
|
+
return record;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* 이 걸음에서 도구가 하지 못한 것을 진단에 남긴다. 글자는 넣지 않는다 — 종류와 요소의 생김새만
|
|
627
|
+
* 같은 것을 같은 자리에서 바로 또 눌렀다(화면이 안 바뀌었다)도 남긴다 — 사이트 탓일 수도, 우리가 못 본 것일 수도 있다
|
|
628
|
+
*/
|
|
629
|
+
async noteDiag(record, decision, el) {
|
|
630
|
+
const kind = diag.kindOf(record.skipped);
|
|
631
|
+
const action = decision.action;
|
|
632
|
+
if (kind) this.diag.push({ kind, action, shape: await diag.shapeOf(el) });
|
|
633
|
+
if (record.swiped) this.diag.push({ kind: "swiped", action, shape: await diag.shapeOf(el) });
|
|
634
|
+
if (record.closedOverlay) this.diag.push({ kind: "popup_backdrop", action });
|
|
635
|
+
if (record.missed) this.diag.push({ kind: "missed", action });
|
|
636
|
+
const prev = this.steps[this.steps.length - 1];
|
|
637
|
+
if (action === "click" && !record.skipped && prev && prev.action === "click" && prev.label === record.label && prev.path === record.path && !prev.skipped)
|
|
638
|
+
this.diag.push({ kind: "reclick_same", action, shape: await diag.shapeOf(el) });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** 카드 판단에 넘길 "이번 방문에 한 일" */
|
|
642
|
+
pathText() {
|
|
643
|
+
return this.history.length ? this.history.slice(-6).join(" / ") : "just arrived";
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
result() {
|
|
647
|
+
return { steps: this.steps, card: this.card, shape: this.shape };
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 우리가 AI 에 물어 가며 한 방문을 끝까지 돈다(자동 실행).
|
|
653
|
+
* @param opts.ask async (systemPrompt, jsonSchema, message) => object
|
|
654
|
+
* @returns { steps: [...], card: {...}|null, shape }
|
|
655
|
+
*/
|
|
656
|
+
async function runVisit(page, person, { siteAbout, ask, rhythm = null, shape = null, maxStepsCap = 40, allowAccounts = false, log = () => {} } = {}) {
|
|
657
|
+
/**
|
|
658
|
+
* 몇 쪽이나 볼지는 그 사람이 정한다. 방문 모양(shape)은 **인내심 표시**로만 쓰고 걸음 수로 자르지 않는다.
|
|
659
|
+
* 전에는 모양에서 걸음 상한을 뽑아(튕김이면 3걸음) 잘랐다 — 9/21 MCP 비교에서 8명 중 3명이
|
|
660
|
+
* "더 내려 볼게요" 하는 중에 잘려 떠나지도 못한 채 끝났다. MCP 길(고객 Claude)은 표시만 받으니 두 길의 규칙이 달랐다.
|
|
661
|
+
* 이제 둘 다 같다: 인내심 표시(`patienceSpent`) + 절대 상한(maxStepsCap, HARD_STEP_CAP).
|
|
662
|
+
*/
|
|
663
|
+
const maxSteps = Math.min(maxStepsCap, HARD_STEP_CAP);
|
|
664
|
+
const v = new Visit(page, person, { rhythm, shape, allowAccounts, log });
|
|
665
|
+
await v.begin();
|
|
666
|
+
|
|
667
|
+
const decideCard = async (shown) => {
|
|
668
|
+
const screen = await screenText(page);
|
|
669
|
+
return ask(answer.systemPrompt(), answer.DECISION_SCHEMA, answer.buildMessage(person, siteAbout, v.pathText(), screen, shown));
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
for (let step = 1; step <= maxSteps && !v.ended; step++) {
|
|
673
|
+
const look = await v.look();
|
|
674
|
+
if (look.card) {
|
|
675
|
+
await v.answer(await decideCard(look.card));
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
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.");
|
|
679
|
+
const counts = {};
|
|
680
|
+
for (const s of v.steps) counts[s.path] = (counts[s.path] || 0) + 1;
|
|
681
|
+
const visited = Object.entries(counts).map(([path, n]) => ({ path, n }));
|
|
682
|
+
const decision = await ask(stepPrompt(), STEP_SCHEMA, buildStepMessage(person, siteAbout, look.view, v.history, look.text, visited));
|
|
683
|
+
await v.act(decision);
|
|
684
|
+
}
|
|
685
|
+
// 떠나는 순간 뜬 카드
|
|
686
|
+
if (v.pending) {
|
|
687
|
+
const c = await v.answer(await decideCard(v.pending));
|
|
688
|
+
log(`Card (on leaving): ${c.result}${c.answer ? ` — ${c.answer}` : ""}`);
|
|
689
|
+
}
|
|
690
|
+
return v.result();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
module.exports = { STEP_SCHEMA, STEP_PROMPT_FILE, HARD_STEP_CAP, stepPrompt, buildStepMessage, renderScreen, renderPerson, Visit, runVisit, settle, watchNet };
|