@usercase/mcp 0.2.4-next.6 → 0.2.4-next.8
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/path.md +2 -1
- package/mcp/fingerprint.js +117 -0
- package/mcp/path.js +40 -10
- package/mcp/server.js +8 -3
- package/package.json +1 -1
package/agents/path.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Record the expected path for a Usercase research
|
|
2
2
|
|
|
3
3
|
Research: {{research}}
|
|
4
|
+
{{task}}
|
|
4
5
|
|
|
5
6
|
A Usercase research asks real participants to do tasks on this site. For each task, the dashboard splits results into **went straight**, **took a detour** and **failed** — but only if the task has an **expected path**: the route the team intends people to take. Without one, every success counts as "went straight", so the results cannot show where people wander.
|
|
6
7
|
|
|
@@ -8,7 +9,7 @@ You record that path by walking it yourself in a browser. Unlike the virtual vis
|
|
|
8
9
|
|
|
9
10
|
## 1. See the tasks
|
|
10
11
|
|
|
11
|
-
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.
|
|
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
13
|
|
|
13
14
|
## 2. Work out the intended route — before opening the browser
|
|
14
15
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 경로 클릭 단계의 요소 특징 — SDK core/elementFingerprint.ts 의 clickableOf·fingerprintOf 와 **같아야 한다**.
|
|
4
|
+
* 위치(XPath)가 안 맞을 때 SDK 가 같은 요소인지 보조로 판정하는 데 쓴다(지금은 그림자 모드 — 진단만).
|
|
5
|
+
* 내용에 따라 블록 수가 다른 상세 페이지에서 같은 버튼이 div[5]/div[6] 으로 갈렸다(10/27 bluetags).
|
|
6
|
+
*
|
|
7
|
+
* 브라우저 안에서 도는 함수 하나다(page.evaluate 에 그대로 넘긴다) — 바깥 변수를 쓰면 안 된다.
|
|
8
|
+
*/
|
|
9
|
+
function fingerprintInPage(el) {
|
|
10
|
+
const PICK_SEL = 'a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],summary,label,input,select,textarea';
|
|
11
|
+
const ATTRS = ["aria-label", "title", "alt", "name", "type", "placeholder", "data-testid", "data-test", "data-cy", "data-action"];
|
|
12
|
+
const ZONES = [
|
|
13
|
+
["dialog", 'dialog,[role="dialog"],[role="alertdialog"],[aria-modal="true"]'],
|
|
14
|
+
["header", 'header,[role="banner"]'],
|
|
15
|
+
["nav", 'nav,[role="navigation"]'],
|
|
16
|
+
["aside", 'aside,[role="complementary"]'],
|
|
17
|
+
["footer", 'footer,[role="contentinfo"]'],
|
|
18
|
+
["main", 'main,[role="main"]'],
|
|
19
|
+
];
|
|
20
|
+
const mask = (s, max) =>
|
|
21
|
+
(s || "")
|
|
22
|
+
.replace(/\s+/g, " ")
|
|
23
|
+
.trim()
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/\d+/g, "#")
|
|
26
|
+
.slice(0, max);
|
|
27
|
+
const textOf = (e) => mask(e.textContent || "", 80);
|
|
28
|
+
const zoneRoot = (e) => {
|
|
29
|
+
for (const [zone, sel] of ZONES) {
|
|
30
|
+
const r = e.closest(sel);
|
|
31
|
+
if (r) return { zone, root: r };
|
|
32
|
+
}
|
|
33
|
+
return { zone: "body", root: e.ownerDocument.body };
|
|
34
|
+
};
|
|
35
|
+
const shapeOf = (e, root) => {
|
|
36
|
+
const parts = [];
|
|
37
|
+
for (let x = e; x && x !== root; x = x.parentElement) parts.unshift(x.tagName.toLowerCase());
|
|
38
|
+
return parts.join(">");
|
|
39
|
+
};
|
|
40
|
+
const hrefPattern = (e) => {
|
|
41
|
+
const a = e.closest("a[href]") || e.querySelector("a[href]");
|
|
42
|
+
const raw = a && a.getAttribute("href");
|
|
43
|
+
if (!raw) return "";
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(raw, e.ownerDocument.location && e.ownerDocument.location.href);
|
|
46
|
+
const same = u.origin === (e.ownerDocument.location && e.ownerDocument.location.origin);
|
|
47
|
+
const p = u.pathname
|
|
48
|
+
.split("/")
|
|
49
|
+
.map((seg) => (/^\d+$/.test(seg) || /^[0-9a-f]{8,}$/i.test(seg) || /^[0-9a-f-]{32,36}$/i.test(seg) ? ":id" : seg))
|
|
50
|
+
.join("/");
|
|
51
|
+
return (same ? "" : u.host) + p;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const iconOf = (e) => {
|
|
57
|
+
const svg = e.tagName.toLowerCase() === "svg" ? e : e.querySelector("svg");
|
|
58
|
+
if (!svg) return "";
|
|
59
|
+
const d = Array.from(svg.querySelectorAll("path"))
|
|
60
|
+
.slice(0, 3)
|
|
61
|
+
.map((p) => p.getAttribute("d") || "")
|
|
62
|
+
.join("|");
|
|
63
|
+
const src = (svg.getAttribute("viewBox") || "") + "|" + d;
|
|
64
|
+
if (src === "|") return "";
|
|
65
|
+
let h = 5381;
|
|
66
|
+
for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0;
|
|
67
|
+
return (h >>> 0).toString(36);
|
|
68
|
+
};
|
|
69
|
+
const attrsOf = (e) => {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const k of ATTRS) {
|
|
72
|
+
const v = e.getAttribute(k);
|
|
73
|
+
if (v && v.trim()) out[k] = mask(v, 60);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
const reactClick = (x) => {
|
|
78
|
+
for (const k in x) if (k.startsWith("__reactProps") && x[k] && typeof x[k].onClick === "function") return true;
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
81
|
+
// 올라가며 처음 만나는 링크·버튼 또는 React onClick — 카드 링크 안의 저장 버튼은 저장 버튼이다
|
|
82
|
+
const clickableOf = (n) => {
|
|
83
|
+
const win = n.ownerDocument.defaultView;
|
|
84
|
+
const body = n.ownerDocument.body;
|
|
85
|
+
const area = (x) => {
|
|
86
|
+
const r = x.getBoundingClientRect();
|
|
87
|
+
return r.width * r.height;
|
|
88
|
+
};
|
|
89
|
+
const base = Math.max(area(n), 1);
|
|
90
|
+
const fits = (x) => area(x) <= base * 6 && !Array.from(x.querySelectorAll(PICK_SEL)).some((c) => !c.contains(n) && !n.contains(c));
|
|
91
|
+
for (let x = n; x && x !== body; x = x.parentElement) {
|
|
92
|
+
if (x.matches(PICK_SEL)) return x;
|
|
93
|
+
if (reactClick(x)) return fits(x) ? x : n;
|
|
94
|
+
}
|
|
95
|
+
let top = n;
|
|
96
|
+
for (let x = n.parentElement; x && x !== body && win && win.getComputedStyle(x).cursor === "pointer" && fits(x); x = x.parentElement) top = x;
|
|
97
|
+
return top;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const e = clickableOf(el);
|
|
101
|
+
const { zone, root } = zoneRoot(e);
|
|
102
|
+
return {
|
|
103
|
+
v: 1,
|
|
104
|
+
zone,
|
|
105
|
+
tag: e.tagName.toLowerCase(),
|
|
106
|
+
role: e.getAttribute("role") || "",
|
|
107
|
+
shape: shapeOf(e, root),
|
|
108
|
+
text: textOf(e),
|
|
109
|
+
attrs: attrsOf(e),
|
|
110
|
+
href: hrefPattern(e),
|
|
111
|
+
icon: iconOf(e),
|
|
112
|
+
before: e.previousElementSibling ? mask(e.previousElementSibling.textContent || "", 30) : "",
|
|
113
|
+
after: e.nextElementSibling ? mask(e.nextElementSibling.textContent || "", 30) : "",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { fingerprintInPage };
|
package/mcp/path.js
CHANGED
|
@@ -17,6 +17,7 @@ const fs = require("fs");
|
|
|
17
17
|
const path = require("path");
|
|
18
18
|
const ws = require("./workspace.js");
|
|
19
19
|
const report = require("./report.js");
|
|
20
|
+
const { fingerprintInPage } = require("./fingerprint.js");
|
|
20
21
|
const { listControls, visibleText, handleAt, clickBlocked } = require("../src/browser/page.js");
|
|
21
22
|
const { renderScreen, settle, watchNet } = require("../src/run/visit.js");
|
|
22
23
|
|
|
@@ -37,8 +38,34 @@ const pathOnly = (u) => {
|
|
|
37
38
|
* 요소 하나를 SDK 가 맞출 수 있는 모양으로. 브라우저 안에서 돈다.
|
|
38
39
|
* XPath 는 SDK main/utils.ts getElementXPath 와 같은 방식(같은 이름 형제 사이 순번, svg 는 name())
|
|
39
40
|
*/
|
|
40
|
-
function describeInPage(n, pickSel) {
|
|
41
|
-
|
|
41
|
+
function describeInPage(n, { sel: pickSel, lift = false }) {
|
|
42
|
+
/**
|
|
43
|
+
* onClick 달린 div 로 만든 버튼은 태그로 안 잡힌다. 그러면 누른 조각(글자 span)만 기록돼, 같은 버튼의 아이콘을
|
|
44
|
+
* 누른 참여자는 다른 길로 갔다고 나왔다(10/27 bluetags: "Save" 글자 대 북마크 아이콘). 클릭을 받는 요소까지 올린다:
|
|
45
|
+
* React 가 onClick 을 붙인 가장 가까운 것, 없으면 손가락 커서가 이어지는 데까지. 둘 다 조작을 하나 더 품거나
|
|
46
|
+
* 처음 것보다 훨씬 크면(카드 전체) 멈춘다 — 아무 데나 눌러도 맞게 되면 안 된다
|
|
47
|
+
*/
|
|
48
|
+
const liftInPage = (e) => {
|
|
49
|
+
const area = (x) => {
|
|
50
|
+
const r = x.getBoundingClientRect();
|
|
51
|
+
return r.width * r.height;
|
|
52
|
+
};
|
|
53
|
+
const base = Math.max(area(e), 1);
|
|
54
|
+
const fits = (x) => area(x) <= base * 6 && !Array.from(x.querySelectorAll(pickSel)).some((c) => !c.contains(e) && !e.contains(c));
|
|
55
|
+
const reactClick = (x) => {
|
|
56
|
+
for (const k in x) if (k.startsWith("__reactProps") && x[k] && typeof x[k].onClick === "function") return true;
|
|
57
|
+
return false;
|
|
58
|
+
};
|
|
59
|
+
for (let x = e; x && x !== document.body; x = x.parentElement) {
|
|
60
|
+
if (x.matches(pickSel)) return x;
|
|
61
|
+
if (reactClick(x)) return fits(x) ? x : e;
|
|
62
|
+
}
|
|
63
|
+
let top = e;
|
|
64
|
+
for (let x = e.parentElement; x && x !== document.body && getComputedStyle(x).cursor === "pointer" && fits(x); x = x.parentElement) top = x;
|
|
65
|
+
return top;
|
|
66
|
+
};
|
|
67
|
+
// 카드 전체가 링크이고 안에 onClick 저장 버튼이 있으면 저장 버튼을 기록한다 — 링크부터 찾으면 카드 전체가 잡혔다(10/27 홈 목록)
|
|
68
|
+
const pick = lift ? liftInPage(n) : (n.closest && n.closest(pickSel)) || n;
|
|
42
69
|
const xp = (e) => {
|
|
43
70
|
const parts = [];
|
|
44
71
|
while (e && e.nodeType === 1) {
|
|
@@ -215,16 +242,19 @@ class PathRecorder {
|
|
|
215
242
|
return this.screen(
|
|
216
243
|
`Not pressed — ${blocked}. If this is where the participant's task ends (e.g. the final "Pay" or "Submit" button), call path_click again with press=false: it is recorded as the final click without pressing it.`,
|
|
217
244
|
);
|
|
218
|
-
const
|
|
219
|
-
const info = await el.evaluate(describeInPage, PICK_SEL);
|
|
245
|
+
const info = await el.evaluate(describeInPage, { sel: PICK_SEL, lift: true });
|
|
220
246
|
if (!info.xpath) return this.screen(`Could not work out where [${index}] is in the page. Try another element.`);
|
|
247
|
+
// 기록한 XPath 로 다시 찾은 것을 찍고 누른다 — SDK 가 참여자 화면에서 찾을 바로 그 요소다
|
|
248
|
+
const pickH = (await c.page.evaluateHandle(byXPathInPage, info.xpath)).asElement() || el;
|
|
221
249
|
const at = pathOnly(c.page.url());
|
|
222
250
|
let step = null;
|
|
223
251
|
if (record) {
|
|
224
252
|
const file = path.join(c.dir, `${String(c.steps.length + 1).padStart(2, "0")}-click.jpg`);
|
|
225
|
-
await pickH.
|
|
226
|
-
await this.shot(file, pickH
|
|
227
|
-
|
|
253
|
+
await pickH.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
|
|
254
|
+
await this.shot(file, pickH);
|
|
255
|
+
// 위치가 안 맞을 때(내용에 따라 칸이 밀리는 상세 페이지) SDK 가 보조로 맞춰 보는 요소 특징. 못 구하면 위치로만
|
|
256
|
+
const fingerprint = await pickH.evaluate(fingerprintInPage).catch(() => null);
|
|
257
|
+
step = { type: "XPATH", url: at, concrete: at, xpath: info.xpath, classes: info.classes, ids: info.ids, fingerprint, label: info.label, tag: info.tag, image: file, pressed: press };
|
|
228
258
|
c.steps.push(step);
|
|
229
259
|
}
|
|
230
260
|
c.actions.push({ do: "click", xpath: info.xpath, label: info.label, press, step: step ? c.steps.length - 1 : null });
|
|
@@ -232,7 +262,7 @@ class PathRecorder {
|
|
|
232
262
|
if (!press) return this.screen(`Recorded "${info.label || info.tag}" as the final click, without pressing it. The path ends here — call path_check next.`);
|
|
233
263
|
const before = c.page.url();
|
|
234
264
|
try {
|
|
235
|
-
await pickH.
|
|
265
|
+
await pickH.click({ timeout: 10000 });
|
|
236
266
|
} catch (e) {
|
|
237
267
|
if (step) c.steps.pop();
|
|
238
268
|
c.actions.pop();
|
|
@@ -251,7 +281,7 @@ class PathRecorder {
|
|
|
251
281
|
const c = this.need();
|
|
252
282
|
const el = c.view && c.view.items[index] ? await handleAt(c.page, index) : null;
|
|
253
283
|
if (!el) return this.screen(`[${index}] is not on the screen any more.`);
|
|
254
|
-
const info = await el.evaluate(describeInPage, "input,textarea,select,[contenteditable]");
|
|
284
|
+
const info = await el.evaluate(describeInPage, { sel: "input,textarea,select,[contenteditable]" });
|
|
255
285
|
await el.fill(text, { timeout: 10000 });
|
|
256
286
|
c.actions.push({ do: "type", xpath: info.xpath, text });
|
|
257
287
|
c.checked = null;
|
|
@@ -374,7 +404,7 @@ class PathRecorder {
|
|
|
374
404
|
const steps = c.steps.slice(0, end + 1);
|
|
375
405
|
const cut = c.steps.slice(end + 1);
|
|
376
406
|
const body = {
|
|
377
|
-
steps: steps.map((s) => ({ type: s.type, url: s.url, xpath: s.xpath || "", classes: s.classes || [], ids: s.ids || [], image: fs.readFileSync(s.image).toString("base64") })),
|
|
407
|
+
steps: steps.map((s) => ({ type: s.type, url: s.url, xpath: s.xpath || "", classes: s.classes || [], ids: s.ids || [], fingerprint: s.fingerprint || null, image: fs.readFileSync(s.image).toString("base64") })),
|
|
378
408
|
};
|
|
379
409
|
if (success === "page") body.success = { condition: "url_entry", targetUrl: last.url };
|
|
380
410
|
else if (success === "click") body.success = { condition: "element_click", xpath: last.xpath, xpathUrl: last.url, classes: last.classes, ids: last.ids };
|
package/mcp/server.js
CHANGED
|
@@ -711,12 +711,17 @@ server.registerPrompt(
|
|
|
711
711
|
|
|
712
712
|
server.registerPrompt(
|
|
713
713
|
"path",
|
|
714
|
-
{
|
|
715
|
-
|
|
714
|
+
{
|
|
715
|
+
description: "Record the expected path of a Usercase research's tasks",
|
|
716
|
+
// 대시보드 과제 화면의 명령은 과제 id 를 붙인다 — 그 과제만, 경로가 있어도 다시
|
|
717
|
+
argsSchema: { research: z.string().optional(), task: z.string().optional() },
|
|
718
|
+
},
|
|
719
|
+
({ research, task }) => {
|
|
716
720
|
const body = fs
|
|
717
721
|
.readFileSync(path.join(__dirname, "..", "agents", "path.md"), "utf8")
|
|
718
722
|
.replace(/\r\n/g, "\n")
|
|
719
|
-
.replace("{{research}}", research || "(ask the user — the dashboard shows it)")
|
|
723
|
+
.replace("{{research}}", research || "(ask the user — the dashboard shows it)")
|
|
724
|
+
.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");
|
|
720
725
|
return { messages: [{ role: "user", content: { type: "text", text: body } }] };
|
|
721
726
|
},
|
|
722
727
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usercase/mcp",
|
|
3
|
-
"version": "0.2.4-next.
|
|
3
|
+
"version": "0.2.4-next.8",
|
|
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",
|