@usercase/mcp 0.2.3-next.1 → 0.2.3-next.3
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/README.md +2 -2
- package/mcp/hub.js +11 -6
- package/mcp/report.js +5 -2
- package/package.json +1 -1
- package/src/browser/hand.js +117 -14
- package/src/run/diag.js +5 -2
- package/src/run/visit.js +232 -11
package/README.md
CHANGED
|
@@ -106,7 +106,7 @@ Claude suggests the moment, the question and its answers, and saves it only afte
|
|
|
106
106
|
- They stay on your site and block analytics tools such as Google Analytics and Hotjar.
|
|
107
107
|
- Either way they never pay, delete, connect a wallet, sign out or change a password, and never type into personal fields such as email or phone.
|
|
108
108
|
- They stop when blocked. They do not solve CAPTCHAs or get around bot protection.
|
|
109
|
-
- When
|
|
109
|
+
- When the tool cannot do something on your site or misreads the screen, a diagnostic is sent to Usercase so the tool gets better: what failed, the page path, the HTML around the element and a screenshot of that moment. Only the Usercase team sees it, and it is kept for 30 days. Turn it off with `USERCASE_DIAGNOSTICS=off`.
|
|
110
110
|
|
|
111
111
|
## Running on a production site
|
|
112
112
|
|
|
@@ -133,7 +133,7 @@ Pass with `claude mcp add ... -e NAME=value`.
|
|
|
133
133
|
| `USERCASE_API_KEY` | Your project API key. Without it, visits stay on your machine only. |
|
|
134
134
|
| `USERCASE_AGENT_KEY` | Your firewall secret (see above). 16+ characters. |
|
|
135
135
|
| `USERCASE_ALLOW_WRITE_PATHS` | Comma-separated path patterns where non-GET requests are allowed, for sites that load pages with `POST` after sign-in. Everything else stays blocked. |
|
|
136
|
-
| `USERCASE_DIAGNOSTICS` | `off` stops sending diagnostics. On by default: when
|
|
136
|
+
| `USERCASE_DIAGNOSTICS` | `off` stops sending diagnostics. On by default: when the tool cannot do something or misreads the screen (a button cut off or covered, a click that landed elsewhere, a change it did not notice), Usercase receives the kind of failure, the element's shape, the page path, the HTML around the element and a screenshot of that moment, to improve the tool. Only the Usercase team sees them; they are kept for 30 days. |
|
|
137
137
|
| `USERCASE_HEADED` | `1` shows the visitors' browser on screen. |
|
|
138
138
|
| `USERCASE_DIR` | Working folder. Default: `.usercase` in your repository. |
|
|
139
139
|
|
package/mcp/hub.js
CHANGED
|
@@ -200,10 +200,10 @@ class Hub {
|
|
|
200
200
|
? "· Writes: SENT — visitors really sign up, post, comment and save on this site (payments, deleting and sign-out stay blocked). Change with set_writes"
|
|
201
201
|
: "· Writes: not sent — visitors may press Post or Save, but nothing is written to the site. Ask the user before the first round whether to send them (set_writes)",
|
|
202
202
|
);
|
|
203
|
-
// 도구가 하지 못한 것(진단) —
|
|
203
|
+
// 도구가 하지 못한 것(진단) — 그 순간의 화면·요소 주변 HTML·페이지 경로가 우리 서버로 간다. 고객에게 숨기지 않는다
|
|
204
204
|
out.push(
|
|
205
205
|
require("../src/run/diag.js").enabled()
|
|
206
|
-
? "· Diagnostics on — when
|
|
206
|
+
? "· Diagnostics on — when the tool cannot do something or misreads the screen, Usercase gets what failed, the page path, the HTML around the element and a screenshot of that moment, to fix the tool. Only the Usercase team sees them; they are not shown in your dashboard. Turn off with USERCASE_DIAGNOSTICS=off"
|
|
207
207
|
: "· Diagnostics off (USERCASE_DIAGNOSTICS=off)",
|
|
208
208
|
);
|
|
209
209
|
// 지키는 선을 고객이 볼 수 있게. 막혔는데 왜인지 모르면 우리 도구 탓으로 읽힌다
|
|
@@ -401,6 +401,11 @@ class Hub {
|
|
|
401
401
|
const look = await v.look();
|
|
402
402
|
if (look.ended) return "This visit has ended.";
|
|
403
403
|
if (look.card) return renderCard(look.card);
|
|
404
|
+
// 브라우저 창(confirm 등)이 떠 있다. 페이지는 멈춰 있다 — 아이콘 그림·위치는 안 본다
|
|
405
|
+
if (look.view.dialog) {
|
|
406
|
+
this.pictures.set(rec.id, null);
|
|
407
|
+
return renderScreen(look);
|
|
408
|
+
}
|
|
404
409
|
const lines = [];
|
|
405
410
|
// 굴리지 않았는데 맨 위로 돌아갔다(창이 닫히며 주소가 바뀐 사이트). 사람은 화면이 튀는 걸 본다(10/21 실험실 q3)
|
|
406
411
|
// 다른 페이지로 간 것은 원래 맨 위에서 시작한다 — 같은 페이지(? 뒤만 바뀐 것 포함)일 때만
|
|
@@ -516,15 +521,15 @@ class Hub {
|
|
|
516
521
|
ms: Date.now() - rec.startedAt,
|
|
517
522
|
sdkReady: rec.visit ? !!rec.visit.sdk?.ready : null,
|
|
518
523
|
...(rec.account ? { account: rec.account } : {}),
|
|
519
|
-
// 도구가 하지 못한 것 — 고객도 자기 기록에서 볼 수 있게
|
|
520
|
-
...(rec.visit && rec.visit.diag && rec.visit.diag.length ? { diag: rec.visit.diag } : {}),
|
|
524
|
+
// 도구가 하지 못한 것 — 고객도 자기 기록에서 볼 수 있게 종류만 남긴다(HTML·화면은 서버로만)
|
|
525
|
+
...(rec.visit && rec.visit.diag && rec.visit.diag.length ? { diag: rec.visit.diag.map(({ kind, action, shape, detail }) => ({ kind, action, shape, detail })) } : {}),
|
|
521
526
|
...extra,
|
|
522
527
|
};
|
|
523
528
|
ws.appendRun(row);
|
|
524
529
|
// 대시보드로 — 키가 없으면 건너뛴다. 실패해도 방문은 끝낸다
|
|
525
530
|
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})`);
|
|
526
|
-
// 도구가 하지 못한 것들 — 우리 쪽 진단으로(
|
|
527
|
-
if (rec.visit && rec.visit.diag && rec.visit.diag.length) await report.sendDiagnostics(rec.visit.diag, rec.person.device).catch(() => 0);
|
|
531
|
+
// 도구가 하지 못한 것들 — 우리 쪽 진단으로(화면·HTML 포함, 고객 대시보드에는 안 보인다). 기본으로 켜져 있다, USERCASE_DIAGNOSTICS=off 로 끈다
|
|
532
|
+
if (rec.visit && rec.visit.diag && rec.visit.diag.length) await report.sendDiagnostics(rec.visit.diag, rec.person.device, rec.visit.diagShots).catch(() => 0);
|
|
528
533
|
const doc = ws.loadPeople();
|
|
529
534
|
const p = doc.people.find((x) => x.id === rec.person.id);
|
|
530
535
|
if (p) {
|
package/mcp/report.js
CHANGED
|
@@ -168,14 +168,17 @@ async function sendVisit(row, person, startedAt, screens = []) {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
/**
|
|
171
|
-
* 도구가 하지
|
|
171
|
+
* 도구가 하지 못했거나 잘못 본 것들(진단)을 보낸다 — 우리 도구를 고치려고. 고객 대시보드에는 안 보인다.
|
|
172
|
+
* 그 순간의 페이지 경로·요소 주변 HTML·화면을 같이 보낸다 — 종류만으로는 못 고친다(10/22, src/run/diag.js).
|
|
172
173
|
* 키가 있고 꺼 두지 않았을 때만. 실패해도 조용히 넘어간다 — 방문과는 상관없다
|
|
173
174
|
*/
|
|
174
|
-
async function sendDiagnostics(events, device) {
|
|
175
|
+
async function sendDiagnostics(events, device, shots = []) {
|
|
175
176
|
const diag = require("../src/run/diag.js");
|
|
176
177
|
if (!events || !events.length || !diag.enabled() || !KEY_RE.test(apiKey())) return 0;
|
|
177
178
|
try {
|
|
178
179
|
const out = await call("POST", "/agent/diagnostics", { run: RUN_REF, version: require("../package.json").version, device, events: events.slice(0, 100) });
|
|
180
|
+
// 화면은 진단 줄이 생긴 뒤에 — 진단 id 로 붙는다
|
|
181
|
+
for (const s of shots) await call("POST", "/agent/diagnostics/screen", { id: s.id, image: s.image.toString("base64") }).catch(() => null);
|
|
179
182
|
return (out && out.saved) || 0;
|
|
180
183
|
} catch {
|
|
181
184
|
return 0;
|
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.3",
|
|
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/browser/hand.js
CHANGED
|
@@ -137,6 +137,83 @@ async function coveredBy(page, el) {
|
|
|
137
137
|
return { invisible: true, x: cx, y: cy };
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** 그 점을 지금 누르면 그 요소에 닿나 */
|
|
141
|
+
async function hits(el, x, y) {
|
|
142
|
+
return el
|
|
143
|
+
.evaluate((node, [qx, qy]) => {
|
|
144
|
+
const hit = document.elementFromPoint(qx, qy);
|
|
145
|
+
return !!hit && (node === hit || node.contains(hit) || hit.contains(node));
|
|
146
|
+
}, [x, y])
|
|
147
|
+
.catch(() => false);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 겨눈 곳으로 가서 누른다. 누르기 직전에 그 자리에 아직 그것이 있나 본다 — 저절로 넘어가는 배너는
|
|
152
|
+
* 커서가 가는 사이 다음 카드가 와서, OKX 를 누르려다 Uniswap 이 열렸다(10/22 실험실 k5). 옮겨 갔으면 다시 겨눈다.
|
|
153
|
+
* 헛클릭(missed)은 원래 빈 곳이라 그대로 누른다. 끝내 못 맞추면 null
|
|
154
|
+
*/
|
|
155
|
+
async function pressAt(page, state, el, aim) {
|
|
156
|
+
for (let t = 0; t < 3 && aim; t++) {
|
|
157
|
+
await moveTo(page, state, aim.x, aim.y);
|
|
158
|
+
await sleep(rand(40, 140));
|
|
159
|
+
if (aim.missed || (await hits(el, aim.x, aim.y))) {
|
|
160
|
+
await page.mouse.click(aim.x, aim.y);
|
|
161
|
+
return aim;
|
|
162
|
+
}
|
|
163
|
+
aim = await aimAt(page, state, el);
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 화면 안에 보이는 같은 것(쌍둥이). 무한히 도는 배너(slick 등)는 카드마다 복사본을 앞뒤에 깔아 둔다 —
|
|
170
|
+
* 잡은 것이 화면 밖 복사본이면 사람은 화면에 보이는 그 카드를 누른다(10/22 실험실 k5: 같은 카드가 3벌)
|
|
171
|
+
*/
|
|
172
|
+
async function visibleTwin(page, el, vw) {
|
|
173
|
+
const h = await el
|
|
174
|
+
.evaluateHandle((node, w) => {
|
|
175
|
+
const key = (n) => [n.tagName, n.getAttribute("href") || "", (n.textContent || "").replace(/\s+/g, " ").trim()].join("|");
|
|
176
|
+
const k = key(node);
|
|
177
|
+
if (k.length < 8) return null;
|
|
178
|
+
for (const n of document.querySelectorAll(node.tagName)) {
|
|
179
|
+
if (n === node || key(n) !== k) continue;
|
|
180
|
+
const r = n.getBoundingClientRect();
|
|
181
|
+
const cx = r.x + r.width / 2;
|
|
182
|
+
const cy = r.y + r.height / 2;
|
|
183
|
+
if (r.width > 0 && cx > 1 && cx < w - 1 && cy > 1 && cy < innerHeight - 1) return n;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}, vw)
|
|
187
|
+
.catch(() => null);
|
|
188
|
+
const twin = h && h.asElement();
|
|
189
|
+
return twin || null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 저절로 넘어가는 줄(자동 배너)에서 잘린 것 — 사람은 밀지 않고 그 카드가 돌아와 화면 안에 들 때까지 본다.
|
|
194
|
+
* 3.5초 동안 안 움직이면 멈춰 있는 줄이다(null — 밀어야 한다). 8초 안에 화면 가운데쯤 오면 그것(또는 복사본)
|
|
195
|
+
*/
|
|
196
|
+
async function waitComeAround(page, el, vw) {
|
|
197
|
+
const first = await el.boundingBox().catch(() => null);
|
|
198
|
+
if (!first) return null;
|
|
199
|
+
let moved = false;
|
|
200
|
+
for (let i = 0; i < 32; i++) {
|
|
201
|
+
await sleep(250);
|
|
202
|
+
const b = await el.boundingBox().catch(() => null);
|
|
203
|
+
if (!b) return null;
|
|
204
|
+
if (Math.abs(b.x - first.x) > 3) moved = true;
|
|
205
|
+
// 자동 배너는 보통 3초마다 넘어간다
|
|
206
|
+
if (!moved && i >= 14) return null;
|
|
207
|
+
if (!moved) continue;
|
|
208
|
+
const c = b.x + b.width / 2;
|
|
209
|
+
if (c > vw * 0.15 && c < vw * 0.85) return el;
|
|
210
|
+
// 무한 배너는 복사본이 대신 돌아온다
|
|
211
|
+
const twin = await visibleTwin(page, el, vw);
|
|
212
|
+
if (twin) return twin;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
140
217
|
/** 손가락으로 옆으로 민다(Chrome 터치 입력). 터치가 없는 화면이면 아무 일 없다 */
|
|
141
218
|
async function touchSwipe(page, x0, x1, y) {
|
|
142
219
|
const cdp = await page.context().newCDPSession(page);
|
|
@@ -283,17 +360,29 @@ async function clickLikePerson(page, state, el) {
|
|
|
283
360
|
// 가로로 넘치는 줄(회전 배너·가로 목록) 안에서 잘린 것이면, 사람은 그 줄을 옆으로 밀어 가져온 뒤 누른다
|
|
284
361
|
// (10/15 실험실 p1·p3: 모바일 첫 화면 배너 끝에 걸친 카드를 끝내 못 눌렀다)
|
|
285
362
|
if (bb && (bb.x + bb.width / 2 > vw - 1 || bb.x + bb.width / 2 < 1)) {
|
|
286
|
-
const
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (again && bb && bb.x + bb.width / 2 > 1 && bb.x + bb.width / 2 < vw - 1) {
|
|
291
|
-
await moveTo(page, state, again.x, again.y);
|
|
292
|
-
await sleep(rand(40, 140));
|
|
293
|
-
await page.mouse.click(again.x, again.y);
|
|
294
|
-
return { missed: again.missed, swiped: true };
|
|
295
|
-
}
|
|
363
|
+
const twin = await visibleTwin(page, el, vw);
|
|
364
|
+
if (twin) {
|
|
365
|
+
const got = await pressAt(page, state, twin, await aimAt(page, state, twin));
|
|
366
|
+
if (got) return { missed: got.missed };
|
|
296
367
|
}
|
|
368
|
+
// 저절로 넘어가는 줄이면 밀지 않고 돌아오길 기다린다. 넘어가는 줄을 밀면 반쯤은 엇나가 "잘렸다" 로 끝났다(10/22 실험실 k5)
|
|
369
|
+
const round = await waitComeAround(page, el, vw);
|
|
370
|
+
if (round) {
|
|
371
|
+
const came = await pressAt(page, state, round, await aimAt(page, state, round));
|
|
372
|
+
if (came) return { missed: came.missed };
|
|
373
|
+
}
|
|
374
|
+
await swipeIntoView(page, state, el, vw).catch(() => false);
|
|
375
|
+
// 무한 배너는 밀고 나면 잡은 것 대신 그 복사본이 화면에 와 있다 — 화면에 든 쪽을 누른다(10/22 실험실 k5)
|
|
376
|
+
bb = await el.boundingBox().catch(() => null);
|
|
377
|
+
const inView = bb && bb.x + bb.width / 2 > 1 && bb.x + bb.width / 2 < vw - 1;
|
|
378
|
+
const target = inView ? el : await visibleTwin(page, el, vw);
|
|
379
|
+
const done = target ? await pressAt(page, state, target, await aimAt(page, state, target)) : null;
|
|
380
|
+
if (done) return { missed: done.missed, swiped: true };
|
|
381
|
+
// 밀어도 못 가져왔다 — 저절로 넘어가는 줄이면 한 번 더 돌아오길 기다린다
|
|
382
|
+
const later = await waitComeAround(page, el, vw);
|
|
383
|
+
const came = later ? await pressAt(page, state, later, await aimAt(page, state, later)) : null;
|
|
384
|
+
if (came) return { missed: came.missed, swiped: true };
|
|
385
|
+
bb = await el.boundingBox().catch(() => null);
|
|
297
386
|
}
|
|
298
387
|
if (bb && (bb.x + bb.width / 2 > vw - 1 || bb.x + bb.width / 2 < 1)) {
|
|
299
388
|
const say = bb.x < vw && bb.x + bb.width > 0 ? "it is cut off at the edge of the screen — only a sliver of it shows, and you cannot reach it" : "it is off the edge of the screen and you cannot reach it";
|
|
@@ -305,10 +394,24 @@ async function clickLikePerson(page, state, el) {
|
|
|
305
394
|
await el.click({ timeout: 8000 });
|
|
306
395
|
return { missed: false };
|
|
307
396
|
}
|
|
308
|
-
await
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
397
|
+
const done = await pressAt(page, state, el, aim);
|
|
398
|
+
if (done) return { missed: done.missed };
|
|
399
|
+
// 누르려는 사이 자꾸 옮겨 갔다 — 저절로 넘어가는 줄이다. 한 번 돌아오길 기다려 본다
|
|
400
|
+
const vw = (page.viewportSize() || { width: 1200 }).width;
|
|
401
|
+
const twin = await visibleTwin(page, el, vw);
|
|
402
|
+
if (twin) {
|
|
403
|
+
const got = await pressAt(page, state, twin, await aimAt(page, state, twin));
|
|
404
|
+
if (got) return { missed: got.missed };
|
|
405
|
+
}
|
|
406
|
+
const round = await waitComeAround(page, el, vw);
|
|
407
|
+
if (round) {
|
|
408
|
+
const came = await pressAt(page, state, round, await aimAt(page, state, round));
|
|
409
|
+
if (came) return { missed: came.missed };
|
|
410
|
+
}
|
|
411
|
+
const say = "it slid away as you went to tap it — that row moves by itself";
|
|
412
|
+
const e = new Error(say);
|
|
413
|
+
e.human = say;
|
|
414
|
+
throw e;
|
|
312
415
|
}
|
|
313
416
|
|
|
314
417
|
const NEAR = { a: "s", s: "d", d: "f", f: "g", g: "h", h: "j", j: "k", k: "l", l: "k", q: "w", w: "e", e: "r", r: "t", t: "y", y: "u", u: "i", i: "o", o: "p", p: "o", z: "x", x: "c", c: "v", v: "b", b: "n", n: "m", m: "n" };
|
package/src/run/diag.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* 사이트마다 다른 이유로 막힌다(회전 배너를 밀 수 없어서, 화면 밖으로 잘려서, 덮개 때문에 …).
|
|
6
6
|
* bluetags 하나로는 다 못 찾는다(10/15 — 스와이프가 없어서 배너 끝 카드를 못 눌렀다).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* 실패의 종류와 요소의 생김새(태그·역할·아이콘인지·회전 배너 안인지·고정 위치인지·대화상자 안인지·크기)에 더해
|
|
9
|
+
* 그 순간의 페이지 경로·요소 주변 HTML·화면을 보낸다(10/22 — 종류만으로는 무한 배너 복사본 같은 원인을 못 찾았다).
|
|
10
|
+
* 우리만 본다 — 고객 대시보드에는 안 보인다. 고객 방문 기록(/agent/visits)과 따로 간다.
|
|
11
|
+
* 도구가 못 한 것뿐 아니라 잘못 본 것도 — 방문자의 다음 말과 도구 판단이 엇갈리면(visit.js checkLastJudgement)
|
|
10
12
|
* 기본으로 켜져 있고 USERCASE_DIAGNOSTICS=off 로 끈다. API 키가 있을 때만 간다(키가 프로젝트를 정한다).
|
|
11
13
|
*/
|
|
12
14
|
|
|
@@ -17,6 +19,7 @@ const enabled = () => !OFF.test(String(process.env.USERCASE_DIAGNOSTICS || "").t
|
|
|
17
19
|
function kindOf(skipped) {
|
|
18
20
|
const s = String(skipped || "");
|
|
19
21
|
if (/cut off at the edge|off the edge of the screen/.test(s)) return "cut_off";
|
|
22
|
+
if (/slid away as you went to tap it/.test(s)) return "moving";
|
|
20
23
|
if (/survey card is on top/.test(s)) return "covered_by_card";
|
|
21
24
|
if (/on top of it/.test(s)) return "covered";
|
|
22
25
|
if (/greyed out/.test(s)) return "disabled";
|
package/src/run/visit.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
const fs = require("fs");
|
|
20
20
|
const path = require("path");
|
|
21
|
+
const { randomUUID } = require("crypto");
|
|
21
22
|
const { listControls, visibleText, handleAt, clickBlocked } = require("../browser/page.js");
|
|
22
23
|
const { readCard, waitCard, screenText, answerCard, noteReason, sleep, waitForSdk } = require("../browser/card.js");
|
|
23
24
|
const hand = require("../browser/hand.js");
|
|
@@ -54,6 +55,15 @@ const MAX_TIMES = { click: 4, scroll: 5, scroll_up: 5 };
|
|
|
54
55
|
* 누른 곳(box)은 사진에 그리지 않고 걸음에 비율(0~1)로 적는다. 대시보드가 위에 그린다
|
|
55
56
|
*/
|
|
56
57
|
const MAX_SCREENS = 6;
|
|
58
|
+
/** 진단(도구가 못 했거나 잘못 본 것)마다 찍는 화면 — 우리만 본다. 고객 대시보드에는 안 간다(10/22) */
|
|
59
|
+
const MAX_DIAG_SHOTS = 6;
|
|
60
|
+
/** 진단에 붙이는 요소 주변 HTML 길이 */
|
|
61
|
+
const MAX_DIAG_HTML = 6000;
|
|
62
|
+
/** 진단으로 볼 만한 것이 아니다 — 의도한 동작(밀어서 가져옴·사람 같은 헛클릭) */
|
|
63
|
+
const DIAG_NO_CONTEXT = new Set(["swiped", "missed"]);
|
|
64
|
+
// 방문자가 바로 다음에 하는 말 — 도구 판단과 엇갈리면 도구가 잘못 본 것이다
|
|
65
|
+
const SAYS_CHANGED = /\b(saved|bookmarked|liked|went (blue|red|green|grey|gray|dark|bold)|turned (blue|red|green|on|off)|it (worked|opened|changed)|that (worked|opened)|now (shows|says)|popped (up|open)|opened up)\b/i;
|
|
66
|
+
const SAYS_NOTHING = /\b(nothing happened|nothing changed|didn.?t (open|work|do anything|respond|change|load|go anywhere)|not (working|responding|loading)|won.?t (open|load|work)|still the same|no response|dead)\b/i;
|
|
57
67
|
// 서버 앞 nginx 의 기본 본문 한도가 1MB 다 — base64 로 커지니 원본은 600KB 아래로
|
|
58
68
|
const MAX_SCREEN_BYTES = 600 * 1024;
|
|
59
69
|
|
|
@@ -64,13 +74,24 @@ const MAX_SCREEN_BYTES = 600 * 1024;
|
|
|
64
74
|
async function errorScreen(page) {
|
|
65
75
|
return page
|
|
66
76
|
.evaluate(() => {
|
|
67
|
-
const t = ((document.body && document.body.innerText) || "").replace(
|
|
77
|
+
const t = ((document.body && document.body.innerText) || "").replace(/\s+/g, " ").trim();
|
|
68
78
|
if (t.length > 600) return false;
|
|
69
|
-
return
|
|
79
|
+
return /\b(404|500|502|503)\b|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
80
|
})
|
|
71
81
|
.catch(() => false);
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
/** 같은 사이트 주소의 경로(+쿼리). 다른 사이트면 null */
|
|
85
|
+
function sameDocPath(href, base) {
|
|
86
|
+
try {
|
|
87
|
+
const u = new URL(href, base);
|
|
88
|
+
if (u.origin !== new URL(base).origin) return null;
|
|
89
|
+
return (u.pathname.replace(/\/+$/, "") || "/") + u.search;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
/** 요소가 화면에서 차지한 곳(화면 비율). 화면 밖이거나 사라졌으면 null */
|
|
75
96
|
async function boxOf(page, el) {
|
|
76
97
|
const b = el ? await el.boundingBox().catch(() => null) : null;
|
|
@@ -86,7 +107,7 @@ async function boxOf(page, el) {
|
|
|
86
107
|
}
|
|
87
108
|
|
|
88
109
|
/**
|
|
89
|
-
* 누르기 전후를 견줄 지문 — 주소, 페이지 전체 글(보이는 것만, 굴려도 같다), 보이는 입력칸·그림, 누른
|
|
110
|
+
* 누르기 전후를 견줄 지문 — 주소, 페이지 전체 글(보이는 것만, 굴려도 같다), 보이는 입력칸·그림, 누른 요소와 감싼 버튼의 상태·HTML(색만 바뀌는 좋아요·저장·토글).
|
|
90
111
|
* 입력칸은 글(innerText)에 안 잡힌다 — 검색 아이콘을 눌러 검색칸이 열렸는데 "그대로" 로 찍혔다(10/22 실험실 e4)
|
|
91
112
|
* 글이 저절로 바뀌는 사이트(넘어가는 배너)에서는 "바뀌었다" 로 본다 — 그대로라고 잘못 찍는 것보다 낫다
|
|
92
113
|
*/
|
|
@@ -109,11 +130,21 @@ async function clickPrint(page, el) {
|
|
|
109
130
|
.catch(() => null);
|
|
110
131
|
const own = el
|
|
111
132
|
? await el
|
|
112
|
-
.evaluate((n) =>
|
|
113
|
-
n.isConnected
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
133
|
+
.evaluate((n) => {
|
|
134
|
+
if (!n.isConnected) return null;
|
|
135
|
+
// 색만 바뀌는 아이콘(저장·좋아요)은 속의 svg 나 감싼 버튼의 속성·클래스가 바뀐다 — 그 HTML 까지 본다.
|
|
136
|
+
// 누른 요소만 봤더니 북마크가 파랗게 됐는데 "그대로" 로 찍혔다(10/22 bluetags 저장).
|
|
137
|
+
// 계산된 색은 안 본다 — 누르기 전에는 커서가 없어서 마우스를 올린 색(hover)만으로 "바뀌었다" 가 된다
|
|
138
|
+
const host = n.closest("a,button,[role],label") || n.parentElement || n;
|
|
139
|
+
return [
|
|
140
|
+
n.className && String(n.className),
|
|
141
|
+
...["aria-pressed", "aria-expanded", "aria-checked", "aria-selected", "aria-current", "data-state"].map((a) => n.getAttribute(a)),
|
|
142
|
+
n.checked,
|
|
143
|
+
n.value,
|
|
144
|
+
(n.textContent || "").slice(0, 200),
|
|
145
|
+
host.outerHTML.slice(0, 3000),
|
|
146
|
+
].join("|");
|
|
147
|
+
})
|
|
117
148
|
.catch(() => null)
|
|
118
149
|
: null;
|
|
119
150
|
return { url: page.url(), doc, own };
|
|
@@ -374,6 +405,8 @@ class Visit {
|
|
|
374
405
|
this.externalTold = new Set();
|
|
375
406
|
/** 도구가 하지 못한 것들 — 방문이 끝나면 hub 가 서버로 보낸다(diag.js). 사이트 글자는 없다 */
|
|
376
407
|
this.diag = [];
|
|
408
|
+
/** 진단 순간의 화면 { id(진단 id), image } — 우리 쪽으로만 간다 */
|
|
409
|
+
this.diagShots = [];
|
|
377
410
|
this.log = log;
|
|
378
411
|
// 손은 방문 내내 이어진다 — 커서가 있던 자리에서 다음 곳으로 간다
|
|
379
412
|
this.handState = { rhythm, mouse: null };
|
|
@@ -411,6 +444,113 @@ class Visit {
|
|
|
411
444
|
.then((a) => (this.fileWindow = { accept: a || "" }))
|
|
412
445
|
.catch(() => {});
|
|
413
446
|
});
|
|
447
|
+
/**
|
|
448
|
+
* 브라우저 창(alert·confirm·prompt). 듣지 않으면 Playwright 가 조용히 "취소" 한다 — bluetags 커뮤니티 나가기는
|
|
449
|
+
* "Are you sure you want to leave?" 가 세 번 다 취소돼 "눌러도 그대로" 로 찍히고 고객 화면에 사이트 탓으로 나갔다(10/22 p2038).
|
|
450
|
+
* 창이 떠 있는 동안 페이지는 멈추고 누르기도 안 끝난다 — 방문자에게 문구를 보여 주고 고르게 한다(answerDialog)
|
|
451
|
+
*/
|
|
452
|
+
this.dialog = null;
|
|
453
|
+
this.dialogWake = null;
|
|
454
|
+
this.dialogWork = null;
|
|
455
|
+
this.dialogText = null;
|
|
456
|
+
this.acting = false;
|
|
457
|
+
this.curRecord = null;
|
|
458
|
+
page.on("dialog", (d) => this.onDialog(d));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** 창이 떴다. 걸음 중이면 방문자에게 넘기고, 아니면(화면 보는 중·카드) 사람이 그냥 넘기듯 닫는다 — 열어 두면 페이지가 멈춘다 */
|
|
462
|
+
onDialog(d) {
|
|
463
|
+
const type = d.type();
|
|
464
|
+
const message = String(d.message() || "").slice(0, 500);
|
|
465
|
+
// 떠나며 "저장 안 한 내용이 있다" 는 사람도 대개 떠난다
|
|
466
|
+
if (type === "beforeunload") return void d.accept().catch(() => {});
|
|
467
|
+
if (!this.acting || this.dialog) {
|
|
468
|
+
(type === "alert" ? d.accept() : d.dismiss()).catch(() => {});
|
|
469
|
+
this.history.push(`A box from the browser popped up saying "${message}" and went away.`);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
this.dialog = { d, type, message, defaultValue: d.defaultValue() || "", path: this.pathNow() };
|
|
473
|
+
this.dialogText = null;
|
|
474
|
+
if (this.dialogWake) this.dialogWake();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
pathNow() {
|
|
478
|
+
try {
|
|
479
|
+
const u = new URL(this.page.url());
|
|
480
|
+
return u.pathname + u.search;
|
|
481
|
+
} catch {
|
|
482
|
+
return "";
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** 창 안에서 고를 것 — 화면의 번호 목록처럼 준다 */
|
|
487
|
+
dialogItems() {
|
|
488
|
+
const d = this.dialog;
|
|
489
|
+
const ok = { key: "ok", label: "OK", where: "in the box" };
|
|
490
|
+
const cancel = { key: "cancel", label: "Cancel", where: "in the box" };
|
|
491
|
+
const items = d.type === "alert" ? [ok] : d.type === "prompt" ? [{ key: "field", label: "(text box)", where: "in the box" }, ok, cancel] : [ok, cancel];
|
|
492
|
+
return items.map((it, i) => ({ ...it, i }));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** 창이 떠 있는 화면. 페이지를 읽을 수 없다(멈춰 있다) — 창만 보인다 */
|
|
496
|
+
dialogLook() {
|
|
497
|
+
const d = this.dialog;
|
|
498
|
+
const what = d.type === "alert" ? "A message box from the browser popped up over the page:" : d.type === "prompt" ? "A box from the browser popped up over the page, asking you to type something:" : "A box from the browser popped up over the page, asking:";
|
|
499
|
+
const lines = [what, "", d.message || "(no text)", ""];
|
|
500
|
+
if (d.type === "prompt") lines.push(this.dialogText !== null ? `You have typed: ${this.dialogText}` : d.defaultValue ? `It already says: ${d.defaultValue}` : "The text box is empty.", "");
|
|
501
|
+
lines.push(d.type === "alert" ? "The page behind it is frozen until you close the box." : "The page behind it is frozen until you answer.");
|
|
502
|
+
const view = { path: d.path, scrollY: 0, height: 0, viewport: 0, items: this.dialogItems().map(({ i, label, where }) => ({ i, label, where })), dialog: true };
|
|
503
|
+
return { view, text: lines.join("\n") };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* 창에 답한다 — OK·Cancel 을 누르거나 prompt 칸에 친다. 원래 걸음(_act)은 창이 닫힌 뒤 마저 끝난다.
|
|
508
|
+
* 고른 것은 그 걸음 기록에 붙는다(record.dialogs). 다른 조작은 창이 막는다 — 떠나기는 Cancel 하고 떠난다
|
|
509
|
+
*/
|
|
510
|
+
async answerDialog(decision) {
|
|
511
|
+
await this.waitTurn();
|
|
512
|
+
const dl = this.dialog;
|
|
513
|
+
const item = this.dialogItems()[decision.index];
|
|
514
|
+
let choice = null;
|
|
515
|
+
if (decision.action === "leave") choice = "cancel";
|
|
516
|
+
else if (decision.action === "type" && item && item.key === "field") {
|
|
517
|
+
this.dialogText = String(decision.text || "").slice(0, 200);
|
|
518
|
+
await sleep(800 + Math.random() * 1500);
|
|
519
|
+
this.nextAt = Date.now() + 600;
|
|
520
|
+
return { dialog: true };
|
|
521
|
+
} else if (decision.action === "click" && item && item.key !== "field") choice = item.key;
|
|
522
|
+
else if (decision.action === "click" && item) return { skipped: "you can only type into that text box" };
|
|
523
|
+
else return { skipped: decision.action === "click" || decision.action === "type" ? "that number is not on the screen" : "the box from the browser is still open on top of the page — answer it first" };
|
|
524
|
+
|
|
525
|
+
this.dialog = null;
|
|
526
|
+
const answer = choice === "ok" ? (dl.type === "prompt" ? "typed and pressed OK" : "OK") : "Cancel";
|
|
527
|
+
await (choice === "ok" ? dl.d.accept(dl.type === "prompt" ? this.dialogText ?? dl.defaultValue : undefined) : dl.d.dismiss()).catch(() => {});
|
|
528
|
+
if (this.curRecord) (this.curRecord.dialogs = this.curRecord.dialogs || []).push({ type: dl.type, message: dl.message, answer, why: decision.why, t: Date.now() - this.t0 });
|
|
529
|
+
this.log(` browser box "${dl.message.slice(0, 40)}" → ${answer}`);
|
|
530
|
+
// 원래 걸음이 마저 끝나기를 본다. 창이 또 뜨면 다시 방문자에게
|
|
531
|
+
const r = await this.untilDialog(this.dialogWork);
|
|
532
|
+
if (r && r.dialog) return r;
|
|
533
|
+
this.dialogWork = null;
|
|
534
|
+
if (decision.action === "leave") return this.act(decision);
|
|
535
|
+
return r || { step: this.steps.length };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 걸음을 하다가 창이 뜨면 거기서 돌려준다({ dialog: true }) — 걸음은 창이 닫힌 뒤 마저 간다 */
|
|
539
|
+
async untilDialog(work) {
|
|
540
|
+
let wake;
|
|
541
|
+
const seen = new Promise((res) => (wake = res));
|
|
542
|
+
this.dialogWake = wake;
|
|
543
|
+
this.acting = true;
|
|
544
|
+
const first = await Promise.race([work.then((r) => ({ r })), seen.then(() => null)]);
|
|
545
|
+
if (first) {
|
|
546
|
+
this.acting = false;
|
|
547
|
+
this.dialogWake = null;
|
|
548
|
+
return first.r;
|
|
549
|
+
}
|
|
550
|
+
this.dialogWork = work;
|
|
551
|
+
// 창 글을 읽는 시간
|
|
552
|
+
this.nextAt = Date.now() + 1200 + Math.min(4000, this.dialog.message.length * 40);
|
|
553
|
+
return { dialog: true };
|
|
414
554
|
}
|
|
415
555
|
|
|
416
556
|
/**
|
|
@@ -456,6 +596,8 @@ class Visit {
|
|
|
456
596
|
*/
|
|
457
597
|
async look() {
|
|
458
598
|
if (this.ended) return { ended: true };
|
|
599
|
+
// 브라우저 창이 떠 있다. 페이지는 멈춰 있어 읽으면 걸린다 — 창만 보여 준다
|
|
600
|
+
if (this.dialog) return this.dialogLook();
|
|
459
601
|
if (!this.card && !this.pending) {
|
|
460
602
|
const shown = await waitCard(this.page, 500);
|
|
461
603
|
if (shown) this.pending = shown;
|
|
@@ -512,8 +654,14 @@ class Visit {
|
|
|
512
654
|
* 한 걸음. decision 은 STEP_SCHEMA 모양({ why, action, index, text, done, times }).
|
|
513
655
|
* 번호는 마지막 look() 화면 기준이다. 떠나면 끝나고, 떠나는 순간 뜬 카드가 있으면 pending 에 남는다.
|
|
514
656
|
* 보고 있던 사이에 카드가 떴으면 하려던 걸 하지 않고 { interrupted: true } 를 돌려준다 — 카드부터 본다.
|
|
657
|
+
* 하는 도중 브라우저 창이 뜨면 { dialog: true } — 다음 look() 이 창을 보여 주고, 다음 act() 가 창에 답한다
|
|
515
658
|
*/
|
|
516
659
|
async act(decision) {
|
|
660
|
+
if (this.dialog) return this.answerDialog(decision);
|
|
661
|
+
return this.untilDialog(this._act(decision));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async _act(decision) {
|
|
517
665
|
if (this.ended) throw new Error("This visit has already ended");
|
|
518
666
|
if (this.pending) throw new Error("A card is showing — answer or close it first");
|
|
519
667
|
await this.waitTurn();
|
|
@@ -529,6 +677,9 @@ class Visit {
|
|
|
529
677
|
const view = this.lastView || (await listControls(page));
|
|
530
678
|
const item = view.items[decision.index];
|
|
531
679
|
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 };
|
|
680
|
+
this.curRecord = record;
|
|
681
|
+
await this.checkLastJudgement(decision);
|
|
682
|
+
const diagFrom = this.diag.length;
|
|
532
683
|
const times = Math.max(1, Math.min(MAX_TIMES[decision.action] || 1, Math.round(Number(decision.times) || 1)));
|
|
533
684
|
if (times > 1) record.times = times;
|
|
534
685
|
// 한 번에 할 수 있는 수보다 많이 달라고 했다. 말없이 줄이면 "사이트가 클릭을 삼킨다" 로 읽었다(10/20 실험실 p7: 15·20·30번이 다 4번)
|
|
@@ -700,7 +851,8 @@ class Visit {
|
|
|
700
851
|
// 안 눌린다 싶어 거듭 누른다. 화면이 바뀌면 멈춘다
|
|
701
852
|
for (let i = 1; i < times && !record.skipped && this.handState.mouse; i++) {
|
|
702
853
|
await sleep(150 + Math.random() * 450);
|
|
703
|
-
|
|
854
|
+
// 창이 떴었다 — 화면이 반응한 것이다. 또 누르면 같은 창이 또 뜬다
|
|
855
|
+
if (page.url() !== before || this.offsite.length > offsiteBefore || record.dialogs) break;
|
|
704
856
|
await page.mouse.click(this.handState.mouse.x, this.handState.mouse.y).catch(() => {});
|
|
705
857
|
}
|
|
706
858
|
if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "click", shape: await diag.shapeOf(el) });
|
|
@@ -761,10 +913,12 @@ class Visit {
|
|
|
761
913
|
)
|
|
762
914
|
await this.shoot(record, "error");
|
|
763
915
|
|
|
764
|
-
// 눌렀는데 그대로. 우리 쪽 사정(빗나감·막은 요청·바깥 사이트·파일 창)으로 그대로인 것은 빼고, 같은 것은 한
|
|
916
|
+
// 눌렀는데 그대로. 우리 쪽 사정(빗나감·막은 요청·바깥 사이트·파일 창)으로 그대로인 것은 빼고, 같은 것은 한 번만.
|
|
917
|
+
// 브라우저 창이 떴으면 반응한 것이다 — Cancel 을 골라 그대로인 것은 사이트 탓이 아니다
|
|
765
918
|
if (
|
|
766
919
|
probe &&
|
|
767
920
|
!record.skipped &&
|
|
921
|
+
!record.dialogs &&
|
|
768
922
|
!record.missed &&
|
|
769
923
|
!record.closedOverlay &&
|
|
770
924
|
!record.offsite &&
|
|
@@ -781,9 +935,18 @@ class Visit {
|
|
|
781
935
|
await this.shoot(record, "no_change", await boxOf(page, probe.el));
|
|
782
936
|
}
|
|
783
937
|
|
|
938
|
+
// 누른 링크와 다른 곳으로 왔다 — 움직이는 줄에서 다른 카드를 눌렀을 수 있다(10/22 실험실 k5: OKX 를 눌렀는데 Uniswap).
|
|
939
|
+
// 사이트가 돌려보낸 것(로그인 등)일 수도 있다 — 우리가 보고 가린다
|
|
940
|
+
if (decision.action === "click" && !record.skipped && !record.offsite && item?.href && page.url() !== before) {
|
|
941
|
+
const want = sameDocPath(item.href, before);
|
|
942
|
+
const got = sameDocPath(page.url(), before);
|
|
943
|
+
if (want && got && want !== got) this.diag.push({ kind: "landed_elsewhere", action: "click", shape: await diag.shapeOf(probe && probe.el) });
|
|
944
|
+
}
|
|
945
|
+
await this.diagContext(record, diagFrom, probe ? probe.el : diagEl);
|
|
946
|
+
|
|
784
947
|
this.steps.push(record);
|
|
785
948
|
this.history.push(
|
|
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)" : ""}`,
|
|
949
|
+
`${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)" : ""}${(record.dialogs || []).map((d) => ` (a box from the browser said "${d.message.slice(0, 120)}"; you pressed ${d.answer === "Cancel" ? "Cancel" : "OK"})`).join("")}`,
|
|
787
950
|
);
|
|
788
951
|
this.log(`${record.step}. ${decision.action}${item ? ` "${String(item.label).slice(0, 30)}"` : ""}${record.skipped ? ` · ${record.skipped}` : ""} — ${decision.why}`);
|
|
789
952
|
this.lastView = null;
|
|
@@ -806,6 +969,64 @@ class Visit {
|
|
|
806
969
|
this.diag.push({ kind: "reclick_same", action, shape: await diag.shapeOf(el) });
|
|
807
970
|
}
|
|
808
971
|
|
|
972
|
+
/**
|
|
973
|
+
* 바로 앞 걸음에 대한 도구 판단이 방문자의 다음 말과 엇갈리나. 엇갈리면 도구가 잘못 본 것이다 — 진단으로 남긴다.
|
|
974
|
+
* - "눌렀는데 그대로" 로 찍었는데 방문자는 "저장됐다·파랗게 됐다·열렸다" 고 한다 → 그 사진은 고객에게 안 보낸다
|
|
975
|
+
* (10/22 bluetags 저장: 북마크가 파랗게 됐는데 그대로로 찍혔다)
|
|
976
|
+
* - 화면이 바뀌었다고 봤는데 방문자는 "아무 일 없다·안 열린다" 고 한다
|
|
977
|
+
* 방문자의 말은 사이트 글자가 아니라 우리 AI 가 쓴 말이다
|
|
978
|
+
*/
|
|
979
|
+
async checkLastJudgement(decision) {
|
|
980
|
+
const last = this.steps[this.steps.length - 1];
|
|
981
|
+
if (!last || last.action !== "click" || last.skipped || !decision.why) return;
|
|
982
|
+
const from = this.diag.length;
|
|
983
|
+
if (last.screen && last.screen.kind === "no_change" && SAYS_CHANGED.test(decision.why)) {
|
|
984
|
+
this.diag.push({ kind: "no_change_doubt", action: "click", detail: `step ${last.step}` });
|
|
985
|
+
this.screens = this.screens.filter((x) => x.step !== last.step);
|
|
986
|
+
delete last.screen;
|
|
987
|
+
} else if (!last.screen && !last.missed && !last.closedOverlay && SAYS_NOTHING.test(decision.why)) {
|
|
988
|
+
this.diag.push({ kind: "visitor_saw_nothing", action: "click", detail: `step ${last.step}` });
|
|
989
|
+
}
|
|
990
|
+
// 지금 화면이 그 걸음 바로 뒤의 화면이다
|
|
991
|
+
if (this.diag.length > from) await this.diagContext(last, from, null);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* 이번 걸음에 생긴 진단에 그 순간을 붙인다 — 화면(우리만 본다), 요소 주변 HTML, 페이지 경로.
|
|
996
|
+
* 종류와 생김새만으로는 못 고친다: 10/22 무한 배너는 "잘림·회전 배너" 까지만 알았고, 같은 카드 복사본 때문인 건 열어 봐야 알았다
|
|
997
|
+
*/
|
|
998
|
+
async diagContext(record, from, el) {
|
|
999
|
+
const events = this.diag.slice(from).filter((e) => !e.id && !DIAG_NO_CONTEXT.has(e.kind));
|
|
1000
|
+
if (!events.length) return;
|
|
1001
|
+
let pagePath = null;
|
|
1002
|
+
try {
|
|
1003
|
+
const u = new URL(this.page.url());
|
|
1004
|
+
pagePath = (u.pathname + u.search).slice(0, 300);
|
|
1005
|
+
} catch {}
|
|
1006
|
+
const html = el
|
|
1007
|
+
? await el
|
|
1008
|
+
.evaluate((n, max) => {
|
|
1009
|
+
if (!n.isConnected) return null;
|
|
1010
|
+
let best = n;
|
|
1011
|
+
for (let p = n.parentElement, d = 0; p && p !== document.body && d < 4; p = p.parentElement, d++) if (p.outerHTML.length <= max) best = p;
|
|
1012
|
+
return best.outerHTML.slice(0, max);
|
|
1013
|
+
}, MAX_DIAG_HTML)
|
|
1014
|
+
.catch(() => null)
|
|
1015
|
+
: null;
|
|
1016
|
+
let shotId = null;
|
|
1017
|
+
if (this.diagShots.length < MAX_DIAG_SHOTS) {
|
|
1018
|
+
const image = await this.snap();
|
|
1019
|
+
if (image) shotId = randomUUID();
|
|
1020
|
+
if (image) this.diagShots.push({ id: shotId, image });
|
|
1021
|
+
}
|
|
1022
|
+
events.forEach((e, i) => {
|
|
1023
|
+
e.id = i === 0 && shotId ? shotId : randomUUID();
|
|
1024
|
+
e.step = record.step;
|
|
1025
|
+
if (pagePath) e.path = pagePath;
|
|
1026
|
+
if (html) e.html = html;
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
809
1030
|
/**
|
|
810
1031
|
* 문제가 있던 화면을 찍어 이 걸음에 붙인다(record.screen). 보이는 만큼만(뷰포트). 크면 한 번 더 작게, 그래도 크면 안 찍는다
|
|
811
1032
|
* 실패해도 방문은 그대로 간다
|