@usercase/mcp 0.2.2 → 0.2.3-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/hub.js CHANGED
@@ -459,6 +459,7 @@ class Hub {
459
459
  if (r.sentWithEnter) said.push("You pressed Enter to send it, as you would in a chat box.");
460
460
  if (r.heldEnter) said.push("The screen changed as you typed, so you did not press Enter — what you see is what typing alone did. If you would press Enter now, type the same text into that field again.");
461
461
  if (r.fileWindow) said.push(r.fileWindow);
462
+ if (r.backOut) said.push("This is the first page you opened on this site, so going back from here would take you off the site. You are still on the page you were on — if leaving the site is what you meant, leave; otherwise carry on here.");
462
463
  if (r.openedApp) said.push("That tried to open another app outside the browser (a share or app link). Nothing opened, and you are back on the page you were on.");
463
464
  if (r.swiped) said.push("It was cut off at the edge, so you swiped that row sideways to bring it into view, then tapped it.");
464
465
  // 개인정보 칸. 사이트가 막은 게 아니라 이 사람이 안 친 것이다 — 그렇게 알린다
@@ -521,7 +522,7 @@ class Hub {
521
522
  };
522
523
  ws.appendRun(row);
523
524
  // 대시보드로 — 키가 없으면 건너뛴다. 실패해도 방문은 끝낸다
524
- const uploaded = await report.sendVisit(row, rec.person, rec.startedAt).catch((e) => `⚠ Could not upload to the dashboard (${e.message})`);
525
+ 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})`);
525
526
  // 도구가 하지 못한 것들 — 우리 쪽 진단으로(사이트 글자 없음). 기본으로 켜져 있다, USERCASE_DIAGNOSTICS=off 로 끈다
526
527
  if (rec.visit && rec.visit.diag && rec.visit.diag.length) await report.sendDiagnostics(rec.visit.diag, rec.person.device).catch(() => 0);
527
528
  const doc = ws.loadPeople();
package/mcp/report.js CHANGED
@@ -123,13 +123,32 @@ function payload(row, person, startedAt) {
123
123
  };
124
124
  }
125
125
 
126
+ /**
127
+ * 문제가 있던 순간의 화면을 방문 id 로 먼저 올린다(서버 R2 screens/). 못 올린 걸음은 표시를 지운다 —
128
+ * 대시보드가 없는 사진 자리를 남기지 않게. 사진은 다시 보내지 않는다(outbox 에 넣지 않는다)
129
+ */
130
+ async function sendScreens(item, screens) {
131
+ if (!screens || !screens.length) return item;
132
+ const failed = new Set();
133
+ for (const s of screens) {
134
+ try {
135
+ await call("POST", "/agent/screens", { id: item.id, step: s.step, image: s.image.toString("base64") }, 30000);
136
+ } catch {
137
+ failed.add(s.step);
138
+ }
139
+ }
140
+ const sent = new Set(screens.map((s) => s.step).filter((n) => !failed.has(n)));
141
+ return { ...item, steps: (item.steps || []).map((s) => (s && s.screen && !sent.has(s.step) ? { ...s, screen: undefined } : s)) };
142
+ }
143
+
126
144
  /**
127
145
  * 방문 하나를 보내고, 전에 못 보낸 것도 같이 보낸다. 실패해도 방문은 막지 않는다 — 결과 한 줄만 돌려준다.
146
+ * @param screens 문제가 있던 순간의 화면 [{ step, image }] (Visit.screens)
128
147
  * @returns 요약에 붙일 한 줄, 키가 없으면 null
129
148
  */
130
- async function sendVisit(row, person, startedAt) {
149
+ async function sendVisit(row, person, startedAt, screens = []) {
131
150
  if (!KEY_RE.test(apiKey())) return null;
132
- const queue = [...readOutbox(), payload(row, person, startedAt)];
151
+ const queue = [...readOutbox(), await sendScreens(payload(row, person, startedAt), screens)];
133
152
  const left = [];
134
153
  let sent = 0;
135
154
  let lastErr = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.2",
3
+ "version": "0.2.3-next.1",
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",
package/src/run/visit.js CHANGED
@@ -42,6 +42,83 @@ const STEP_SCHEMA = {
42
42
  /** 같은 조작을 연달아 몇 번까지. 굴리며 훑기·안 눌려서 거듭 누르기 */
43
43
  const MAX_TIMES = { click: 4, scroll: 5, scroll_up: 5 };
44
44
 
45
+ /**
46
+ * 문제가 있던 순간의 화면 — 대시보드가 그 걸음 아래에 보인다(서버 /agent/screens). **방문자(AI)에게는 안 보인다.**
47
+ * 걸음마다 찍지 않는다. 영상(세션 리플레이)은 봇의 움직임이 드러나 쓰지 않기로 했다(10/21)
48
+ * no_change 눌렀는데 화면이 그대로
49
+ * left_unhappy 원하던 걸 못 이루고 떠남(leave, done=false) — 마지막 화면
50
+ * covered 사이트의 무언가(팝업 등)가 덮어 못 누름 — 우리 카드·위젯이 덮은 것은 뺀다
51
+ * error 화면이 깨짐 — 문서가 오류로 옴(4xx·5xx), 오류 문구뿐인 화면.
52
+ * 끝내 안 열리는 화면은 못 찍는다 — 불러오는 중에는 브라우저가 화면을 안 내준다(10/22 시험)
53
+ * 우리 카드(Usercase 질문 카드)는 찍지 않는다 — 사이트 문제가 아니다. 카드가 떠 있는 동안도 안 찍는다(10/22)
54
+ * 누른 곳(box)은 사진에 그리지 않고 걸음에 비율(0~1)로 적는다. 대시보드가 위에 그린다
55
+ */
56
+ const MAX_SCREENS = 6;
57
+ // 서버 앞 nginx 의 기본 본문 한도가 1MB 다 — base64 로 커지니 원본은 600KB 아래로
58
+ const MAX_SCREEN_BYTES = 600 * 1024;
59
+
60
+ /**
61
+ * 오류 문구뿐인 화면인가 — 404·"not found"·"application error" 등. 글이 짧은 화면만 본다(기사 속 "404" 는 아니다).
62
+ * 클라이언트에서 그리는 404(Next.js 등)는 문서가 200 으로 와서 상태로는 모른다
63
+ */
64
+ async function errorScreen(page) {
65
+ return page
66
+ .evaluate(() => {
67
+ const t = ((document.body && document.body.innerText) || "").replace(/s+/g, " ").trim();
68
+ if (t.length > 600) return false;
69
+ return /(404|500|502|503)|not found|page (doesn.?t|does not|could not|cannot) (exist|be found)|application error|something went wrong|internal server error/i.test(t);
70
+ })
71
+ .catch(() => false);
72
+ }
73
+
74
+ /** 요소가 화면에서 차지한 곳(화면 비율). 화면 밖이거나 사라졌으면 null */
75
+ async function boxOf(page, el) {
76
+ const b = el ? await el.boundingBox().catch(() => null) : null;
77
+ const vp = page.viewportSize();
78
+ if (!b || !vp || b.width <= 0 || b.height <= 0) return null;
79
+ const x0 = Math.max(0, b.x);
80
+ const y0 = Math.max(0, b.y);
81
+ const x1 = Math.min(vp.width, b.x + b.width);
82
+ const y1 = Math.min(vp.height, b.y + b.height);
83
+ if (x1 <= x0 || y1 <= y0) return null;
84
+ const r = (n) => Math.round(n * 1000) / 1000;
85
+ return { x: r(x0 / vp.width), y: r(y0 / vp.height), w: r((x1 - x0) / vp.width), h: r((y1 - y0) / vp.height) };
86
+ }
87
+
88
+ /**
89
+ * 누르기 전후를 견줄 지문 — 주소, 페이지 전체 글(보이는 것만, 굴려도 같다), 보이는 입력칸·그림, 누른 요소의 상태(색만 바뀌는 좋아요·토글).
90
+ * 입력칸은 글(innerText)에 안 잡힌다 — 검색 아이콘을 눌러 검색칸이 열렸는데 "그대로" 로 찍혔다(10/22 실험실 e4)
91
+ * 글이 저절로 바뀌는 사이트(넘어가는 배너)에서는 "바뀌었다" 로 본다 — 그대로라고 잘못 찍는 것보다 낫다
92
+ */
93
+ async function clickPrint(page, el) {
94
+ const doc = await page
95
+ .evaluate(() => {
96
+ const shown = (n) => {
97
+ const r = n.getBoundingClientRect();
98
+ return r.width > 0 && r.height > 0 && getComputedStyle(n).visibility !== "hidden";
99
+ };
100
+ const extra = [...document.querySelectorAll("input,textarea,select,[contenteditable=true],img,video,iframe,canvas")]
101
+ .filter(shown)
102
+ .map((n) => [n.tagName, n.getAttribute("placeholder"), n.getAttribute("aria-label"), n.getAttribute("src")].join(":"))
103
+ .join("|");
104
+ const t = ((document.body && document.body.innerText) || "") + "|" + extra;
105
+ let h = 5381;
106
+ for (let i = 0; i < t.length; i++) h = ((h << 5) + h + t.charCodeAt(i)) | 0;
107
+ return `${t.length}:${h}`;
108
+ })
109
+ .catch(() => null);
110
+ const own = el
111
+ ? await el
112
+ .evaluate((n) =>
113
+ n.isConnected
114
+ ? [n.className && String(n.className), ...["aria-pressed", "aria-expanded", "aria-checked", "aria-selected", "aria-current", "data-state"].map((a) => n.getAttribute(a)), n.checked, n.value, (n.textContent || "").slice(0, 200)].join("|")
115
+ : null,
116
+ )
117
+ .catch(() => null)
118
+ : null;
119
+ return { url: page.url(), doc, own };
120
+ }
121
+
45
122
  /**
46
123
  * 클릭 뒤 화면이 자리 잡기를 기다린다. 사람도 눌렀으면 뜰 때까지 본다.
47
124
  * 안 기다리면 느린 화면이 "눌러도 안 열린다" 로 읽힌다 — 10/13 bluetags 에서 카드가 "안 열린다 → 조금 뒤 열렸다".
@@ -301,6 +378,15 @@ class Visit {
301
378
  // 손은 방문 내내 이어진다 — 커서가 있던 자리에서 다음 곳으로 간다
302
379
  this.handState = { rhythm, mouse: null };
303
380
  this.steps = [];
381
+ /** 이번 조작으로 받은 문서의 상태 코드(본 탭의 화면 이동만). 조작마다 비운다 */
382
+ this.docStatus = null;
383
+ page.on("response", (res) => {
384
+ try {
385
+ if (res.request().isNavigationRequest() && res.frame() === page.mainFrame()) this.docStatus = res.status();
386
+ } catch {}
387
+ });
388
+ /** 문제가 있던 순간의 화면 { step, image(JPEG) } — 방문이 끝나면 hub 가 서버로 올린다 */
389
+ this.screens = [];
304
390
  this.history = [];
305
391
  this.seenPaths = new Set();
306
392
  this.card = null; // 처리한 카드(한 방문에 하나)
@@ -453,9 +539,11 @@ class Visit {
453
539
  const externalBefore = this.external.length;
454
540
  this.fileWindow = null;
455
541
  this.lastAction = decision.action;
542
+ this.docStatus = null;
456
543
 
457
544
  if (decision.action === "leave") {
458
545
  record.done = !!decision.done;
546
+ if (!record.done) await this.shoot(record, "left_unhappy");
459
547
  this.steps.push(record);
460
548
  this.log(`${record.step}. left — ${decision.why}`);
461
549
  // 떠나는 모양도 사람처럼. 데스크톱은 커서가 창 위로 빠져나간다
@@ -475,6 +563,7 @@ class Visit {
475
563
 
476
564
  const net = watchNet(page);
477
565
  let diagEl = null; // 진단에 생김새를 남길 요소
566
+ let probe = null; // 누르기 전 지문 — 눌렀는데 그대로였나
478
567
  if (decision.action === "read") {
479
568
  // 가만히 읽는 시간. 사람은 계속 누르지 않는다 — 이게 없으면 "N초 머묾" 같은 조건이 영영 안 걸린다.
480
569
  // 기다리는 건 다음 조작 때(waitTurn). 그 사이 카드가 뜨면 다음 조작 전에 본다
@@ -582,6 +671,7 @@ class Visit {
582
671
  // 같은 사이트를 새 탭으로 여는 링크는 이 탭에서 열게 한다. 사람은 열린 탭에서 이어 보고, 방문은 한 탭이다.
583
672
  // 새 탭을 잡아 옮기는 건(hub) 브라우저가 새 탭을 늦게, 주소 없이 알려 줘서 놓쳤다 —
584
673
  // 방문자는 "안 열린다" 를 보고 다시 누르면 "없어졌다" 가 됐다(10/14 실험실 p3, 첫 화면 카드)
674
+ probe = { el, before: await clickPrint(page, el) };
585
675
  await el
586
676
  .evaluate((n) => {
587
677
  const a = n.closest("a[target]");
@@ -589,9 +679,13 @@ class Visit {
589
679
  })
590
680
  .catch(() => {});
591
681
  // 사람 손이라 빗나갈 수 있다. 빈 곳을 누르면 아무 일도 안 일어난다 — 그게 헛클릭이다
682
+ let ours = false;
592
683
  const clicked = await hand
593
684
  .clickLikePerson(page, this.handState, el)
594
- .catch((e) => ((record.skipped = e.human || humanClickError(e)), { missed: false }));
685
+ .catch((e) => ((record.skipped = e.human || humanClickError(e)), (ours = /eb-insight|eb-plugin/.test(String(e.message))), { missed: false }));
686
+ // 사이트의 무언가가 덮어 못 눌렀다. 우리 카드·위젯이 덮은 것은 사이트 탓이 아니다
687
+ if (record.skipped && /on top of it/.test(record.skipped) && !/survey card/.test(record.skipped) && !ours)
688
+ await this.shoot(record, "covered", await boxOf(page, el));
595
689
  if (clicked.missed) {
596
690
  record.missed = true;
597
691
  this.history.push("Your click landed just outside it and nothing happened.");
@@ -621,8 +715,14 @@ class Visit {
621
715
  if (/^https?:/i.test(before) && !/^https?:/i.test(page.url())) {
622
716
  await page.goto(before, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
623
717
  await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
624
- record.openedApp = true;
625
- this.diag.push({ kind: "left_to_blank", action: decision.action, shape: null });
718
+ // 뒤로 가기가 빈 페이지로 간 것은 앱이 아니라 방문의 시작보다 앞으로 간 것이다(바로 들어온 첫 페이지).
719
+ // "다른 앱을 열려 했다" 고 알리면 방문자가 사이트 탓으로 적었다(10/22 실험실 q4)
720
+ if (decision.action === "back") {
721
+ record.backOut = true;
722
+ } else {
723
+ record.openedApp = true;
724
+ this.diag.push({ kind: "left_to_blank", action: decision.action, shape: null });
725
+ }
626
726
  }
627
727
 
628
728
  // 사이트 밖으로 가는 링크였다. 끊었으니 방문자는 그대로 이 화면에 있다.
@@ -652,9 +752,38 @@ class Visit {
652
752
  if (this.fileWindow) record.fileWindow = fileWindowNote(this.fileWindow);
653
753
  await this.noteDiag(record, decision, diagEl);
654
754
 
755
+ // 화면이 깨졌다 — 오류로 온 문서, 오류 문구뿐인 화면. 바깥 사이트·앱·빈 페이지로 간 것은 뺀다
756
+ if (
757
+ !record.offsite &&
758
+ !record.openedApp &&
759
+ !record.backOut &&
760
+ ((this.docStatus && this.docStatus >= 400) || (page.url() !== before && (await errorScreen(page))))
761
+ )
762
+ await this.shoot(record, "error");
763
+
764
+ // 눌렀는데 그대로. 우리 쪽 사정(빗나감·막은 요청·바깥 사이트·파일 창)으로 그대로인 것은 빼고, 같은 것은 한 번만
765
+ if (
766
+ probe &&
767
+ !record.skipped &&
768
+ !record.missed &&
769
+ !record.closedOverlay &&
770
+ !record.offsite &&
771
+ !record.notSent &&
772
+ !record.notSentQuiet &&
773
+ !record.external &&
774
+ !record.fileWindow &&
775
+ !record.openedApp &&
776
+ !record.backOut &&
777
+ !this.steps.some((s) => s.screen && s.screen.kind === "no_change" && s.label === record.label && s.path === record.path)
778
+ ) {
779
+ const after = await clickPrint(page, probe.el);
780
+ if (after.url === probe.before.url && after.doc && after.doc === probe.before.doc && after.own !== null && after.own === probe.before.own)
781
+ await this.shoot(record, "no_change", await boxOf(page, probe.el));
782
+ }
783
+
655
784
  this.steps.push(record);
656
785
  this.history.push(
657
- `${decision.action}${times > 1 ? ` ×${times}` : ""}${item ? ` "${item.label}"` : ""} — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}${record.cappedTimes ? " (that is the most in one go)" : ""}${record.offsite ? ` (it leads to another website, ${record.offsite})` : ""}${record.notSent ? " (it would have sent something to the site; it was not sent)" : ""}${record.fileWindow ? " (a window to choose a file opened; you closed it without choosing)" : ""}${record.personalField !== undefined ? " (you chose not to enter personal details there)" : ""}${record.openedApp ? " (it tried to open another app; nothing opened)" : ""}`,
786
+ `${decision.action}${times > 1 ? ` ×${times}` : ""}${item ? ` "${item.label}"` : ""} — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}${record.cappedTimes ? " (that is the most in one go)" : ""}${record.offsite ? ` (it leads to another website, ${record.offsite})` : ""}${record.notSent ? " (it would have sent something to the site; it was not sent)" : ""}${record.fileWindow ? " (a window to choose a file opened; you closed it without choosing)" : ""}${record.personalField !== undefined ? " (you chose not to enter personal details there)" : ""}${record.openedApp ? " (it tried to open another app; nothing opened)" : ""}${record.backOut ? " (going back from here would have taken you off the site; you stayed)" : ""}`,
658
787
  );
659
788
  this.log(`${record.step}. ${decision.action}${item ? ` "${String(item.label).slice(0, 30)}"` : ""}${record.skipped ? ` · ${record.skipped}` : ""} — ${decision.why}`);
660
789
  this.lastView = null;
@@ -677,6 +806,31 @@ class Visit {
677
806
  this.diag.push({ kind: "reclick_same", action, shape: await diag.shapeOf(el) });
678
807
  }
679
808
 
809
+ /**
810
+ * 문제가 있던 화면을 찍어 이 걸음에 붙인다(record.screen). 보이는 만큼만(뷰포트). 크면 한 번 더 작게, 그래도 크면 안 찍는다
811
+ * 실패해도 방문은 그대로 간다
812
+ */
813
+ async shoot(record, kind, box = null) {
814
+ if (!record || record.screen || this.screens.length >= MAX_SCREENS) return;
815
+ // 우리 카드가 떠 있다. 눌렀는데 그대로인 것도 카드가 가로챈 것일 수 있다 — 사이트 탓으로 찍지 않는다
816
+ const card = await readCard(this.page).catch(() => null);
817
+ if (card && card.visible) return;
818
+ const image = await this.snap();
819
+ if (!image) return;
820
+ record.screen = { kind, box };
821
+ this.screens.push({ step: record.step, image });
822
+ }
823
+
824
+ /** 뷰포트 JPEG. 크면 한 번 더 작게, 그래도 크면 null */
825
+ async snap() {
826
+ let image = null;
827
+ for (const quality of [70, 45]) {
828
+ image = await this.page.screenshot({ type: "jpeg", quality, scale: "css", timeout: 5000 }).catch(() => null);
829
+ if (!image || image.length <= MAX_SCREEN_BYTES) break;
830
+ }
831
+ return image && image.length <= MAX_SCREEN_BYTES ? image : null;
832
+ }
833
+
680
834
  /** 카드 판단에 넘길 "이번 방문에 한 일" */
681
835
  pathText() {
682
836
  return this.history.length ? this.history.slice(-6).join(" / ") : "just arrived";