@usercase/mcp 0.2.3-next.0 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/run/visit.js +55 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.3-next.0",
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
@@ -47,14 +47,30 @@ const MAX_TIMES = { click: 4, scroll: 5, scroll_up: 5 };
47
47
  * 걸음마다 찍지 않는다. 영상(세션 리플레이)은 봇의 움직임이 드러나 쓰지 않기로 했다(10/21)
48
48
  * no_change 눌렀는데 화면이 그대로
49
49
  * left_unhappy 원하던 걸 못 이루고 떠남(leave, done=false) — 마지막 화면
50
- * bounced 들어오자마자 뒤로
51
- * card 질문 카드가 뜬 화면 — 카드는 걸음이 아니라 바로 앞 걸음에 붙인다(첫 걸음 전이면 다음 걸음)
50
+ * covered 사이트의 무언가(팝업 등)가 덮어 못 누름 — 우리 카드·위젯이 덮은 것은 뺀다
51
+ * error 화면이 깨짐 — 문서가 오류로 옴(4xx·5xx), 오류 문구뿐인 화면.
52
+ * 끝내 안 열리는 화면은 못 찍는다 — 불러오는 중에는 브라우저가 화면을 안 내준다(10/22 시험)
53
+ * 우리 카드(Usercase 질문 카드)는 찍지 않는다 — 사이트 문제가 아니다. 카드가 떠 있는 동안도 안 찍는다(10/22)
52
54
  * 누른 곳(box)은 사진에 그리지 않고 걸음에 비율(0~1)로 적는다. 대시보드가 위에 그린다
53
55
  */
54
56
  const MAX_SCREENS = 6;
55
57
  // 서버 앞 nginx 의 기본 본문 한도가 1MB 다 — base64 로 커지니 원본은 600KB 아래로
56
58
  const MAX_SCREEN_BYTES = 600 * 1024;
57
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
+
58
74
  /** 요소가 화면에서 차지한 곳(화면 비율). 화면 밖이거나 사라졌으면 null */
59
75
  async function boxOf(page, el) {
60
76
  const b = el ? await el.boundingBox().catch(() => null) : null;
@@ -70,13 +86,22 @@ async function boxOf(page, el) {
70
86
  }
71
87
 
72
88
  /**
73
- * 누르기 전후를 견줄 지문 — 주소, 페이지 전체 글(보이는 것만, 굴려도 같다), 누른 요소의 상태(색만 바뀌는 좋아요·토글).
89
+ * 누르기 전후를 견줄 지문 — 주소, 페이지 전체 글(보이는 것만, 굴려도 같다), 보이는 입력칸·그림, 누른 요소의 상태(색만 바뀌는 좋아요·토글).
90
+ * 입력칸은 글(innerText)에 안 잡힌다 — 검색 아이콘을 눌러 검색칸이 열렸는데 "그대로" 로 찍혔다(10/22 실험실 e4)
74
91
  * 글이 저절로 바뀌는 사이트(넘어가는 배너)에서는 "바뀌었다" 로 본다 — 그대로라고 잘못 찍는 것보다 낫다
75
92
  */
76
93
  async function clickPrint(page, el) {
77
94
  const doc = await page
78
95
  .evaluate(() => {
79
- const t = (document.body && document.body.innerText) || "";
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;
80
105
  let h = 5381;
81
106
  for (let i = 0; i < t.length; i++) h = ((h << 5) + h + t.charCodeAt(i)) | 0;
82
107
  return `${t.length}:${h}`;
@@ -353,6 +378,13 @@ class Visit {
353
378
  // 손은 방문 내내 이어진다 — 커서가 있던 자리에서 다음 곳으로 간다
354
379
  this.handState = { rhythm, mouse: null };
355
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
+ });
356
388
  /** 문제가 있던 순간의 화면 { step, image(JPEG) } — 방문이 끝나면 hub 가 서버로 올린다 */
357
389
  this.screens = [];
358
390
  this.history = [];
@@ -447,9 +479,6 @@ class Visit {
447
479
  async answer(decision) {
448
480
  const shown = this.pending;
449
481
  if (!shown) throw new Error("No card is showing");
450
- // 첫 걸음 전에 뜬 카드(들어오자마자)는 붙일 걸음이 없다 — 찍어 두었다가 다음 걸음에 붙인다(10/22 실험실 w5)
451
- if (this.steps.length) await this.shoot(this.steps[this.steps.length - 1], "card");
452
- else this.early = { kind: "card", image: await this.snap() };
453
482
  const act = answer.toAction(decision, shown);
454
483
  // 카드를 읽고 정하는 시간. 닫는 건 금방, 쓰는 건 치는 만큼
455
484
  await sleep(act.kind === "close" ? 600 + Math.random() * 1800 : 1500 + Math.random() * 3000);
@@ -500,13 +529,6 @@ class Visit {
500
529
  const view = this.lastView || (await listControls(page));
501
530
  const item = view.items[decision.index];
502
531
  const record = { step: this.steps.length + 1, t: Date.now() - this.t0, path: view.path, action: decision.action, why: decision.why, label: item?.label ?? null };
503
- if (this.early) {
504
- if (this.early.image && this.screens.length < MAX_SCREENS) {
505
- record.screen = { kind: this.early.kind, box: null };
506
- this.screens.push({ step: record.step, image: this.early.image });
507
- }
508
- this.early = null;
509
- }
510
532
  const times = Math.max(1, Math.min(MAX_TIMES[decision.action] || 1, Math.round(Number(decision.times) || 1)));
511
533
  if (times > 1) record.times = times;
512
534
  // 한 번에 할 수 있는 수보다 많이 달라고 했다. 말없이 줄이면 "사이트가 클릭을 삼킨다" 로 읽었다(10/20 실험실 p7: 15·20·30번이 다 4번)
@@ -517,6 +539,7 @@ class Visit {
517
539
  const externalBefore = this.external.length;
518
540
  this.fileWindow = null;
519
541
  this.lastAction = decision.action;
542
+ this.docStatus = null;
520
543
 
521
544
  if (decision.action === "leave") {
522
545
  record.done = !!decision.done;
@@ -556,10 +579,6 @@ class Visit {
556
579
  }
557
580
  await sleep(250);
558
581
  } else if (decision.action === "back") {
559
- // 이 화면에 와서 한 걸음도 채 안 하고 돌아간다 — 들어오자마자 뒤로. 돌아가기 전 화면을 찍는다
560
- let here = 0;
561
- for (let i = this.steps.length - 1; i >= 0 && this.steps[i].path === view.path; i--) here++;
562
- if (here <= 1) await this.shoot(record, "bounced");
563
582
  await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
564
583
  if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "back" });
565
584
  } else if (decision.action === "type") {
@@ -660,9 +679,13 @@ class Visit {
660
679
  })
661
680
  .catch(() => {});
662
681
  // 사람 손이라 빗나갈 수 있다. 빈 곳을 누르면 아무 일도 안 일어난다 — 그게 헛클릭이다
682
+ let ours = false;
663
683
  const clicked = await hand
664
684
  .clickLikePerson(page, this.handState, el)
665
- .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));
666
689
  if (clicked.missed) {
667
690
  record.missed = true;
668
691
  this.history.push("Your click landed just outside it and nothing happened.");
@@ -696,11 +719,6 @@ class Visit {
696
719
  // "다른 앱을 열려 했다" 고 알리면 방문자가 사이트 탓으로 적었다(10/22 실험실 q4)
697
720
  if (decision.action === "back") {
698
721
  record.backOut = true;
699
- // 돌아가지 못했다 — 들어오자마자 뒤로(bounced)로 찍은 것은 무른다
700
- if (record.screen && record.screen.kind === "bounced") {
701
- delete record.screen;
702
- this.screens = this.screens.filter((s) => s.step !== record.step);
703
- }
704
722
  } else {
705
723
  record.openedApp = true;
706
724
  this.diag.push({ kind: "left_to_blank", action: decision.action, shape: null });
@@ -734,6 +752,15 @@ class Visit {
734
752
  if (this.fileWindow) record.fileWindow = fileWindowNote(this.fileWindow);
735
753
  await this.noteDiag(record, decision, diagEl);
736
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
+
737
764
  // 눌렀는데 그대로. 우리 쪽 사정(빗나감·막은 요청·바깥 사이트·파일 창)으로 그대로인 것은 빼고, 같은 것은 한 번만
738
765
  if (
739
766
  probe &&
@@ -780,11 +807,14 @@ class Visit {
780
807
  }
781
808
 
782
809
  /**
783
- * 지금 화면을 찍어 이 걸음에 붙인다(record.screen). 보이는 만큼만(뷰포트). 크면 한 번 더 작게, 그래도 크면 안 찍는다
810
+ * 문제가 있던 화면을 찍어 이 걸음에 붙인다(record.screen). 보이는 만큼만(뷰포트). 크면 한 번 더 작게, 그래도 크면 안 찍는다
784
811
  * 실패해도 방문은 그대로 간다
785
812
  */
786
813
  async shoot(record, kind, box = null) {
787
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;
788
818
  const image = await this.snap();
789
819
  if (!image) return;
790
820
  record.screen = { kind, box };