@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.
- package/package.json +1 -1
- 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.
|
|
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
|
-
*
|
|
51
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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 };
|