@usercase/mcp 0.2.11 → 0.2.12-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/agents/condition.md +1 -1
- package/agents/fix.md +1 -1
- package/agents/new-research.md +5 -1
- package/agents/path.md +1 -1
- package/agents/secrets.md +8 -0
- package/agents/steps.md +1 -1
- package/agents/study.md +1 -1
- package/mcp/hub.js +267 -26
- package/mcp/path.js +1 -1
- package/mcp/report.js +80 -2
- package/mcp/server.js +79 -33
- package/package.json +2 -1
- package/src/browser/guard.js +48 -11
- package/src/browser/page.js +26 -1
- package/src/run/visit.js +30 -11
package/agents/condition.md
CHANGED
|
@@ -2,7 +2,7 @@ A condition has a `page` (the screen the visitor is on when the card appears), `
|
|
|
2
2
|
|
|
3
3
|
Step kinds (`do`): `arrive` · `back` · `click` · `input` · `submit` · `scroll` · `idle` · `hide` (tab hidden) · `error` (a JavaScript error).
|
|
4
4
|
Options on a step: `page`, `from` (arrive/back), `target` (click/input/submit), `atLeast`, `percent` (scroll), `seconds` (idle: a pause between two actions), `cleared` (input: typed then deleted), `absent` (this did not happen between the previous step and the moment).
|
|
5
|
-
Moment: `on_match` (as soon as the steps are met) · `idle` with `seconds` (then that long with no click, typing or page change; scrolling and reading still count as a pause) · `leave` (
|
|
5
|
+
Moment: `on_match` (as soon as the steps are met) · `idle` with `seconds` (then that long with no click, typing or page change; scrolling and reading still count as a pause) · `leave` (when they leave that screen: moving to another page, closing the tab on a computer, or coming back from another tab; phones cannot tell when a tab closes). When the card shows on the next screen it carries the line "About the previous page", so a leave question should not say "this page" — name what they were looking at. With an absent click step, moving to another page never matches, because moving is a click.
|
|
6
6
|
|
|
7
7
|
Globs: `*` matches anything including `/`; `:name` matches one path segment (`/project/:slug`). Use the route parameter names from the code. Never put `?` or `#` in a glob.
|
|
8
8
|
|
package/agents/fix.md
CHANGED
|
@@ -8,7 +8,7 @@ A Usercase research watched people (or virtual users) do tasks on this site. Its
|
|
|
8
8
|
|
|
9
9
|
## 1. Read the finding
|
|
10
10
|
|
|
11
|
-
Call `report_finding` with the research id, the finding key and the report. If no finding key is given above, call `report_findings` first, show the user the list (key, severity, title, and whether someone is already fixing it), and ask which one to fix.
|
|
11
|
+
Call `report_finding` with the research (its id, or the name the user gave — if the server lists several or none, ask the user which one), the finding key and the report. If no finding key is given above, call `report_findings` first, show the user the list (key, severity, title, and whether someone is already fixing it), and ask which one to fix.
|
|
12
12
|
|
|
13
13
|
Read all of it: the title and why, who it hit, the screen checks (what the page really showed — these come from the recordings, not from opinions), the paths people took, the groups of what they wrote, and the suggested changes. The suggested changes were written without seeing the code; treat them as a direction, not a spec.
|
|
14
14
|
|
package/agents/new-research.md
CHANGED
|
@@ -40,6 +40,10 @@ Let the user pick one or change it.
|
|
|
40
40
|
|
|
41
41
|
One research is **one flow a participant can walk in one sitting**: 1–4 tasks, about 5–10 minutes in all.
|
|
42
42
|
|
|
43
|
+
**Purpose and hypotheses**
|
|
44
|
+
- `purpose` is read by participants on the invite card and at the start. One or two sentences in their words on what they will help with. It never carries the evidence, the problem you suspect, or anything that hints at an answer.
|
|
45
|
+
- `hypotheses` are for the team only. Write what you expect to go wrong, one per item, usually 1–3, each grounded in the evidence. Each must be something the tasks can confirm or rule out; if no task would show it, change the tasks or drop it. The report judges each one, and they lock once the first real person takes part.
|
|
46
|
+
|
|
43
47
|
**Tasks**
|
|
44
48
|
- The title is a goal in the participant's words: what they want to get done, not how. Never name a button, a menu, a page or a route — that hands them the answer.
|
|
45
49
|
- Every task has `info_text`: one or two sentences, in the participant's words, on the situation they are in and why they would do this. It follows on from the previous task. Like the title, it never names a button, a menu, a page or a route.
|
|
@@ -75,7 +79,7 @@ The results tell who went straight from who took a detour only when a task has a
|
|
|
75
79
|
|
|
76
80
|
## 7. Show the user everything at once
|
|
77
81
|
|
|
78
|
-
Show the whole research compactly: the purpose
|
|
82
|
+
Show the whole research compactly: the evidence, the hypotheses, the purpose participants will read, each task with how it counts as done (in plain words, no XPath) and its route in one line, the questions, the invite, and the minutes. Say that on their approval you will walk each route in a browser and save the draft with the routes. Change whatever they ask for.
|
|
79
83
|
|
|
80
84
|
## 8. On approval: record the routes, then save
|
|
81
85
|
|
package/agents/path.md
CHANGED
|
@@ -9,7 +9,7 @@ You record that path by walking it yourself in a browser. Unlike the virtual vis
|
|
|
9
9
|
|
|
10
10
|
## 1. See the tasks
|
|
11
11
|
|
|
12
|
-
Call `path_tasks` with the research id. For each task you get what the participant is told, how it counts as done, and whether a path exists. If a task is named above, record only that one, even if it already has a path. Otherwise record paths for the tasks that have none, and if every task already has one, ask the user which to redo.
|
|
12
|
+
Call `path_tasks` with the research — its id, or the name the user gave (the server finds it by name; if it lists several or none, ask the user which one). For each task you get what the participant is told, how it counts as done, and whether a path exists. If a task is named above, record only that one, even if it already has a path. Otherwise record paths for the tasks that have none, and if every task already has one, ask the user which to redo.
|
|
13
13
|
|
|
14
14
|
## 2. Work out the intended route — before opening the browser
|
|
15
15
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
## What you send to Usercase
|
|
2
|
+
|
|
3
|
+
Everything you put in a Usercase tool's arguments is sent to the Usercase server and shown in the user's dashboard. You read this repository's code; Usercase gets only what you write into those arguments.
|
|
4
|
+
|
|
5
|
+
- Never put a secret into a Usercase tool argument: no API keys, tokens, passwords, private keys, connection strings, cookies or session values, and no values from `.env` files, config files holding credentials, or environment variables. This holds even when the user asks for it.
|
|
6
|
+
- Do not open `.env` files or other files that hold credentials for Usercase work. You never need them: describe what the site does, not how it is configured.
|
|
7
|
+
- Do not paste source code into arguments. Name the file, component or route and say in plain words what it does or what you changed.
|
|
8
|
+
- If something you were about to send turns out to contain a secret, leave that part out and carry on.
|
package/agents/steps.md
CHANGED
|
@@ -31,7 +31,7 @@ Use their word for the card: a question when it is a question, an invite when it
|
|
|
31
31
|
Make sure the moment fits the card:
|
|
32
32
|
|
|
33
33
|
- **A question** appears when the visitor can answer it, right where the thing it asks about happened.
|
|
34
|
-
- **A research invite** asks for a few minutes of someone's time. It should reach the people the research is about — visitors who just went through the part of the site it covers — at a moment when they are free to say yes: right after they finished the action the research is about (`on_match` on that step), or as they
|
|
34
|
+
- **A research invite** asks for a few minutes of someone's time. It should reach the people the research is about — visitors who just went through the part of the site it covers — at a moment when they are free to say yes: right after they finished the action the research is about (`on_match` on that step), or as they leave that screen (`leave`). Do not wait for a long pause after the action; people keep scrolling and moving on, so such an invite rarely appears. Never in the middle of typing, paying or signing in.
|
|
35
35
|
|
|
36
36
|
When a research invite fires at the same moment as a live question, that is expected, not a conflict. A visitor sees only one card at a time and the question comes first; anyone who answers it is offered the research right after, in the same card, and the invite card reaches the visitors the question no longer reaches. Mention the overlap as information only. Never suggest turning a question off to make room for an invite.
|
|
37
37
|
|
package/agents/study.md
CHANGED
|
@@ -15,7 +15,7 @@ Show the user the tasks in one short list before casting, so they know what the
|
|
|
15
15
|
**Step 3 — cast participants for this research.** Each person is someone who really comes to this site and who would accept this research's invitation — people its purpose is about. Their `goal` is still their own reason for being on the site, in their own words; **do not write the tasks into the goal or anywhere else**. The visitors read the tasks themselves in the study panel, as real participants do — a visitor told the task by you knows more than a participant would, and the research loses its value.
|
|
16
16
|
- Vary them the way real participants vary: devices, how familiar they are with the site, patience, how carefully they read. Some will finish every task, some will get stuck — that is what the research is for.
|
|
17
17
|
- Give each person a `landing` page where the research's invitation would meet them: the page the first task starts from, if the tasks or the code make it clear; otherwise where they would arrive on their own.
|
|
18
|
-
- Pass `research
|
|
18
|
+
- Pass `research` to `save_people` as the research **id** `path_tasks` showed (not a name). Only people saved with it take part.
|
|
19
19
|
- Leave out `feature` spreading over the whole feature map — the research decides what the visit is about.
|
|
20
20
|
|
|
21
21
|
**Step 4 — unchanged.** Each visitor accepts the invitation and agrees to the recording automatically (they are participants who said yes), then does the tasks from the study panel. Do not tell the visitors anything about the research.
|
package/mcp/hub.js
CHANGED
|
@@ -14,8 +14,9 @@ const persona = require("../src/persona/persona.js");
|
|
|
14
14
|
const { personKey } = require("../src/persona/people.js");
|
|
15
15
|
const entry = require("../src/run/entry.js");
|
|
16
16
|
const { Visit, renderScreen, renderPerson } = require("../src/run/visit.js");
|
|
17
|
-
const { iconPicture } = require("../src/browser/page.js");
|
|
18
|
-
const
|
|
17
|
+
const { iconPicture, screenPicture } = require("../src/browser/page.js");
|
|
18
|
+
const crypto = require("crypto");
|
|
19
|
+
const { createGuard, isSameSite } = require("../src/browser/guard.js");
|
|
19
20
|
const policyMod = require("../src/browser/policy.js");
|
|
20
21
|
const { loadRobots } = require("../src/browser/robots.js");
|
|
21
22
|
const { launchBrowser, openVisit } = require("../src/browser/open.js");
|
|
@@ -63,6 +64,59 @@ const AGENT = "qa-explore";
|
|
|
63
64
|
const MAX_OPEN = 5;
|
|
64
65
|
/** 서브에이전트가 죽으면 방문이 열린 채 남는다. 이만큼 조용하면 닫는다 */
|
|
65
66
|
const IDLE_MS = 10 * 60 * 1000;
|
|
67
|
+
// 페이지를 새로 불러온 뒤 SDK 를 기다리는 시간. 스니펫은 load 뒤에 SDK 를 부른다
|
|
68
|
+
const SDK_PAGE_WAIT_MS = 8000;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 이 페이지에 Usercase SDK 가 있나 — 페이지마다 본다. waitForSdk(card.js)의 sessionStorage 는 같은 사이트의
|
|
72
|
+
* 앞 페이지에서 남아 있어 SDK 없는 페이지에서도 "있다" 가 된다. 스니펫이 세우는 표시(UsercaseInitialized,
|
|
73
|
+
* 이름 바꾸기 전 EarningbirdsInitialized)나 SDK 가 띄운 패널을 본다
|
|
74
|
+
*/
|
|
75
|
+
// 새 페이지에서 리서치 패널이 다시 뜨기를 기다리는 시간(같은 세션으로 다시 붙는 데 보통 1~2초)
|
|
76
|
+
const STUDY_PANEL_WAIT_MS = 10000;
|
|
77
|
+
/** 리서치 패널이 진행 중 상태로 뜨나 */
|
|
78
|
+
async function waitStudyActive(page, ms) {
|
|
79
|
+
for (let t = 0; ; t += 400) {
|
|
80
|
+
const s = await study.readStudy(page).catch(() => null);
|
|
81
|
+
if (s && STUDY_ACTIVE.has(s.state)) return true;
|
|
82
|
+
if (t >= ms) return false;
|
|
83
|
+
await sleep(400);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function pageHasSdk(page, ms = SDK_PAGE_WAIT_MS) {
|
|
88
|
+
for (let t = 0; ; t += 300) {
|
|
89
|
+
const ok = await page
|
|
90
|
+
.evaluate(() => !!(window.UsercaseInitialized || window.EarningbirdsInitialized || document.getElementById("eb-plugin")))
|
|
91
|
+
.catch(() => null);
|
|
92
|
+
if (ok) return true;
|
|
93
|
+
if (t >= ms) return ok === null ? null : false;
|
|
94
|
+
await sleep(300);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** SDK 가 없던(missing) · 있는데 서버가 막은(not_connected) 페이지 주소를 방문에 남긴다. 쿼리 · 해시는 뺀다 */
|
|
98
|
+
function noSdkPage(rec, kind) {
|
|
99
|
+
let url;
|
|
100
|
+
try {
|
|
101
|
+
const u = new URL(rec.page.url());
|
|
102
|
+
if (!/^https?:$/.test(u.protocol)) return;
|
|
103
|
+
url = u.origin + u.pathname;
|
|
104
|
+
} catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
rec.noSdkPages = rec.noSdkPages || [];
|
|
108
|
+
if (rec.noSdkPages.length < 20 && !rec.noSdkPages.some((p) => p.url === url)) rec.noSdkPages.push({ url, kind });
|
|
109
|
+
}
|
|
110
|
+
// 진행 중인 방문 — 걸음마다 디스크에 적는다. MCP 가 강제로 꺼져도(세션 종료) 다음에 켜질 때 끝나지 않은 방문으로 올린다
|
|
111
|
+
const OPEN_DIR = require("path").join(ws.DIR, "open-visits");
|
|
112
|
+
const pidAlive = (pid) => {
|
|
113
|
+
try {
|
|
114
|
+
process.kill(pid, 0);
|
|
115
|
+
return true;
|
|
116
|
+
} catch (e) {
|
|
117
|
+
return e.code === "EPERM";
|
|
118
|
+
}
|
|
119
|
+
};
|
|
66
120
|
|
|
67
121
|
/**
|
|
68
122
|
* 쓰기를 허용할 경로(정규식, 쉼표로). 로그인 뒤 화면은 조회도 POST 로 하는 사이트가 많다(GraphQL 등).
|
|
@@ -104,6 +158,52 @@ class Hub {
|
|
|
104
158
|
// 시작할 때 비밀값을 한 번 검사한다 — 잘못된 값이 방문 도중 조용히 넘어가지 않게
|
|
105
159
|
this.keySet = !!agentKey();
|
|
106
160
|
setInterval(() => this.sweep(), 60 * 1000).unref();
|
|
161
|
+
this.recoverOpen();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
journalPath(rec) {
|
|
165
|
+
return require("path").join(OPEN_DIR, `${process.pid}-${rec.startedAt}-${rec.id}.json`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 이 방문이 지금까지 한 것을 디스크에 적는다. 끝난 방문이면 지운다 */
|
|
169
|
+
journal(id) {
|
|
170
|
+
const rec = this.visits.get(id);
|
|
171
|
+
if (!rec) return;
|
|
172
|
+
try {
|
|
173
|
+
const file = this.journalPath(rec);
|
|
174
|
+
if (rec.closed) {
|
|
175
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
fs.mkdirSync(OPEN_DIR, { recursive: true });
|
|
179
|
+
fs.writeFileSync(file, JSON.stringify({ pid: process.pid, row: this.visitRow(rec, { abandoned: true }), person: rec.person, startedAt: rec.startedAt }));
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 지난번 MCP 가 끝내지 못한 방문(그 프로세스가 이미 없다) — 로컬 기록과 outbox 에 넣는다. 다음 방문이 끝날 때 대시보드로 간다 */
|
|
184
|
+
recoverOpen() {
|
|
185
|
+
let n = 0;
|
|
186
|
+
try {
|
|
187
|
+
if (!fs.existsSync(OPEN_DIR)) return;
|
|
188
|
+
for (const name of fs.readdirSync(OPEN_DIR)) {
|
|
189
|
+
const file = require("path").join(OPEN_DIR, name);
|
|
190
|
+
try {
|
|
191
|
+
const j = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
192
|
+
if (j.pid && j.pid !== process.pid && pidAlive(j.pid)) continue;
|
|
193
|
+
ws.appendRun(j.row);
|
|
194
|
+
report.queueVisit(j.row, j.person, j.startedAt);
|
|
195
|
+
n++;
|
|
196
|
+
} catch {}
|
|
197
|
+
try {
|
|
198
|
+
fs.unlinkSync(file);
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
if (n) {
|
|
203
|
+
process.stderr.write(`Recovered ${n} visit(s) the last session did not finish — kept in the outbox for the dashboard
|
|
204
|
+
`);
|
|
205
|
+
setTimeout(() => report.flushOutbox().catch(() => {}), 0);
|
|
206
|
+
}
|
|
107
207
|
}
|
|
108
208
|
|
|
109
209
|
browser() {
|
|
@@ -132,7 +232,7 @@ class Hub {
|
|
|
132
232
|
arm(s) {
|
|
133
233
|
s.policy = policyNow();
|
|
134
234
|
// robots 는 나중에 읽는다(네트워크). 함수로 넘겨 읽히는 대로 반영되게
|
|
135
|
-
s.guard = createGuard({ site: s.origin, inject: this.inject, allowWritePaths: ALLOW_WRITE_PATHS, policy: s.policy, robots: () => s.robots });
|
|
235
|
+
s.guard = createGuard({ site: s.origin, inject: this.inject, allowWritePaths: ALLOW_WRITE_PATHS, policy: s.policy, robots: () => s.robots, robotsFor: (o) => this.robotsFor(s, o) });
|
|
136
236
|
}
|
|
137
237
|
|
|
138
238
|
/** 사이트에 쓰는 것을 보낼지. 사용자가 고른다 */
|
|
@@ -158,6 +258,23 @@ class Hub {
|
|
|
158
258
|
return s.robots;
|
|
159
259
|
}
|
|
160
260
|
|
|
261
|
+
/** 하위 도메인의 robots.txt — 처음 갈 때 한 번 읽는다. 사이트 것과 같은 머리로(자기를 밝힌다) */
|
|
262
|
+
robotsFor(s, origin) {
|
|
263
|
+
if (!s.subRobots) s.subRobots = new Map();
|
|
264
|
+
if (!s.subRobots.has(origin)) {
|
|
265
|
+
const key = agentKey();
|
|
266
|
+
const headers = { [AGENT_HEADER]: AGENT, "User-Agent": uaWith(AGENT, "Mozilla/5.0 (compatible)"), ...(key ? { [KEY_HEADER]: key } : {}) };
|
|
267
|
+
s.subRobots.set(
|
|
268
|
+
origin,
|
|
269
|
+
loadRobots(origin, { headers }).then((rb) => {
|
|
270
|
+
process.stderr.write(`${origin} ${rb.describe()}\n`);
|
|
271
|
+
return rb;
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return s.subRobots.get(origin);
|
|
276
|
+
}
|
|
277
|
+
|
|
161
278
|
async context(s, opts = {}) {
|
|
162
279
|
return openVisit(await this.browser(), playwright.devices, { agent: AGENT, site: s.origin, guard: s.guard, ...opts });
|
|
163
280
|
}
|
|
@@ -290,6 +407,9 @@ class Hub {
|
|
|
290
407
|
|
|
291
408
|
/** 가상 사용자 한 명의 방문을 연다. 그 사람이 누구인지와 첫 화면을 돌려준다 */
|
|
292
409
|
async open(who) {
|
|
410
|
+
// 방문 기록이 요금제로 막혔다 — 브라우저를 더 띄우지 않는다(report.js)
|
|
411
|
+
const plan = report.planBlocked("visits");
|
|
412
|
+
if (plan) return { stop: true, text: report.planStopText(plan) };
|
|
293
413
|
const doc = ws.loadPeople();
|
|
294
414
|
if (!doc.site) throw new Error("people.json has no site. Create people with save_people first.");
|
|
295
415
|
const p = doc.people.find((x) => x.id === who);
|
|
@@ -368,10 +488,8 @@ class Hub {
|
|
|
368
488
|
}
|
|
369
489
|
return;
|
|
370
490
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
same = new URL(u).origin === s.origin;
|
|
374
|
-
} catch {}
|
|
491
|
+
// 같은 사이트 — www · 하위 도메인까지(guard 와 같은 기준). 전에는 origin 이 똑같아야 해서 사이트 주소가 bluetags.app 이면 www 새 탭도 "다른 사이트" 였다
|
|
492
|
+
const same = isSameSite(s.origin, u);
|
|
375
493
|
if (!same) return void offsite.push(u);
|
|
376
494
|
// 같은 사이트를 새 탭으로 여는 링크. 사람은 열린 탭에서 이어 본다 — 닫기만 하면 "눌러도 안 열린다" 가 된다
|
|
377
495
|
// (10/14 0.1.9 bluetags 프로젝트 카드 "See more" 가 target=_blank). 탭을 하나로 두려고 보던 탭을 그 주소로 옮긴다
|
|
@@ -408,8 +526,10 @@ class Hub {
|
|
|
408
526
|
}
|
|
409
527
|
const how = entry.chooseEntry(landingOff ? { ...p, landing: undefined } : p, links);
|
|
410
528
|
if (landingOff) process.stderr.write(`${p.id}: landing ${p.landing} is off limits (${landingOff}) — entering elsewhere\n`);
|
|
411
|
-
const rec = { id, person: p, site: s, ctx, page, how, startedAt: Date.now(), touched: Date.now(), closed: false, visit: null };
|
|
529
|
+
const rec = { id, person: p, site: s, ctx, page, how, startedAt: Date.now(), touched: Date.now(), closed: false, visit: null, loads: 0, noSdk: [] };
|
|
412
530
|
this.visits.set(id, rec);
|
|
531
|
+
// 페이지를 새로 불러올 때마다 센다 — 그 뒤에 SDK 가 그 페이지에도 있는지 본다(sdkGone)
|
|
532
|
+
page.on("load", () => rec.loads++);
|
|
413
533
|
|
|
414
534
|
await page.goto(s.origin + how.path, { waitUntil: "domcontentloaded", referer: how.referer || undefined, timeout: 60000 }).catch(() => {});
|
|
415
535
|
// 화면이 보여도 사이트 코드가 아직 안 붙었으면 버튼이 안 먹는다. 바로 상세·달력으로 들어오면 첫 클릭이 그렇게 사라졌다
|
|
@@ -442,6 +562,7 @@ class Hub {
|
|
|
442
562
|
}
|
|
443
563
|
rec.account = account ? account.account : null;
|
|
444
564
|
rec.visit = new Visit(page, p, {
|
|
565
|
+
siteOrigin: s.origin,
|
|
445
566
|
rhythm,
|
|
446
567
|
shape,
|
|
447
568
|
policy: s.policy,
|
|
@@ -495,8 +616,14 @@ class Hub {
|
|
|
495
616
|
lines.push(
|
|
496
617
|
"This study has a voice: you allowed your microphone, and a voice may talk to you during the study (you see its words as a caption). When it asks you something, you answer out loud — with answer_card.",
|
|
497
618
|
);
|
|
498
|
-
if (!sdk.ready)
|
|
619
|
+
if (!sdk.ready) {
|
|
620
|
+
lines.push("(note for whoever runs this: the Usercase SDK did not start on this page — this visit may not be recorded)");
|
|
621
|
+
rec.noSdk.push(pageName(rec));
|
|
622
|
+
const has = await pageHasSdk(page, 0);
|
|
623
|
+
if (has !== null) noSdkPage(rec, has ? "not_connected" : "missing");
|
|
624
|
+
}
|
|
499
625
|
lines.push("", await this.screen(rec));
|
|
626
|
+
this.journal(id);
|
|
500
627
|
return { id, text: lines.join("\n") };
|
|
501
628
|
}
|
|
502
629
|
|
|
@@ -518,7 +645,7 @@ class Hub {
|
|
|
518
645
|
// 사람은 막히면 계속 찾아본다. 그러다 실패 시간이 지나면 SDK 가 묻는 창을 띄우고, 다음 화면에서 그 창이 먼저 보인다
|
|
519
646
|
// 브라우저 창(confirm 등)이 떠 있다. 페이지는 멈춰 있다 — 아이콘 그림·위치는 안 본다
|
|
520
647
|
if (look.view.dialog) {
|
|
521
|
-
this.pictures.set(rec.id,
|
|
648
|
+
this.pictures.set(rec.id, []);
|
|
522
649
|
return renderScreen(look);
|
|
523
650
|
}
|
|
524
651
|
const lines = [];
|
|
@@ -537,8 +664,23 @@ class Hub {
|
|
|
537
664
|
lines.push(renderScreen(look));
|
|
538
665
|
if (st && st.panel) lines.push("", st.panel);
|
|
539
666
|
// 이름 없는 아이콘은 그림으로 같이 준다 — 좌표만으로는 사진 첨부와 보내기를 못 갈랐다(10/21 bluetags 2b72)
|
|
540
|
-
const
|
|
541
|
-
|
|
667
|
+
const browser = await this.browser().catch(() => null);
|
|
668
|
+
const pics = [];
|
|
669
|
+
// 화면 전체 그림. 글자 목록만으로는 사진·색·배치를 못 봐 "그림이 없다"·"색이 안 적혀 있다" 를 지어내고
|
|
670
|
+
// 생김새로 고르는 사람은 포기했다. 그림을 주니 사람처럼 골랐고 깨진 상품 이미지도 찾았다 — 걸음당 비용 +20%쯤(11/11 bluetags·데모 쇼핑몰 48방문).
|
|
671
|
+
// 지난번에 준 그림과 똑같으면 다시 안 준다(눌렀는데 그대로인 화면). 걸음마다 쌓여 다시 읽히는 몫이 크다
|
|
672
|
+
const shot = await screenPicture(v.page, browser).catch(() => null);
|
|
673
|
+
const sum = shot && crypto.createHash("sha1").update(shot).digest("hex");
|
|
674
|
+
if (shot && sum !== rec.lastShot) {
|
|
675
|
+
rec.lastShot = sum;
|
|
676
|
+
pics.push({ data: shot, mime: "image/jpeg" });
|
|
677
|
+
lines.push("(A small picture of the screen as you see it is attached. Use it to see the images, the layout and what stands out — the list above is still how you click.)");
|
|
678
|
+
} else if (shot) {
|
|
679
|
+
lines.push("(The screen looks the same as in your last picture.)");
|
|
680
|
+
}
|
|
681
|
+
const pic = await iconPicture(v.page, look.view.items, browser).catch(() => null);
|
|
682
|
+
if (pic) pics.push({ data: pic, mime: "image/png" });
|
|
683
|
+
this.pictures.set(rec.id, pics);
|
|
542
684
|
if (pic) lines.push("(A picture of the unlabelled icons on this screen is attached — each is marked with its number from the list above. Look at their shapes the way a person would.)");
|
|
543
685
|
// 인내심이 다 됐다는 단정이 아니라 조건이다. "볼 만큼 봤다" 고 하면 방문자가 그 자리에서 나갔다 — 진전이 있으면 이어 간다(10/14)
|
|
544
686
|
// 리서치 참여자는 과제를 하러 왔다 — 둘러보다 지치는 인내심 표시는 맞지 않는다
|
|
@@ -755,10 +897,10 @@ class Hub {
|
|
|
755
897
|
}
|
|
756
898
|
|
|
757
899
|
/** 마지막 화면에 붙일 아이콘 그림. 한 번 가져가면 지운다(다음 화면은 새로 만든다) */
|
|
758
|
-
|
|
759
|
-
const
|
|
900
|
+
takePictures(id) {
|
|
901
|
+
const pics = this.pictures.get(id) || [];
|
|
760
902
|
this.pictures.delete(id);
|
|
761
|
-
return
|
|
903
|
+
return pics;
|
|
762
904
|
}
|
|
763
905
|
|
|
764
906
|
get(id) {
|
|
@@ -771,6 +913,14 @@ class Hub {
|
|
|
771
913
|
|
|
772
914
|
/** 한 걸음. 끝나면 기록하고 닫는다 */
|
|
773
915
|
async act(id, decision) {
|
|
916
|
+
try {
|
|
917
|
+
return await this.step(id, decision);
|
|
918
|
+
} finally {
|
|
919
|
+
this.journal(id);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async step(id, decision) {
|
|
774
924
|
const rec = this.get(id);
|
|
775
925
|
const v = rec.visit;
|
|
776
926
|
// 리서치 질문이 페이지를 덮고 있다 — 먼저 답해야 한다
|
|
@@ -790,7 +940,13 @@ class Hub {
|
|
|
790
940
|
rec.lastAction = decision.action;
|
|
791
941
|
const a0 = Date.now();
|
|
792
942
|
v.lastWaitMs = 0;
|
|
943
|
+
const loadsBefore = rec.loads;
|
|
793
944
|
const r = await v.act(decision);
|
|
945
|
+
// 새 페이지로 왔다 — 거기에도 SDK 가 있나. 리서치 참여 중인데 없으면 방문을 멈추고 알린다
|
|
946
|
+
if (rec.loads !== loadsBefore && !v.ended) {
|
|
947
|
+
const gone = await this.sdkGone(rec);
|
|
948
|
+
if (gone) return gone;
|
|
949
|
+
}
|
|
794
950
|
if (TRACE) trace(rec, { ev: "act", action: decision.action, study: r && r.study, actMs: Date.now() - a0, waitTurnMs: v.lastWaitMs || 0, sdk: await sdkSnap(rec.page) });
|
|
795
951
|
// 패널의 건너뛰기를 눌렀다 — 그 과제를 건너뛴 것으로 남긴다
|
|
796
952
|
if (r && r.study === "skip" && !r.skipped && rec.study) rec.study.skipped.push(rec.study.lastTask);
|
|
@@ -837,8 +993,49 @@ class Hub {
|
|
|
837
993
|
return [...said, ...(said.length ? [""] : []), shown].join("\n");
|
|
838
994
|
}
|
|
839
995
|
|
|
996
|
+
/**
|
|
997
|
+
* 새로 불러온 페이지에 SDK 가 없다 — 그 페이지를 적는다. 리서치에 참여 중이면 거기서는 녹화도 과제 패널도 없어
|
|
998
|
+
* 더 다녀도 쓸 데이터가 안 나온다 → 방문을 멈추고 코디네이터가 사용자에게 알리게 한다. 리서치가 아니면 적기만 한다
|
|
999
|
+
* (걸음은 MCP 가 기록한다). @returns 방문자에게 줄 글(멈췄을 때), 아니면 null
|
|
1000
|
+
*/
|
|
1001
|
+
async sdkGone(rec) {
|
|
1002
|
+
const url = rec.page.url();
|
|
1003
|
+
// 끝난 뒤 설문은 우리 설문 사이트 — SDK 가 없는 게 맞다. 시작 주소만이 아니라 그 사이트 전체를 뺀다
|
|
1004
|
+
if (!/^https?:/.test(url) || study.isSurveyUrl(url) || /^https?:\/\/survey\.(usercase\.io|localhost)(:\d+)?\//i.test(url)) return null;
|
|
1005
|
+
const has = await pageHasSdk(rec.page);
|
|
1006
|
+
if (has === null) return null; // 페이지를 못 읽었다(이동 중) — 모르면 멈추지 않는다
|
|
1007
|
+
const inStudy = !!rec.study && !rec.study.over;
|
|
1008
|
+
// SDK 는 있는데 리서치 패널이 안 뜬다 — 서버가 이 도메인을 거절했다(프로젝트 도메인 설정에 없는 하위 도메인, 4003).
|
|
1009
|
+
// 서버는 이유 없이 연결만 닫아서 페이지에서는 알 수 없다. 패널이 안 뜨면 studyLook 이 "과제가 다 끝났다" 로 잘못 읽었다(11/10 about.bluetags.app)
|
|
1010
|
+
const notConnected = has === true && inStudy && !(await waitStudyActive(rec.page, STUDY_PANEL_WAIT_MS));
|
|
1011
|
+
if (has === true && !notConnected) return null;
|
|
1012
|
+
const where = pageName(rec);
|
|
1013
|
+
const label = notConnected ? `${where} (the SDK is there but did not connect — is this domain allowed in the project's settings?)` : where;
|
|
1014
|
+
if (!rec.noSdk.includes(label)) rec.noSdk.push(label);
|
|
1015
|
+
noSdkPage(rec, notConnected ? "not_connected" : "missing");
|
|
1016
|
+
if (!inStudy) return null;
|
|
1017
|
+
const sum = await this.finish(rec, { stopped: notConnected ? "sdk_not_connected" : "no_sdk" });
|
|
1018
|
+
return [
|
|
1019
|
+
notConnected
|
|
1020
|
+
? `The Usercase SDK on this page (${where}) did not connect to the study — the study panel did not come back, so nothing is recorded here. This domain is probably not allowed in the project's settings. The visit was stopped.`
|
|
1021
|
+
: `The Usercase SDK is not on this page (${where}), so the study cannot go on here — nothing is recorded and the study panel is gone. The visit was stopped.`,
|
|
1022
|
+
"",
|
|
1023
|
+
sum,
|
|
1024
|
+
"",
|
|
1025
|
+
"→ Reply to the coordinator with exactly the first line above, and stop.",
|
|
1026
|
+
].join("\n");
|
|
1027
|
+
}
|
|
1028
|
+
|
|
840
1029
|
/** 카드에 답하거나 닫는다 */
|
|
841
1030
|
async answer(id, decision) {
|
|
1031
|
+
try {
|
|
1032
|
+
return await this.answerCard(id, decision);
|
|
1033
|
+
} finally {
|
|
1034
|
+
this.journal(id);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
async answerCard(id, decision) {
|
|
842
1039
|
const rec = this.get(id);
|
|
843
1040
|
if (rec.studyPending) return this.studyAnswer(rec, decision);
|
|
844
1041
|
const v = rec.visit;
|
|
@@ -854,10 +1051,8 @@ class Hub {
|
|
|
854
1051
|
return [done, "", await this.screen(rec)].join("\n");
|
|
855
1052
|
}
|
|
856
1053
|
|
|
857
|
-
/**
|
|
858
|
-
|
|
859
|
-
if (rec.closed) return "";
|
|
860
|
-
rec.closed = true;
|
|
1054
|
+
/** 방문 기록 한 줄(runs · 대시보드) */
|
|
1055
|
+
visitRow(rec, extra = {}) {
|
|
861
1056
|
const res = rec.visit ? rec.visit.result() : { steps: [], card: null, shape: null };
|
|
862
1057
|
const row = {
|
|
863
1058
|
at: new Date().toISOString(),
|
|
@@ -872,6 +1067,10 @@ class Hub {
|
|
|
872
1067
|
card: res.card,
|
|
873
1068
|
ms: Date.now() - rec.startedAt,
|
|
874
1069
|
sdkReady: rec.visit ? !!rec.visit.sdk?.ready : null,
|
|
1070
|
+
// SDK 가 없던 페이지 — 거기서는 녹화도 리서치 패널도 없다. results 가 사용자에게 알리게 한다
|
|
1071
|
+
...(rec.noSdk && rec.noSdk.length ? { noSdk: rec.noSdk } : {}),
|
|
1072
|
+
// 같은 것을 주소로 — 서버가 대시보드 Connection 체크리스트("스크립트 없음")에 쓴다
|
|
1073
|
+
...(rec.noSdkPages && rec.noSdkPages.length ? { noSdkPages: rec.noSdkPages } : {}),
|
|
875
1074
|
...(rec.account ? { account: rec.account } : {}),
|
|
876
1075
|
// 리서치 참여 — 어느 과제까지 갔고, 무엇을 건너뛰고, 과제 질문에 무엇을 답했나. 참여 기록 자체는 SDK 가 서버에 올린다
|
|
877
1076
|
...(rec.study
|
|
@@ -890,17 +1089,31 @@ class Hub {
|
|
|
890
1089
|
...(rec.visit && rec.visit.diag && rec.visit.diag.length ? { diag: rec.visit.diag.map(({ kind, action, shape, detail }) => ({ kind, action, shape, detail })) } : {}),
|
|
891
1090
|
...extra,
|
|
892
1091
|
};
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1092
|
+
return row;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** 사람 파일에 이 사람을 쓴 날을 적는다 */
|
|
1096
|
+
markUsed(rec, at) {
|
|
898
1097
|
const doc = ws.loadPeople();
|
|
899
1098
|
const p = doc.people.find((x) => x.id === rec.person.id);
|
|
900
1099
|
if (p) {
|
|
901
|
-
p.usedAt =
|
|
1100
|
+
p.usedAt = at;
|
|
902
1101
|
ws.savePeople(doc);
|
|
903
1102
|
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** 방문을 닫고 기록을 남긴다. 사람 파일에 쓴 날을 적는다 */
|
|
1106
|
+
async finish(rec, extra = {}) {
|
|
1107
|
+
if (rec.closed) return "";
|
|
1108
|
+
rec.closed = true;
|
|
1109
|
+
const row = this.visitRow(rec, extra);
|
|
1110
|
+
ws.appendRun(row);
|
|
1111
|
+
this.journal(rec.id);
|
|
1112
|
+
// 대시보드로 — 키가 없으면 건너뛴다. 실패해도 방문은 끝낸다
|
|
1113
|
+
const uploaded = await report.sendVisit(row, rec.person, rec.startedAt, rec.visit ? rec.visit.screens : []).catch((e) => `⚠ Could not upload to the dashboard (${e.message})`);
|
|
1114
|
+
// 도구가 하지 못한 것들 — 우리 쪽 진단으로(화면·HTML 포함, 고객 대시보드에는 안 보인다). 기본으로 켜져 있다, USERCASE_DIAGNOSTICS=off 로 끈다
|
|
1115
|
+
if (rec.visit && rec.visit.diag && rec.visit.diag.length) await report.sendDiagnostics(rec.visit.diag, rec.person.device, rec.visit.diagShots).catch(() => 0);
|
|
1116
|
+
this.markUsed(rec, row.at);
|
|
904
1117
|
await new Promise((r) => setTimeout(r, 1500));
|
|
905
1118
|
await rec.ctx.close().catch(() => {});
|
|
906
1119
|
return [summarize(row), uploaded].filter(Boolean).join("\n");
|
|
@@ -1091,8 +1304,24 @@ class Hub {
|
|
|
1091
1304
|
return lines.join("\n");
|
|
1092
1305
|
}
|
|
1093
1306
|
|
|
1307
|
+
/**
|
|
1308
|
+
* MCP 가 꺼진다(세션 끝) — 곧 프로세스가 끝날 수 있다. 끝나지 않은 방문을 하나씩 finish 하면(올리기 · 1.5초 · 창 닫기)
|
|
1309
|
+
* 첫 방문을 하는 사이에 끊겨 나머지는 기록조차 안 남았다(11/05 bluetags 74). 그래서 먼저 전부 디스크에 적고(runs · outbox),
|
|
1310
|
+
* 그다음 한꺼번에 보낸다. 못 보낸 것은 다음 방문이 끝날 때 간다
|
|
1311
|
+
*/
|
|
1094
1312
|
async close() {
|
|
1095
|
-
|
|
1313
|
+
const open = [...this.visits.values()].filter((r) => !r.closed);
|
|
1314
|
+
for (const rec of open) {
|
|
1315
|
+
try {
|
|
1316
|
+
rec.closed = true;
|
|
1317
|
+
const row = this.visitRow(rec, { abandoned: true });
|
|
1318
|
+
ws.appendRun(row);
|
|
1319
|
+
report.queueVisit(row, rec.person, rec.startedAt);
|
|
1320
|
+
this.journal(rec.id);
|
|
1321
|
+
this.markUsed(rec, row.at);
|
|
1322
|
+
} catch {}
|
|
1323
|
+
}
|
|
1324
|
+
if (open.length) await Promise.race([report.flushOutbox().catch(() => null), sleep(8000)]);
|
|
1096
1325
|
if (this.browserP) await (await this.browserP).close().catch(() => {});
|
|
1097
1326
|
if (this.loginBrowser) await this.loginBrowser.close().catch(() => {});
|
|
1098
1327
|
if (this.loginReal) await this.loginReal.close().catch(() => {});
|
|
@@ -1228,6 +1457,17 @@ function studyLine(s) {
|
|
|
1228
1457
|
}
|
|
1229
1458
|
|
|
1230
1459
|
/** 방문 한 줄 요약 — 메인 에이전트가 모아서 사용자에게 보여준다 */
|
|
1460
|
+
/** 지금 페이지 — 사이트 안이면 경로, 하위 도메인이면 호스트까지 */
|
|
1461
|
+
function pageName(rec) {
|
|
1462
|
+
try {
|
|
1463
|
+
const u = new URL(rec.page.url());
|
|
1464
|
+
const bare = (h) => h.replace(/^www\./, "");
|
|
1465
|
+
return bare(u.host) === bare(new URL(rec.site.origin).host) ? u.pathname || "/" : u.host + u.pathname;
|
|
1466
|
+
} catch {
|
|
1467
|
+
return rec.page.url();
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1231
1471
|
function summarize(row) {
|
|
1232
1472
|
const last = row.steps[row.steps.length - 1];
|
|
1233
1473
|
const card = row.card ? `Card "${row.card.question}" → ${row.card.result === "close" ? "closed" : row.card.answer}` : "No card";
|
|
@@ -1236,6 +1476,7 @@ function summarize(row) {
|
|
|
1236
1476
|
row.pages?.length ? `Pages: ${row.pages.join(" → ")}` : "",
|
|
1237
1477
|
card,
|
|
1238
1478
|
row.study ? studyLine(row.study) : "",
|
|
1479
|
+
row.noSdk && row.noSdk.length ? `⚠ Usercase SDK missing or not connected on: ${row.noSdk.join(", ")}${/^(no_sdk|sdk_not_connected)$/.test(row.stopped || "") ? " — the visit was stopped there" : " — not recorded there"}` : "",
|
|
1239
1480
|
last ? `Last: ${last.action} — ${last.why}` : row.blocked ? "Stopped at a block page" : "",
|
|
1240
1481
|
]
|
|
1241
1482
|
.filter(Boolean)
|
package/mcp/path.js
CHANGED
|
@@ -151,7 +151,7 @@ class PathRecorder {
|
|
|
151
151
|
const cond = (t) =>
|
|
152
152
|
t.condition === "url_entry" ? `done on reaching ${t.targetUrl || "(no page set)"}` : t.condition === "element_click" ? `done on clicking an element on ${t.xpathUrl || "?"}` : `done after ${t.timer}s`;
|
|
153
153
|
return [
|
|
154
|
-
`Research "${r.research.title}" (${r.research.status})${r.research.website ? ` — site ${r.research.website}` : ""}`,
|
|
154
|
+
`Research "${r.research.title}" (${r.research.status}) — id ${r.research.id}${r.research.website ? ` — site ${r.research.website}` : ""}`,
|
|
155
155
|
r.research.purpose ? `Purpose: ${r.research.purpose}` : "",
|
|
156
156
|
"",
|
|
157
157
|
...r.tasks.map((t) =>
|
package/mcp/report.js
CHANGED
|
@@ -40,6 +40,43 @@ const setClient = (name) => {
|
|
|
40
40
|
};
|
|
41
41
|
const toolName = () => client || (process.env.USERCASE_CLIENT === "codex" ? "codex" : "claude-code");
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* 요금제 한도 — 서버가 402 와 { code: "plan_limit", error, feature, upgradeUrl } 로 막는다.
|
|
45
|
+
* 401 은 키 문제(다시 연결)라 쓰지 않는다. 지금(11/10)은 다 무료라 아무것도 402 를 보내지 않지만,
|
|
46
|
+
* 고객 PC 의 MCP 는 오래 남으니 받는 쪽을 미리 둔다.
|
|
47
|
+
* 막힌 기능(feature, 없으면 "all")은 10분 동안 기억한다 — 그동안 새 방문을 열지 않는다. 결제하면 10분 뒤 다시 된다
|
|
48
|
+
*/
|
|
49
|
+
const PLAN_MEMORY_MS = 10 * 60 * 1000;
|
|
50
|
+
const planBlocks = new Map();
|
|
51
|
+
function planLimitError(status, json) {
|
|
52
|
+
const feature = (json && typeof json.feature === "string" && json.feature) || "all";
|
|
53
|
+
const info = {
|
|
54
|
+
feature,
|
|
55
|
+
message: (json && json.error) || "This needs a paid Usercase plan.",
|
|
56
|
+
upgradeUrl: (json && typeof json.upgradeUrl === "string" && json.upgradeUrl) || dashboardUrl("/"),
|
|
57
|
+
at: Date.now(),
|
|
58
|
+
};
|
|
59
|
+
planBlocks.set(feature, info);
|
|
60
|
+
return Object.assign(new Error(info.message), { status, planLimit: info });
|
|
61
|
+
}
|
|
62
|
+
/** 이 기능(또는 전체)이 최근에 요금제로 막혔으면 그 내용 */
|
|
63
|
+
function planBlocked(feature) {
|
|
64
|
+
for (const k of [feature, "all"]) {
|
|
65
|
+
const b = planBlocks.get(k);
|
|
66
|
+
if (b && Date.now() - b.at < PLAN_MEMORY_MS) return b;
|
|
67
|
+
if (b) planBlocks.delete(k);
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/** 에이전트에게 — 오류가 아니라 멈출 일이다. 우회 · 재시도 금지, 사용자에게 그대로 */
|
|
72
|
+
const planStopText = (b) =>
|
|
73
|
+
[
|
|
74
|
+
`STOP — Usercase plan limit: ${b.message}`,
|
|
75
|
+
`Upgrade: ${b.upgradeUrl}`,
|
|
76
|
+
"",
|
|
77
|
+
"This is not an error to work around. Do not retry, do not try other tools, other ids or a different approach to get around it, and do not run more visits. Tell the user the message and the link above in your own words, then stop the current command.",
|
|
78
|
+
].join("\n");
|
|
79
|
+
|
|
43
80
|
/** @param key 이 키로 부른다(연결 API). 없으면 지금 쓰는 키 */
|
|
44
81
|
async function call(method, route, body, timeoutMs = 15000, key) {
|
|
45
82
|
const cur = key === undefined ? conn.current() : null;
|
|
@@ -56,6 +93,7 @@ async function call(method, route, body, timeoutMs = 15000, key) {
|
|
|
56
93
|
json = JSON.parse(text);
|
|
57
94
|
} catch {}
|
|
58
95
|
if (!res.ok) {
|
|
96
|
+
if (res.status === 402 || (json && json.code === "plan_limit")) throw planLimitError(res.status, json);
|
|
59
97
|
// 키가 거절됐다 — 연결로 받은 키면 지우고, 직접 넣은 키면 이번 세션엔 안 쓴다. 다음 호출이 브라우저 연결로 간다
|
|
60
98
|
if (res.status === 401 && cur && cur.source !== "none") {
|
|
61
99
|
conn.rejected(cur.source);
|
|
@@ -104,6 +142,8 @@ async function describeKey(sdkProject, retried = false) {
|
|
|
104
142
|
const m = await getMe();
|
|
105
143
|
if (sdkProject && m.projectId !== sdkProject)
|
|
106
144
|
return `⚠ Connected to the "${m.title}" project, but the SDK on this site sends to another project (${sdkProject}). Visits and card answers would land in different projects. Call the \`connect\` tool to pick the right project`;
|
|
145
|
+
// 연결 전에 돈 방문이 남아 있으면 지금 올린다
|
|
146
|
+
if (readOutbox().length) await flushOutbox(m.projectId).catch(() => null);
|
|
107
147
|
return connected || `✓ Connected — visits are sent to the "${m.title}" project. To use another project, call the \`connect\` tool`;
|
|
108
148
|
} catch (e) {
|
|
109
149
|
// 키가 거절됐다 — call 이 지웠으니 한 번만 다시(브라우저 연결로)
|
|
@@ -197,11 +237,42 @@ async function sendScreens(item, screens) {
|
|
|
197
237
|
* @returns 요약에 붙일 한 줄, 키가 없으면 null
|
|
198
238
|
*/
|
|
199
239
|
async function sendVisit(row, person, startedAt, screens = []) {
|
|
200
|
-
|
|
240
|
+
// 이 폴더가 아직 Usercase 프로젝트에 연결되지 않았다 — 전에는 말없이 건너뛰어 대시보드에 없는 줄 몰랐다(11/10).
|
|
241
|
+
// 버리지 않고 outbox 에 둔다. check_site 로 연결하면 그때 올라간다(사진은 다시 안 보낸다)
|
|
242
|
+
if (!KEY_RE.test(apiKey())) {
|
|
243
|
+
writeOutbox([...readOutbox(), { ...payload(row, person, startedAt), _project: null }]);
|
|
244
|
+
return NOT_CONNECTED_LINE;
|
|
245
|
+
}
|
|
201
246
|
// 기록마다 어느 프로젝트로 가야 했는지 붙여 둔다 — 다시 연결하며 다른 프로젝트를 골라도 엉뚱한 곳에 들어가지 않게
|
|
202
247
|
const project = await getMe().then((m) => m.projectId).catch(() => null);
|
|
203
248
|
const fresh = { ...(await sendScreens(payload(row, person, startedAt), screens)), _project: project };
|
|
204
|
-
|
|
249
|
+
// 보내기 전에 outbox 에 먼저 적는다 — 보내는 도중 MCP 가 꺼져도 다음에 간다(서버는 같은 id 를 한 번만 받는다)
|
|
250
|
+
writeOutbox([...readOutbox(), fresh]);
|
|
251
|
+
return flushOutbox(project);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 방문 하나를 지금 바로 outbox 에 적는다(기다리지 않는다) — MCP 가 꺼지는 중이라 보낼 시간이 없을 수 있다.
|
|
256
|
+
* 프로젝트는 이미 알면 붙인다. 키가 없으면 false
|
|
257
|
+
*/
|
|
258
|
+
function queueVisit(row, person, startedAt) {
|
|
259
|
+
// 연결 전이어도 남긴다 — 연결하면 올라간다
|
|
260
|
+
writeOutbox([...readOutbox(), { ...payload(row, person, startedAt), _project: me ? me.projectId : null }]);
|
|
261
|
+
return KEY_RE.test(apiKey());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const NOT_CONNECTED_LINE = `⚠ Not sent to the dashboard — this folder is not connected to a Usercase project. The visit is kept in ${OUTBOX} and uploads once check_site connects this folder.`;
|
|
265
|
+
|
|
266
|
+
/** 이 폴더가 Usercase 프로젝트에 연결돼 있나(키가 있나) */
|
|
267
|
+
const connected = () => KEY_RE.test(apiKey());
|
|
268
|
+
/** 아직 못 올린 방문 수 */
|
|
269
|
+
const pendingCount = () => readOutbox().length;
|
|
270
|
+
|
|
271
|
+
/** outbox 를 보낸다. 다른 프로젝트 것은 남긴다. @returns 요약에 붙일 한 줄, 키가 없으면 null */
|
|
272
|
+
async function flushOutbox(project) {
|
|
273
|
+
if (!KEY_RE.test(apiKey())) return null;
|
|
274
|
+
if (project === undefined) project = await getMe().then((m) => m.projectId).catch(() => null);
|
|
275
|
+
const queue = readOutbox();
|
|
205
276
|
const left = [];
|
|
206
277
|
let sent = 0;
|
|
207
278
|
let other = 0;
|
|
@@ -225,6 +296,7 @@ async function sendVisit(row, person, startedAt, screens = []) {
|
|
|
225
296
|
writeOutbox(left);
|
|
226
297
|
const otherLine = other ? ` · ${other} from another project kept in ${OUTBOX} until you connect that project again` : "";
|
|
227
298
|
if (left.length === other) return `· Sent to the dashboard${sent > 1 ? ` (${sent} including earlier ones)` : ""}${otherLine}`;
|
|
299
|
+
if (lastErr && lastErr.planLimit) return `⛔ Not uploaded — ${planStopText(lastErr.planLimit)}\n(${left.length - other} kept in ${OUTBOX}; they upload once the plan allows it.)`;
|
|
228
300
|
return `⚠ Could not upload to the dashboard (${lastErr?.status === 401 ? "connection rejected — run check_site to connect again" : lastErr?.message}) — ${left.length - other} kept in ${OUTBOX}, will retry next time${otherLine}`;
|
|
229
301
|
}
|
|
230
302
|
|
|
@@ -313,6 +385,10 @@ module.exports = {
|
|
|
313
385
|
reconnect,
|
|
314
386
|
getInstall,
|
|
315
387
|
sendVisit,
|
|
388
|
+
connected,
|
|
389
|
+
pendingCount,
|
|
390
|
+
queueVisit,
|
|
391
|
+
flushOutbox,
|
|
316
392
|
sendDiagnostics,
|
|
317
393
|
getResearchTasks,
|
|
318
394
|
savePath,
|
|
@@ -330,5 +406,7 @@ module.exports = {
|
|
|
330
406
|
getIssues,
|
|
331
407
|
getIssue,
|
|
332
408
|
saveIssueFix,
|
|
409
|
+
planBlocked,
|
|
410
|
+
planStopText,
|
|
333
411
|
dashboardUrl,
|
|
334
412
|
};
|
package/mcp/server.js
CHANGED
|
@@ -26,7 +26,19 @@ const peopleRules = require("../src/persona/people.js");
|
|
|
26
26
|
const { PathRecorder, patternOf } = require("./path.js");
|
|
27
27
|
|
|
28
28
|
const hub = new Hub();
|
|
29
|
-
|
|
29
|
+
// 서버 안내문 — 도구 설명과 달리 AI 에게 처음부터 보인다(Claude Code 는 MCP 도구를 이름만 보여 주고 설명은 찾아야 보인다).
|
|
30
|
+
// 말로 부탁받았을 때 지시문(instructions 도구)부터 받게 한다. 11/10 시험: 안내문 없이 "방문자한테 물어볼 질문 만들어줘" → Usercase 를 안 쓰고 대화로 질문만 썼다
|
|
31
|
+
const server = new McpServer(
|
|
32
|
+
{ name: "usercase", version: require("../package.json").version },
|
|
33
|
+
{
|
|
34
|
+
instructions:
|
|
35
|
+
"Usercase is connected: virtual users that visit this site, research with real visitors, and questions shown to visitors on the site. " +
|
|
36
|
+
"When the user asks for any of these — in a command or in their own words — call the Usercase `instructions` tool first with the matching command and follow what it returns: " +
|
|
37
|
+
"test or get feedback on the site with virtual users (research); plan or create a research or usability study (new-research); add or install Usercase on the site (install); " +
|
|
38
|
+
"make a question to ask visitors (question); write the steps for the question open in the dashboard (steps); record the paths of a research's tasks (path); " +
|
|
39
|
+
"fix a finding from a research report or an issue from Issues (fix); connect this repository to another Usercase project (connect).",
|
|
40
|
+
},
|
|
41
|
+
);
|
|
30
42
|
|
|
31
43
|
// Claude Code 플러그인(plugin/)으로 떴는지. 플러그인의 .mcp.json 이 USERCASE_PLUGIN=1 을 준다.
|
|
32
44
|
// 플러그인에서는 방문자 에이전트가 플러그인에 들어 있어 이름이 usercase:usercase-visitor 이고(도구도 mcp__plugin_usercase_usercase__*),
|
|
@@ -45,14 +57,18 @@ const cmd = (name) => (CODEX ? `$usercase:${name}` : PLUGIN ? `/usercase:${name}
|
|
|
45
57
|
const SPAWN = CODEX ? " (with `fork_turns` set to `none`, so it starts without this conversation)" : "";
|
|
46
58
|
|
|
47
59
|
const text = (t) => ({ content: [{ type: "text", text: String(t) }] });
|
|
48
|
-
/** 방문 화면 + 그 화면의
|
|
60
|
+
/** 방문 화면 + 그 화면의 그림들(화면 전체·이름 없는 아이콘, 있으면). 방문자는 그림을 보고 사람처럼 안다 */
|
|
49
61
|
const screen = (t, visitId) => {
|
|
50
|
-
const pic = visitId ? hub.takePicture(visitId) : null;
|
|
51
62
|
const out = text(t);
|
|
52
|
-
|
|
63
|
+
for (const p of visitId ? hub.takePictures(visitId) : []) out.content.push({ type: "image", data: p.data.toString("base64"), mimeType: p.mime });
|
|
53
64
|
return out;
|
|
54
65
|
};
|
|
55
|
-
|
|
66
|
+
// 요금제 한도(402)는 오류가 아니라 멈출 일 — 우회하지 말고 사용자에게 그대로 (report.js planLimitError)
|
|
67
|
+
/** 말로 부탁받은 AI 가 지시문을 건너뛰고 바로 부르는 도구들 — 지시문(instructions)부터 읽게 한다(11/10 시험: 고쳐줘 → issue_read 로 바로 갔다) */
|
|
68
|
+
const PROC = (c) => ` If you have not read the ${c} procedure yet, call the instructions tool with command ${c} first.`;
|
|
69
|
+
/** 리서치는 id 또는 이름 — 말로 부탁하면 이름이 온다. 서버가 이름으로 찾는다(11/10) */
|
|
70
|
+
const RESEARCH_REF = "The research id, or its name as the user said it (the server finds it by name; if several or none match, it lists them — ask the user which one)";
|
|
71
|
+
const fail = (e) => ({ content: [{ type: "text", text: e && e.planLimit ? report.planStopText(e.planLimit) : `Error: ${e.message}` }], isError: true });
|
|
56
72
|
const wrap = (fn) => async (args) => {
|
|
57
73
|
try {
|
|
58
74
|
return await fn(args);
|
|
@@ -67,7 +83,7 @@ server.registerTool(
|
|
|
67
83
|
"check_site",
|
|
68
84
|
{
|
|
69
85
|
description:
|
|
70
|
-
"Open the site once as a virtual user and report: does it load, is the Usercase SDK running (which project), is a firewall/bot protection blocking us (and how to unblock, per vendor), is the secret key set. Always run this before starting visits. If it reports a block, stop and tell the user.",
|
|
86
|
+
"Open the site once as a virtual user and report: does it load, is the Usercase SDK running (which project), is a firewall/bot protection blocking us (and how to unblock, per vendor), is the secret key set. Always run this before starting visits. If it reports a block, stop and tell the user." + PROC("research"),
|
|
71
87
|
inputSchema: { url: z.string().url().describe("The site to test, e.g. https://staging.example.com") },
|
|
72
88
|
},
|
|
73
89
|
wrap(async ({ url }) => text(await hub.checkSite(url))),
|
|
@@ -90,7 +106,7 @@ server.registerTool(
|
|
|
90
106
|
"install_snippet",
|
|
91
107
|
{
|
|
92
108
|
description:
|
|
93
|
-
"Get the Usercase script for the connected project, with where to put it per framework. If nothing is connected yet, it opens the browser to connect and says what to tell the user. Use when check_site says the SDK is not running and you are in the site's repository: add the script once to the root layout/document, show the user the diff, then run check_site on the local site to confirm. Do not commit or deploy unless asked.",
|
|
109
|
+
"Get the Usercase script for the connected project, with where to put it per framework. If nothing is connected yet, it opens the browser to connect and says what to tell the user. Use when check_site says the SDK is not running and you are in the site's repository: add the script once to the root layout/document, show the user the diff, then run check_site on the local site to confirm. Do not commit or deploy unless asked." + PROC("install"),
|
|
94
110
|
inputSchema: {},
|
|
95
111
|
},
|
|
96
112
|
wrap(async () => {
|
|
@@ -184,7 +200,7 @@ server.registerTool(
|
|
|
184
200
|
research: z
|
|
185
201
|
.string()
|
|
186
202
|
.optional()
|
|
187
|
-
.describe("Only when the run takes part in a research
|
|
203
|
+
.describe("Only when the run takes part in a research — its id, as path_tasks shows it: everyone saved in this call takes part in it"),
|
|
188
204
|
},
|
|
189
205
|
},
|
|
190
206
|
wrap(async ({ site, site_about, people, research }) => {
|
|
@@ -242,9 +258,18 @@ server.registerTool(
|
|
|
242
258
|
const rows = ws.recentRuns(limit);
|
|
243
259
|
const { summarize } = require("./hub.js");
|
|
244
260
|
const lines = rows.map((r) => summarize(r)).join("\n\n");
|
|
261
|
+
// SDK 가 없던 페이지 — 사이트 설정 문제라 사용자에게 알린다(도구 문제가 아니다)
|
|
262
|
+
const noSdk = [...new Set(rows.flatMap((r) => r.noSdk || []))];
|
|
263
|
+
const sdkLine = noSdk.length
|
|
264
|
+
? `⚠ Pages where the Usercase SDK was missing or did not connect: ${noSdk.join(", ")}. Visits are not recorded there, and research visits that reached one were stopped. Tell the user this in plain words: where the script is missing, adding the Usercase script to those pages (or that subdomain) fixes it; where it did not connect, add that domain to the project's allowed domains in Usercase settings (a wildcard such as *.example.com covers every subdomain).`
|
|
265
|
+
: "";
|
|
266
|
+
// 이 폴더가 연결되지 않아 대시보드에 안 올라간 방문 — 사용자는 대시보드에서 찾다가 없는 줄 안다(11/10)
|
|
267
|
+
const keyLine = !report.connected() && report.pendingCount()
|
|
268
|
+
? `⚠ This folder is not connected to a Usercase project, so ${report.pendingCount()} visit(s) are not on the dashboard yet (kept locally). Tell the user, and run check_site to connect — they upload as soon as it is connected.`
|
|
269
|
+
: "";
|
|
245
270
|
const st = hub.status();
|
|
246
271
|
const next = runStatus();
|
|
247
|
-
return text([lines || "No visits yet.", st ? `\n---\n${st}` : "", next ? `\n---\n${next}` : ""].join("\n"));
|
|
272
|
+
return text([lines || "No visits yet.", keyLine ? `\n---\n${keyLine}` : "", sdkLine ? `\n---\n${sdkLine}` : "", st ? `\n---\n${st}` : "", next ? `\n---\n${next}` : ""].join("\n"));
|
|
248
273
|
}),
|
|
249
274
|
);
|
|
250
275
|
|
|
@@ -409,7 +434,7 @@ const QCondition = z.object({
|
|
|
409
434
|
page: z.string().describe("Path glob of the screen the visitor is on when the question appears"),
|
|
410
435
|
steps: z.array(QStep).describe("What happened, in order. Other actions may happen in between"),
|
|
411
436
|
moment: z.object({
|
|
412
|
-
type: z.enum(["on_match", "idle", "leave"]).describe("on_match = as soon as the steps are met · idle = after that, N seconds with no click, typing or page change (scrolling still counts as a pause) · leave = when
|
|
437
|
+
type: z.enum(["on_match", "idle", "leave"]).describe("on_match = as soon as the steps are met · idle = after that, N seconds with no click, typing or page change (scrolling still counts as a pause) · leave = when they leave that screen: moving to another page, closing the tab on a computer, or coming back from another tab (phones cannot tell a tab closing)"),
|
|
413
438
|
seconds: z.number().optional(),
|
|
414
439
|
}),
|
|
415
440
|
});
|
|
@@ -438,7 +463,7 @@ server.registerTool(
|
|
|
438
463
|
"question_context",
|
|
439
464
|
{
|
|
440
465
|
description:
|
|
441
|
-
"Screens and element words from this project's recent real visits. Call first in the question procedure, before writing a condition — page globs and click targets must match what visitors actually saw.",
|
|
466
|
+
"Screens and element words from this project's recent real visits. Call first in the question procedure, before writing a condition — page globs and click targets must match what visitors actually saw." + PROC("question"),
|
|
442
467
|
inputSchema: {},
|
|
443
468
|
},
|
|
444
469
|
wrap(async () => {
|
|
@@ -507,7 +532,7 @@ server.registerTool(
|
|
|
507
532
|
server.registerTool(
|
|
508
533
|
"steps_brief",
|
|
509
534
|
{
|
|
510
|
-
description: "What is in the user's Several steps window: whether the card is a question or a research invite, the page under Where, and the words they wrote. Call first in the steps procedure.",
|
|
535
|
+
description: "What is in the user's Several steps window: whether the card is a question or a research invite, the page under Where, and the words they wrote. Call first in the steps procedure." + PROC("steps"),
|
|
511
536
|
inputSchema: { session: z.string().describe("The window id from the dashboard command") },
|
|
512
537
|
},
|
|
513
538
|
wrap(async ({ session }) => {
|
|
@@ -565,8 +590,8 @@ server.registerTool(
|
|
|
565
590
|
"report_findings",
|
|
566
591
|
{
|
|
567
592
|
description:
|
|
568
|
-
"List the findings of a Usercase research's latest report (key, severity, title, who, and whether one is being fixed). Use when the fix command has no finding key.",
|
|
569
|
-
inputSchema: { research: z.string().describe(
|
|
593
|
+
"List the findings of a Usercase research's latest report (key, severity, title, who, and whether one is being fixed). Use when the fix command has no finding key." + PROC("fix"),
|
|
594
|
+
inputSchema: { research: z.string().describe(RESEARCH_REF), audience: audienceArg },
|
|
570
595
|
},
|
|
571
596
|
wrap(async ({ research, audience }) => {
|
|
572
597
|
const r = await report.getReport(research, audience);
|
|
@@ -589,8 +614,8 @@ server.registerTool(
|
|
|
589
614
|
"report_finding",
|
|
590
615
|
{
|
|
591
616
|
description:
|
|
592
|
-
"Read one finding of a Usercase research report with all its evidence: why, who, the screen checks from the recordings, the paths people took, what they wrote, and the suggested changes. Read it before looking for the code.",
|
|
593
|
-
inputSchema: { research: z.string().describe(
|
|
617
|
+
"Read one finding of a Usercase research report with all its evidence: why, who, the screen checks from the recordings, the paths people took, what they wrote, and the suggested changes. Read it before looking for the code." + PROC("fix"),
|
|
618
|
+
inputSchema: { research: z.string().describe(RESEARCH_REF), finding: z.string().describe("Finding key, e.g. F1"), audience: audienceArg },
|
|
594
619
|
},
|
|
595
620
|
wrap(async ({ research, finding, audience }) => {
|
|
596
621
|
const r = await report.getFinding(research, finding, audience);
|
|
@@ -630,7 +655,7 @@ server.registerTool(
|
|
|
630
655
|
description:
|
|
631
656
|
"Record on the research report that you are fixing a finding (status fixing, once the user approved the change) or have fixed it (status fixed, with the files, a one-sentence summary for the team, and the pull request link if any). If the last fix of this finding is already live on the site, this starts the next fix (fix 2, 3 …); otherwise it adds to the current one. The report compares people after each fix goes live with the version right before it.",
|
|
632
657
|
inputSchema: {
|
|
633
|
-
research: z.string().describe(
|
|
658
|
+
research: z.string().describe(RESEARCH_REF),
|
|
634
659
|
finding: z.string().describe("Finding key, e.g. F1"),
|
|
635
660
|
audience: audienceArg,
|
|
636
661
|
status: z.enum(["fixing", "fixed"]),
|
|
@@ -652,7 +677,7 @@ const issueFixLine = (f) => (f ? ` — ${f.status === "fixed" ? "fixed" : "being
|
|
|
652
677
|
server.registerTool(
|
|
653
678
|
"issue_list",
|
|
654
679
|
{
|
|
655
|
-
description: "List the open issues of the Usercase project (problems found from visitors' answers to questions on the site): id, title, where, whether it is a clear fix, and whether one is being fixed. Use when the fix command has no issue id.",
|
|
680
|
+
description: "List the open issues of the Usercase project (problems found from visitors' answers to questions on the site): id, title, where, whether it is a clear fix, and whether one is being fixed. Use when the fix command has no issue id." + PROC("fix"),
|
|
656
681
|
inputSchema: {},
|
|
657
682
|
},
|
|
658
683
|
wrap(async () => {
|
|
@@ -675,7 +700,7 @@ server.registerTool(
|
|
|
675
700
|
"issue_read",
|
|
676
701
|
{
|
|
677
702
|
description:
|
|
678
|
-
"Read one issue of the Usercase project with all its evidence: the question and page, what people chose, what they wrote, the AI's reading, other issues with the same cause, and earlier fixes. Read it before looking for the code.",
|
|
703
|
+
"Read one issue of the Usercase project with all its evidence: the question and page, what people chose, what they wrote, the AI's reading, other issues with the same cause, and earlier fixes. Read it before looking for the code." + PROC("fix"),
|
|
679
704
|
inputSchema: { issue: z.string().describe("The issue id, as in the dashboard command") },
|
|
680
705
|
},
|
|
681
706
|
wrap(async ({ issue }) => {
|
|
@@ -728,8 +753,8 @@ server.registerTool(
|
|
|
728
753
|
server.registerTool(
|
|
729
754
|
"path_tasks",
|
|
730
755
|
{
|
|
731
|
-
description: "List the tasks of a Usercase research (from the dashboard) and whether each already has an expected path. Call first in the path procedure.",
|
|
732
|
-
inputSchema: { research: z.string().describe(
|
|
756
|
+
description: "List the tasks of a Usercase research (from the dashboard) and whether each already has an expected path. Call first in the path procedure." + PROC("path"),
|
|
757
|
+
inputSchema: { research: z.string().describe(RESEARCH_REF) },
|
|
733
758
|
},
|
|
734
759
|
wrap(async ({ research }) => {
|
|
735
760
|
const out = await recorder.tasks(research);
|
|
@@ -829,7 +854,7 @@ server.registerTool(
|
|
|
829
854
|
"research_context",
|
|
830
855
|
{
|
|
831
856
|
description:
|
|
832
|
-
"What this project's data says is worth researching: open findings, where real visitors and virtual users left, what virtual users said when they left without getting what they came for, and the researches and drafts that already exist. Call first in the new-research procedure.",
|
|
857
|
+
"What this project's data says is worth researching: open findings, where real visitors and virtual users left, what virtual users said when they left without getting what they came for, and the researches and drafts that already exist. Call first in the new-research procedure." + PROC("new-research"),
|
|
833
858
|
inputSchema: {},
|
|
834
859
|
},
|
|
835
860
|
wrap(async () => {
|
|
@@ -873,7 +898,8 @@ server.registerTool(
|
|
|
873
898
|
"Save the research as a DRAFT in the dashboard Inbox, after the user approved it. It becomes a research only when someone creates it there. Paths are page paths starting with / (no host), with :name for the parts that change. Expected paths recorded and saved beforehand as plan-<n> (n = the task's position in tasks, from 1) are uploaded with it.",
|
|
874
899
|
inputSchema: {
|
|
875
900
|
title: z.string().describe("Short, for the team"),
|
|
876
|
-
purpose: z.string().describe("
|
|
901
|
+
purpose: z.string().describe("Shown to participants (invite card and start of the study): one or two sentences, in their words, on what they will help with. Never the team's evidence, suspicions or hypotheses"),
|
|
902
|
+
hypotheses: z.array(z.string()).max(5).optional().describe("Team only, never shown to participants: what the team expects to go wrong, one per item, 1–3 usually. The report judges each one. Each must be something these tasks can confirm or rule out"),
|
|
877
903
|
start_screen: z.object({ title: z.string(), subtitle: z.string() }).optional().describe("What participants read before the first task"),
|
|
878
904
|
tasks: z
|
|
879
905
|
.array(
|
|
@@ -1143,7 +1169,7 @@ const COMMANDS = {
|
|
|
1143
1169
|
research: z
|
|
1144
1170
|
.string()
|
|
1145
1171
|
.optional()
|
|
1146
|
-
.describe("A research
|
|
1172
|
+
.describe("A research from the dashboard (its id, or its name): the people take part in that research — its tasks and questions — instead of exploring freely"),
|
|
1147
1173
|
},
|
|
1148
1174
|
body: ({ url, count, focus, research }) => {
|
|
1149
1175
|
const plan = researchPlan(count);
|
|
@@ -1193,10 +1219,10 @@ const COMMANDS = {
|
|
|
1193
1219
|
path: {
|
|
1194
1220
|
description: "Record the expected path of a Usercase research's tasks",
|
|
1195
1221
|
// 대시보드 과제 화면의 명령은 과제 id 를 붙인다 — 그 과제만, 경로가 있어도 다시
|
|
1196
|
-
argsSchema: { research: z.string().optional(), task: z.string().optional() },
|
|
1222
|
+
argsSchema: { research: z.string().optional().describe(RESEARCH_REF), task: z.string().optional() },
|
|
1197
1223
|
body: ({ research, task }) =>
|
|
1198
1224
|
doc("path.md")
|
|
1199
|
-
.replace("{{research}}", research || "(ask the user
|
|
1225
|
+
.replace("{{research}}", research || "(not given — ask the user which research; its name is enough)")
|
|
1200
1226
|
.replace("{{task}}", task ? `Task: ${task} — record this task only, and record it again even if it already has a path.` : "Task: all tasks of the research"),
|
|
1201
1227
|
},
|
|
1202
1228
|
// 인자를 두지 않는다 — Claude Code 는 명령어 인자를 공백으로 나눠 문장을 못 싣는다. 무엇을 물을지는 대화로 묻는다
|
|
@@ -1228,7 +1254,7 @@ const COMMANDS = {
|
|
|
1228
1254
|
fix: {
|
|
1229
1255
|
description: "Fix a finding from a Usercase research report, or an issue from Issues, in this repository's code",
|
|
1230
1256
|
argsSchema: {
|
|
1231
|
-
research: z.string().optional(),
|
|
1257
|
+
research: z.string().optional().describe(RESEARCH_REF),
|
|
1232
1258
|
finding: z.string().optional(),
|
|
1233
1259
|
audience: z.string().optional(),
|
|
1234
1260
|
issue: z.string().optional().describe("An issue id from the dashboard's Issues — fixes that issue instead of a report finding"),
|
|
@@ -1238,7 +1264,7 @@ const COMMANDS = {
|
|
|
1238
1264
|
issue || String(research || "").trim().toLowerCase() === "issue"
|
|
1239
1265
|
? agentDoc("fix-issue.md", { issue: String(issue || "").trim() || "(not given — list the open issues and ask)", research_cmd: cmd("research") })
|
|
1240
1266
|
: agentDoc("fix.md", {
|
|
1241
|
-
research: research || "(ask the user
|
|
1267
|
+
research: research || "(not given — ask the user which research; its name is enough)",
|
|
1242
1268
|
finding: finding ? String(finding).toUpperCase() : "(not given — list the findings and ask)",
|
|
1243
1269
|
audience: audience === "real" ? "real (the report of real people)" : "virtual (the report of virtual users)",
|
|
1244
1270
|
check:
|
|
@@ -1254,12 +1280,31 @@ const COMMANDS = {
|
|
|
1254
1280
|
},
|
|
1255
1281
|
};
|
|
1256
1282
|
|
|
1257
|
-
|
|
1283
|
+
// 코드를 읽는 명령은 모두 끝에 "Usercase 로 무엇을 보내는가" 규칙을 붙인다 — 에이전트가 읽은 키·.env 값을 도구 인자에 실어
|
|
1284
|
+
// 우리 서버로 보내지 않게. 명령마다 따로 쓰면 빠지는 데가 생겨 한 벌(agents/secrets.md)만 둔다. connect 는 코드를 안 읽는다
|
|
1285
|
+
const NO_SECRETS = new Set(["connect"]);
|
|
1286
|
+
const commandText = (name, args) => {
|
|
1287
|
+
const c = COMMANDS[name];
|
|
1288
|
+
const body = c.body(byName(args, c.argsSchema)).trimEnd();
|
|
1289
|
+
return NO_SECRETS.has(name) ? body : `${body}\n\n${doc("secrets.md").trim()}\n`;
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
// 지시문은 어느 연결에서든 도구로도 준다. 사용자는 명령 대신 말로("새 리서치 만들어줘") 부탁하는데, AI 는 프롬프트를 스스로 열지 못한다 —
|
|
1293
|
+
// 플러그인 없이 붙인 Claude Code 에서 말로 부탁하면 지시문 없이 도구를 짐작으로 불렀다(11/10 시험). 프롬프트(/mcp__usercase__이름)는 그대로 둔다
|
|
1294
|
+
{
|
|
1258
1295
|
server.registerTool(
|
|
1259
1296
|
"instructions",
|
|
1260
1297
|
{
|
|
1261
1298
|
description:
|
|
1262
|
-
"
|
|
1299
|
+
"Start here for any Usercase work — whether the user typed a command or just asked in their own words. Returns the step-by-step procedure; follow it as the user's request, and call this before the other Usercase tools. Commands: " +
|
|
1300
|
+
"research (test or get feedback on the site with virtual users; `research` id to have them take part in a research), " +
|
|
1301
|
+
"new-research (plan or create a research / usability study), " +
|
|
1302
|
+
"install (add or install the Usercase script on the site), " +
|
|
1303
|
+
"question (make a question to ask visitors at a moment on the site), " +
|
|
1304
|
+
"steps (write the steps for the question open in the dashboard; `session` = its window id), " +
|
|
1305
|
+
"path (record the expected paths of a research's tasks; `research` id, optional `task`), " +
|
|
1306
|
+
"fix (fix a finding from a research report: `research` id + `finding` key like F1, `audience` real for the real-people report; or an issue from Issues: `issue` id), " +
|
|
1307
|
+
"connect (connect this repository to another Usercase project).",
|
|
1263
1308
|
inputSchema: {
|
|
1264
1309
|
command: z.enum(Object.keys(COMMANDS)),
|
|
1265
1310
|
...COMMANDS.research.argsSchema,
|
|
@@ -1268,12 +1313,13 @@ if (PLUGIN || CODEX || OTHER) {
|
|
|
1268
1313
|
...COMMANDS.fix.argsSchema,
|
|
1269
1314
|
},
|
|
1270
1315
|
},
|
|
1271
|
-
wrap(async ({ command, ...args }) => text(
|
|
1316
|
+
wrap(async ({ command, ...args }) => text(commandText(command, args))),
|
|
1272
1317
|
);
|
|
1273
|
-
}
|
|
1318
|
+
}
|
|
1319
|
+
if (!(PLUGIN || CODEX || OTHER)) {
|
|
1274
1320
|
for (const [name, c] of Object.entries(COMMANDS)) {
|
|
1275
1321
|
const meta = c.argsSchema ? { description: c.description, argsSchema: c.argsSchema } : { description: c.description };
|
|
1276
|
-
server.registerPrompt(name, meta, (args = {}) => ({ messages: [{ role: "user", content: { type: "text", text:
|
|
1322
|
+
server.registerPrompt(name, meta, (args = {}) => ({ messages: [{ role: "user", content: { type: "text", text: commandText(name, args) } }] }));
|
|
1277
1323
|
}
|
|
1278
1324
|
}
|
|
1279
1325
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usercase/mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12-next.0",
|
|
4
4
|
"description": "Virtual users that browse your website and tell you what they thought — an MCP server for Claude Code",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://usercase.io",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
37
37
|
"playwright": "^1.58.2",
|
|
38
|
+
"tldts": "^7.4.19",
|
|
38
39
|
"zod": "^4.6.5"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
package/src/browser/guard.js
CHANGED
|
@@ -31,6 +31,14 @@ const SURVEY = /^https?:\/\/survey\.(usercase\.io|localhost)(:\d+)?$/;
|
|
|
31
31
|
|
|
32
32
|
const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
33
33
|
|
|
34
|
+
const { getDomain } = require("tldts");
|
|
35
|
+
/**
|
|
36
|
+
* 같은 사이트의 뿌리 도메인. www.about.bluetags.app → bluetags.app.
|
|
37
|
+
* 공용 접미사 목록의 개인 도메인(vercel.app · github.io 등)까지 본다 — foo.vercel.app 과 bar.vercel.app 은 남이다.
|
|
38
|
+
* IP · localhost 는 뿌리가 없다(null) — 그 주소만 같은 사이트다
|
|
39
|
+
*/
|
|
40
|
+
const rootOf = (host) => getDomain(host, { allowPrivateDomains: true }) || null;
|
|
41
|
+
|
|
34
42
|
/** 새 탭·창도 그 자체로 최상위다. 페이지 안 iframe(영상·지도)은 사이트 밖이어도 그냥 둔다 */
|
|
35
43
|
function isMainFrame(req) {
|
|
36
44
|
try {
|
|
@@ -48,7 +56,10 @@ const isLocal = (site) => /^https?:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(si
|
|
|
48
56
|
* @param blockTrackers 분석 도구를 막나(기본 true)
|
|
49
57
|
* @param allowWritePaths 쓰기를 허용할 경로 정규식들. 로그인 뒤 조회를 POST 로 하는 사이트용(PLAN.md "로그인")
|
|
50
58
|
*/
|
|
51
|
-
|
|
59
|
+
/**
|
|
60
|
+
* @param robotsFor 하위 도메인 origin → 그 robots(비동기, 한 번 읽어 둔다). 없으면 하위 도메인은 robots 를 안 본다
|
|
61
|
+
*/
|
|
62
|
+
function createGuard({ site, inject = null, blockTrackers = true, allowWritePaths = [], policy = null, robots = null, robotsFor = null } = {}) {
|
|
52
63
|
const origin = new URL(site).origin;
|
|
53
64
|
const local = isLocal(site);
|
|
54
65
|
/** 우리가 심은 SDK 와 그 서버. 이건 막지 않는다 */
|
|
@@ -69,19 +80,26 @@ function createGuard({ site, inject = null, blockTrackers = true, allowWritePath
|
|
|
69
80
|
*/
|
|
70
81
|
const bare = (h) => h.replace(/^www\./, "");
|
|
71
82
|
const siteHost = bare(new URL(origin).host);
|
|
72
|
-
|
|
83
|
+
/**
|
|
84
|
+
* 같은 사이트 — 같은 뿌리 도메인의 하위 도메인까지(www · app · about …). 사람처럼 사이트의 다른 하위 도메인으로 넘어간다.
|
|
85
|
+
* 전에는 www 만 같은 사이트로 봐서 소개는 www, 제품은 app 인 사이트에서 "시작하기" 가 막혔다.
|
|
86
|
+
* 하위 도메인에 SDK 가 없으면 hub 가 그 페이지를 알린다(리서치 방문은 멈춘다, sdkGone)
|
|
87
|
+
*/
|
|
88
|
+
const siteRoot = local ? null : rootOf(new URL(origin).hostname);
|
|
89
|
+
const sameSite = (u) => u.origin === origin || bare(u.host) === siteHost || (!!siteRoot && /^https?:$/.test(u.protocol) && rootOf(u.hostname) === siteRoot);
|
|
73
90
|
|
|
74
91
|
function writeAllowed(pathname) {
|
|
75
92
|
if (writeOk.some((rx) => rx.test(pathname))) return true;
|
|
76
93
|
return !!(policy && policy.nonGetAllowed(pathname));
|
|
77
94
|
}
|
|
78
95
|
|
|
79
|
-
function navBlocked(u) {
|
|
80
|
-
if (u
|
|
96
|
+
async function navBlocked(u) {
|
|
97
|
+
if (!sameSite(u)) return null; // 사이트 밖은 아래에서 따로 다룬다
|
|
98
|
+
// 고객이 정한 금지 경로는 하위 도메인에도 같다
|
|
81
99
|
const byPolicy = policy ? policy.pathReason(u.pathname) : null;
|
|
82
100
|
if (byPolicy) return byPolicy;
|
|
83
|
-
// robots 는 나중에 읽혀 들어올 수 있다(네트워크). 함수로 받으면 그때그때 최신을
|
|
84
|
-
const rb = typeof robots === "function" ? robots() : robots;
|
|
101
|
+
// robots 는 나중에 읽혀 들어올 수 있다(네트워크). 함수로 받으면 그때그때 최신을 본다. 하위 도메인은 그 도메인의 robots.txt
|
|
102
|
+
const rb = u.origin === origin ? (typeof robots === "function" ? robots() : robots) : robotsFor ? await robotsFor(u.origin).catch(() => null) : null;
|
|
85
103
|
const byRobots = rb ? rb.blocked(u.pathname) : null;
|
|
86
104
|
return byRobots ? `robots.txt Disallow ${byRobots}` : null;
|
|
87
105
|
}
|
|
@@ -91,7 +109,7 @@ function createGuard({ site, inject = null, blockTrackers = true, allowWritePath
|
|
|
91
109
|
* @param onWrite 사이트에 쓰는 요청을 끊었을 때 "METHOD /path" 로 부른다. 방문자가 "안 된다" 로 읽지 않게 알린다
|
|
92
110
|
*/
|
|
93
111
|
async function apply(ctx, { onOffsite = null, onWrite = null, onExternal = null } = {}) {
|
|
94
|
-
await ctx.route("**/*", (r) => {
|
|
112
|
+
await ctx.route("**/*", async (r) => {
|
|
95
113
|
const req = r.request();
|
|
96
114
|
let u;
|
|
97
115
|
try {
|
|
@@ -121,8 +139,8 @@ function createGuard({ site, inject = null, blockTrackers = true, allowWritePath
|
|
|
121
139
|
* 금지 구역으로 **옮겨 가는 것**만 막는다(클릭은 막지 않는다 — SDK 가 그 클릭을 봐야 한다).
|
|
122
140
|
* 화면 안에서 쓰는 요청(XHR 등)까지 이걸로 막으면 멀쩡한 사이트가 안 돈다.
|
|
123
141
|
*/
|
|
124
|
-
if (u
|
|
125
|
-
const why = navBlocked(u);
|
|
142
|
+
if (sameSite(u) && req.isNavigationRequest()) {
|
|
143
|
+
const why = await navBlocked(u);
|
|
126
144
|
if (why) {
|
|
127
145
|
const k = `${u.pathname} — ${why}`;
|
|
128
146
|
stats.paths.set(k, (stats.paths.get(k) || 0) + 1);
|
|
@@ -135,7 +153,8 @@ function createGuard({ site, inject = null, blockTrackers = true, allowWritePath
|
|
|
135
153
|
* 전에는 아래 로컬 분기가 localhost 를 먼저 통과시켜 로컬에서는 쓰기가 한 번도 안 막혔다(10/14 발견).
|
|
136
154
|
* 개발 서버 자체 요청(/_next/, /__nextjs)은 사이트 데이터가 아니다
|
|
137
155
|
*/
|
|
138
|
-
|
|
156
|
+
// 하위 도메인도 그 사이트다 — app. 에서 가입 · 글쓰기가 실제로 나가면 안 된다
|
|
157
|
+
const siteReq = sameSite(u) || (local && /^https?:\/\/(localhost|127\.0\.0\.1)/.test(u.origin) && !/^\/(_next|__nextjs)/.test(u.pathname));
|
|
139
158
|
if (siteReq && !READ_METHODS.has(req.method()) && !writeAllowed(u.pathname)) {
|
|
140
159
|
const k = `${req.method()} ${u.pathname}`;
|
|
141
160
|
stats.writes.set(k, (stats.writes.get(k) || 0) + 1);
|
|
@@ -216,4 +235,22 @@ function injectSdk({ project, src }) {
|
|
|
216
235
|
}
|
|
217
236
|
}
|
|
218
237
|
|
|
219
|
-
|
|
238
|
+
/**
|
|
239
|
+
* 두 주소가 같은 사이트인가 — guard 의 sameSite 와 같은 기준(www 무시, 같은 뿌리 도메인의 하위 도메인까지).
|
|
240
|
+
* 방문(visit.js "다른 사이트로 가는 링크" 알림)과 새 탭 처리(hub)가 같이 쓴다. 로컬 · IP 는 호스트가 같아야 한다
|
|
241
|
+
*/
|
|
242
|
+
function isSameSite(a, b) {
|
|
243
|
+
try {
|
|
244
|
+
const ua = new URL(a);
|
|
245
|
+
const ub = new URL(b);
|
|
246
|
+
const bare = (h) => h.replace(/^www\./, "");
|
|
247
|
+
if (bare(ua.host) === bare(ub.host)) return true;
|
|
248
|
+
if (!/^https?:$/.test(ub.protocol) || isLocal(ua.origin) || isLocal(ub.origin)) return false;
|
|
249
|
+
const ra = rootOf(ua.hostname);
|
|
250
|
+
return !!ra && ra === rootOf(ub.hostname);
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = { createGuard, isLocal, isSameSite, TRACKERS };
|
package/src/browser/page.js
CHANGED
|
@@ -644,4 +644,29 @@ async function iconPicture(page, items, browser, max = 16) {
|
|
|
644
644
|
}
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
-
|
|
647
|
+
/**
|
|
648
|
+
* 지금 보이는 화면 전체를 작게 한 장. 사람은 사진·배너·배치·눈에 띄는 것을 보고 고르는데, 방문자는 글자 목록만 받아
|
|
649
|
+
* 이미지 속 내용을 몰랐다(11/11). 글자는 목록으로 이미 받으니 긴 변 640px 로 줄여 사진과 배치만 알아볼 만큼 — 한 장 300 토큰쯤.
|
|
650
|
+
* 줄이는 판은 방문과 따로 연 빈 창이라 사이트에 아무것도 안 보낸다. 못 찍으면 null
|
|
651
|
+
*/
|
|
652
|
+
async function screenPicture(page, browser, long = 640) {
|
|
653
|
+
if (!browser) return null;
|
|
654
|
+
const vp = page.viewportSize() || { width: 1280, height: 720 };
|
|
655
|
+
const png = await page.screenshot({ type: "png", scale: "css", animations: "disabled", timeout: 5000 }).catch(() => null);
|
|
656
|
+
if (!png) return null;
|
|
657
|
+
const k = Math.min(1, long / Math.max(vp.width, vp.height));
|
|
658
|
+
const w = Math.round(vp.width * k);
|
|
659
|
+
const h = Math.round(vp.height * k);
|
|
660
|
+
const ctx = await browser.newContext({ viewport: { width: w, height: h } });
|
|
661
|
+
try {
|
|
662
|
+
const sheet = await ctx.newPage();
|
|
663
|
+
await sheet.setContent(`<!doctype html><html><body style="margin:0"><img style="display:block" width="${w}" height="${h}" src="data:image/png;base64,${png.toString("base64")}"></body></html>`);
|
|
664
|
+
return await sheet.screenshot({ type: "jpeg", quality: 70, timeout: 5000 });
|
|
665
|
+
} catch {
|
|
666
|
+
return null;
|
|
667
|
+
} finally {
|
|
668
|
+
await ctx.close().catch(() => {});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
module.exports = { OWN_SEL, listControls, visibleText, handleAt, clickBlocked, iconPicture, screenPicture };
|
package/src/run/visit.js
CHANGED
|
@@ -22,6 +22,7 @@ const { randomUUID } = require("crypto");
|
|
|
22
22
|
const { listControls, visibleText, handleAt, clickBlocked } = require("../browser/page.js");
|
|
23
23
|
const { readCard, waitCard, screenText, answerCard, noteReason, sleep, waitForSdk } = require("../browser/card.js");
|
|
24
24
|
const hand = require("../browser/hand.js");
|
|
25
|
+
const { isSameSite } = require("../browser/guard.js");
|
|
25
26
|
const answer = require("./answer.js");
|
|
26
27
|
const diag = require("./diag.js");
|
|
27
28
|
|
|
@@ -240,13 +241,15 @@ function watchNet(page) {
|
|
|
240
241
|
};
|
|
241
242
|
}
|
|
242
243
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
244
|
+
/**
|
|
245
|
+
* 링크가 사이트 밖이면 그 호스트. www 만 다른 것, 같은 뿌리 도메인의 하위 도메인은 같은 사이트다(guard.js isSameSite).
|
|
246
|
+
* @param site 사이트 주소. 없으면 지금 페이지 기준
|
|
247
|
+
*/
|
|
248
|
+
function offsiteHost(href, here, site) {
|
|
245
249
|
if (!href || !/^https?:\/\//i.test(href)) return null;
|
|
246
250
|
try {
|
|
247
|
-
const bare = (h) => h.replace(/^www\./, "");
|
|
248
251
|
const to = new URL(href);
|
|
249
|
-
return
|
|
252
|
+
return isSameSite(site || here, href) || isSameSite(here, href) ? null : to.host;
|
|
250
253
|
} catch {
|
|
251
254
|
return null;
|
|
252
255
|
}
|
|
@@ -409,8 +412,10 @@ class Visit {
|
|
|
409
412
|
* @param study 리서치에 참여 중인 방문이면 { press(key) } — 위젯 단추(과제 건너뛰기)를 누른다(hub · browser/study.js).
|
|
410
413
|
* 그 방문에서 리서치 초대 카드는 질문 카드로 다루지 않는다
|
|
411
414
|
*/
|
|
412
|
-
constructor(page, person, { rhythm = null, shape = null, allowAccounts = false, policy = null, offsite = [], notSent = [], external = [], study = null, log = () => {} } = {}) {
|
|
415
|
+
constructor(page, person, { rhythm = null, shape = null, allowAccounts = false, policy = null, offsite = [], notSent = [], external = [], study = null, siteOrigin = null, log = () => {} } = {}) {
|
|
413
416
|
this.page = page;
|
|
417
|
+
/** 사이트 주소 — 하위 도메인 페이지는 경로 앞에 호스트를 붙여 적는다(about.bluetags.app/docs) */
|
|
418
|
+
this.siteOrigin = siteOrigin;
|
|
414
419
|
this.study = study;
|
|
415
420
|
this.person = person;
|
|
416
421
|
/** 방문 시작 — 걸음마다 몇 초째인지(t) 적는다 */
|
|
@@ -509,10 +514,22 @@ class Visit {
|
|
|
509
514
|
return c && !(this.study && c.invite) ? c : null;
|
|
510
515
|
}
|
|
511
516
|
|
|
517
|
+
/** 하위 도메인이면 호스트를 붙인 경로 — 사이트의 "/" 와 섞이지 않게 */
|
|
518
|
+
hostPath(p) {
|
|
519
|
+
try {
|
|
520
|
+
const u = new URL(this.page.url());
|
|
521
|
+
// www 는 같은 곳이다(사이트 주소가 bluetags.app 이어도 페이지는 www.bluetags.app)
|
|
522
|
+
const bare = (h) => h.replace(/^www\./, "");
|
|
523
|
+
return this.siteOrigin && /^https?:$/.test(u.protocol) && bare(u.host) !== bare(new URL(this.siteOrigin).host) ? u.host + p : p;
|
|
524
|
+
} catch {
|
|
525
|
+
return p;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
512
529
|
pathNow() {
|
|
513
530
|
try {
|
|
514
531
|
const u = new URL(this.page.url());
|
|
515
|
-
return u.pathname + u.search;
|
|
532
|
+
return this.hostPath(u.pathname + u.search);
|
|
516
533
|
} catch {
|
|
517
534
|
return "";
|
|
518
535
|
}
|
|
@@ -647,6 +664,7 @@ class Visit {
|
|
|
647
664
|
// 카드 판단에는 페이지 전체를 준다 — 이 방문에서 본 것을 다 기억하는 사람이다
|
|
648
665
|
if (this.pending) return { card: this.pending, text: await screenText(this.page) };
|
|
649
666
|
const view = await listControls(this.page);
|
|
667
|
+
view.path = this.hostPath(view.path);
|
|
650
668
|
// 위쪽만 빠진 것은 넘침이 아니다 — 굴려 올리면 다시 나온다
|
|
651
669
|
if (view.droppedInView > (view.droppedIcons || 0)) this.diag.push({ kind: "controls_overflow", detail: `dropped ${view.droppedInView - (view.droppedIcons || 0)}` });
|
|
652
670
|
const text = await visibleText(this.page, 2500);
|
|
@@ -679,7 +697,7 @@ class Visit {
|
|
|
679
697
|
t: Date.now() - this.t0,
|
|
680
698
|
path: (() => {
|
|
681
699
|
try {
|
|
682
|
-
return new URL(this.page.url()).pathname;
|
|
700
|
+
return this.hostPath(new URL(this.page.url()).pathname);
|
|
683
701
|
} catch {
|
|
684
702
|
return null;
|
|
685
703
|
}
|
|
@@ -761,9 +779,10 @@ class Visit {
|
|
|
761
779
|
await page.mouse.move(600, -8, { steps: 2 }).catch(() => {});
|
|
762
780
|
}
|
|
763
781
|
this.ended = true;
|
|
764
|
-
// 떠나려는 순간에 뜨는 카드가
|
|
765
|
-
|
|
766
|
-
|
|
782
|
+
// 떠나려는 순간에 뜨는 카드가 있다(데스크톱 커서). 휴대폰은 사이트 밖으로 나가는 순간을 SDK 가 못 잡는다 —
|
|
783
|
+
// 예전엔 15초 멈춤으로 대신해 16초를 기다렸다(core #68 에서 뺐다). 화면을 옮길 때 뜨는 카드는 다음 조작에서 본다
|
|
784
|
+
if (!this.card && this.person.device !== "mobile") {
|
|
785
|
+
const parting = this.asCard(await waitCard(page, 6000));
|
|
767
786
|
if (parting) this.pending = parting;
|
|
768
787
|
}
|
|
769
788
|
return record;
|
|
@@ -945,7 +964,7 @@ class Visit {
|
|
|
945
964
|
|
|
946
965
|
// 사이트 밖으로 가는 링크였다. 끊었으니 방문자는 그대로 이 화면에 있다.
|
|
947
966
|
// 새 탭으로 여는 링크(target=_blank)는 방어선을 안 지나고 열린다 — 누른 링크 주소로도 본다(탭은 hub 가 닫는다)
|
|
948
|
-
const hrefOff = decision.action === "click" && !record.skipped ? offsiteHost(item?.href, before) : null;
|
|
967
|
+
const hrefOff = decision.action === "click" && !record.skipped ? offsiteHost(item?.href, before, this.siteOrigin) : null;
|
|
949
968
|
if (this.offsite.length > offsiteBefore) {
|
|
950
969
|
try {
|
|
951
970
|
record.offsite = new URL(this.offsite[this.offsite.length - 1]).host;
|