@usercase/mcp 0.2.4-next.7 → 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 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. Record paths for the tasks that have none. If every task already has one, ask the user which to redo.
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
 
@@ -56,14 +57,15 @@ function describeInPage(n, { sel: pickSel, lift = false }) {
56
57
  return false;
57
58
  };
58
59
  for (let x = e; x && x !== document.body; x = x.parentElement) {
60
+ if (x.matches(pickSel)) return x;
59
61
  if (reactClick(x)) return fits(x) ? x : e;
60
62
  }
61
63
  let top = e;
62
64
  for (let x = e.parentElement; x && x !== document.body && getComputedStyle(x).cursor === "pointer" && fits(x); x = x.parentElement) top = x;
63
65
  return top;
64
66
  };
65
- const hit = n.closest && n.closest(pickSel);
66
- const pick = hit || (lift ? liftInPage(n) : n);
67
+ // 카드 전체가 링크이고 안에 onClick 저장 버튼이 있으면 저장 버튼을 기록한다 — 링크부터 찾으면 카드 전체가 잡혔다(10/27 홈 목록)
68
+ const pick = lift ? liftInPage(n) : (n.closest && n.closest(pickSel)) || n;
67
69
  const xp = (e) => {
68
70
  const parts = [];
69
71
  while (e && e.nodeType === 1) {
@@ -250,7 +252,9 @@ class PathRecorder {
250
252
  const file = path.join(c.dir, `${String(c.steps.length + 1).padStart(2, "0")}-click.jpg`);
251
253
  await pickH.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
252
254
  await this.shot(file, pickH);
253
- step = { type: "XPATH", url: at, concrete: at, xpath: info.xpath, classes: info.classes, ids: info.ids, label: info.label, tag: info.tag, image: file, pressed: press };
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 };
254
258
  c.steps.push(step);
255
259
  }
256
260
  c.actions.push({ do: "click", xpath: info.xpath, label: info.label, press, step: step ? c.steps.length - 1 : null });
@@ -400,7 +404,7 @@ class PathRecorder {
400
404
  const steps = c.steps.slice(0, end + 1);
401
405
  const cut = c.steps.slice(end + 1);
402
406
  const body = {
403
- 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") })),
404
408
  };
405
409
  if (success === "page") body.success = { condition: "url_entry", targetUrl: last.url };
406
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
- { description: "Record the expected path of a Usercase research's tasks", argsSchema: { research: z.string().optional() } },
715
- ({ research }) => {
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.7",
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",