@usercase/mcp 0.2.2-next.2 → 0.2.2-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/mcp/hub.js +17 -2
- package/package.json +1 -1
- package/src/browser/hand.js +87 -5
- package/src/browser/page.js +158 -13
- package/src/run/visit.js +14 -5
package/mcp/hub.js
CHANGED
|
@@ -399,7 +399,18 @@ class Hub {
|
|
|
399
399
|
const look = await v.look();
|
|
400
400
|
if (look.ended) return "This visit has ended.";
|
|
401
401
|
if (look.card) return renderCard(look.card);
|
|
402
|
-
const lines = [
|
|
402
|
+
const lines = [];
|
|
403
|
+
// 굴리지 않았는데 맨 위로 돌아갔다(창이 닫히며 주소가 바뀐 사이트). 사람은 화면이 튀는 걸 본다(10/21 실험실 q3)
|
|
404
|
+
// 다른 페이지로 간 것은 원래 맨 위에서 시작한다 — 같은 페이지(? 뒤만 바뀐 것 포함)일 때만
|
|
405
|
+
const y = look.view.scrollY;
|
|
406
|
+
const at = look.view.path.split("?")[0];
|
|
407
|
+
// 창이 뜨거나 닫히며 위치 기준(페이지 ↔ 창)이 바뀐 것은 튄 게 아니다(10/21 실험실 r3)
|
|
408
|
+
const cover = !!(look.view.cover || look.view.inPanel);
|
|
409
|
+
if (rec.lastY > 300 && y < 40 && at === rec.lastAt && cover === rec.lastCover && !/^scroll/.test(rec.lastAction || "")) lines.push("(The page jumped back to the top.)", "");
|
|
410
|
+
rec.lastY = y;
|
|
411
|
+
rec.lastAt = at;
|
|
412
|
+
rec.lastCover = cover;
|
|
413
|
+
lines.push(renderScreen(look));
|
|
403
414
|
// 인내심이 다 됐다는 단정이 아니라 조건이다. "볼 만큼 봤다" 고 하면 방문자가 그 자리에서 나갔다 — 진전이 있으면 이어 간다(10/14)
|
|
404
415
|
if (v.patienceSpent) lines.push("", "(Your patience is running low. Unless what is in front of you is getting you closer to what you came for, this is about where you would give up.)");
|
|
405
416
|
return lines.join("\n");
|
|
@@ -417,6 +428,7 @@ class Hub {
|
|
|
417
428
|
async act(id, decision) {
|
|
418
429
|
const rec = this.get(id);
|
|
419
430
|
const v = rec.visit;
|
|
431
|
+
rec.lastAction = decision.action;
|
|
420
432
|
const r = await v.act(decision);
|
|
421
433
|
if (r.interrupted) return ["Before you could do that, a survey card appeared.", "", await this.screen(rec)].join("\n");
|
|
422
434
|
const said = [];
|
|
@@ -439,7 +451,10 @@ class Hub {
|
|
|
439
451
|
const sum = await this.finish(rec);
|
|
440
452
|
return [...said, "You left the site. This visit is over.", "", sum].join("\n");
|
|
441
453
|
}
|
|
442
|
-
|
|
454
|
+
const shown = await this.screen(rec);
|
|
455
|
+
// 하는 사이에 카드가 떴다. 손가락이 카드에 먼저 닿았을 수 있다 — 말 없이 카드만 주면 클릭이 왜 안 됐는지 모른다(10/21 실험실 t3)
|
|
456
|
+
if (v.pending && !/^(read|leave)$/.test(decision.action)) said.push("A survey card popped up just as you did that — once you have dealt with it, check whether what you did went through.");
|
|
457
|
+
return [...said, ...(said.length ? [""] : []), shown].join("\n");
|
|
443
458
|
}
|
|
444
459
|
|
|
445
460
|
/** 카드에 답하거나 닫는다 */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usercase/mcp",
|
|
3
|
-
"version": "0.2.2-next.
|
|
3
|
+
"version": "0.2.2-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
|
@@ -92,7 +92,14 @@ async function aimAt(page, state, el) {
|
|
|
92
92
|
const py = Math.min(Math.max(y, box.y + 2), box.y + box.height - 2);
|
|
93
93
|
if ((await at(px, py)) === "대상") return { x: px, y: py, missed: false };
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
if ((await at(cx, cy)) === "대상") return { x: cx, y: cy, missed: false };
|
|
96
|
+
// 가운데만 가렸으면 사람은 보이는 쪽을 누른다 — 화면에 붙은 창의 Join 버튼 아래쪽이 아래 메뉴에 깔려
|
|
97
|
+
// 굴려도 안 비켜서 "Watchlist 가 덮었다" 가 됐다(10/21 실험실 q2). 가운데에서 가까운 점부터 본다
|
|
98
|
+
const pts = [];
|
|
99
|
+
for (const fy of [0.2, 0.35, 0.5, 0.65, 0.8]) for (const fx of [0.5, 0.3, 0.7, 0.15, 0.85]) pts.push([box.x + box.width * fx, box.y + box.height * fy]);
|
|
100
|
+
pts.sort((a, b) => Math.hypot(a[0] - cx, a[1] - cy) - Math.hypot(b[0] - cx, b[1] - cy));
|
|
101
|
+
for (const [px, py] of pts) if ((await at(px, py)) === "대상") return { x: px, y: py, missed: false };
|
|
102
|
+
return null;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
/**
|
|
@@ -215,6 +222,7 @@ async function swipeIntoView(page, state, el, vw) {
|
|
|
215
222
|
* @returns { missed: boolean } missed 면 빈 곳을 눌러 아무 일도 안 일어난 것이다
|
|
216
223
|
*/
|
|
217
224
|
async function clickLikePerson(page, state, el) {
|
|
225
|
+
const startY = await page.evaluate(() => scrollY).catch(() => null);
|
|
218
226
|
// 위아래로만 굴린다. 사람은 휴대폰에서 화면을 옆으로 끌어 숨은 버튼을 찾지 않는다 — 옆으로 굴리면 화면 밖 것을 끌어와 눌렀다(10/15)
|
|
219
227
|
await el
|
|
220
228
|
.evaluate((n) => {
|
|
@@ -226,10 +234,16 @@ async function clickLikePerson(page, state, el) {
|
|
|
226
234
|
if (!aim) {
|
|
227
235
|
// 화면 끝에 걸쳐 고정 막대(위 머리줄·아래 메뉴)에 가린 것은 사람이 조금 더 굴려서 누른다.
|
|
228
236
|
// 이걸 안 하고 "덮였다" 고 하면 모바일 아래 메뉴 밑 항목이 안 눌린다(10/14 bluetags p28, 0.1.12)
|
|
237
|
+
// 창 안(스크롤되는 칸)의 것이면 그 칸을 굴린다 — 페이지만 굴려서는 모바일 정보 창 맨 아래 Join 이
|
|
238
|
+
// 아래 메뉴 밑에서 안 나왔다(10/21 실험실 y2). scrollIntoView 는 감싼 칸들을 모두 굴린다
|
|
229
239
|
await el
|
|
230
240
|
.evaluate((n) => {
|
|
231
|
-
const
|
|
232
|
-
|
|
241
|
+
const before = n.getBoundingClientRect().top;
|
|
242
|
+
n.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" });
|
|
243
|
+
if (Math.abs(n.getBoundingClientRect().top - before) < 1) {
|
|
244
|
+
const r = n.getBoundingClientRect();
|
|
245
|
+
window.scrollBy(0, r.top + r.height / 2 - innerHeight / 2);
|
|
246
|
+
}
|
|
233
247
|
})
|
|
234
248
|
.catch(() => {});
|
|
235
249
|
await sleep(rand(150, 350));
|
|
@@ -256,6 +270,8 @@ async function clickLikePerson(page, state, el) {
|
|
|
256
270
|
return { missed: false, closedOverlay: gone };
|
|
257
271
|
}
|
|
258
272
|
if (cover) {
|
|
273
|
+
// 못 누른 채 화면만 굴러가 있으면 "안 됐다" 는데 화면이 바뀐다(10/21 실험실 q2) — 사람은 굴리지 않고 덮인 걸 본다
|
|
274
|
+
if (startY !== null) await page.evaluate((y) => window.scrollTo({ top: y, behavior: "instant" }), startY).catch(() => {});
|
|
259
275
|
const e = new Error(cover.say);
|
|
260
276
|
e.human = cover.say;
|
|
261
277
|
throw e;
|
|
@@ -339,14 +355,78 @@ async function scrollLikePerson(page, state, dir = 1) {
|
|
|
339
355
|
const total = (300 + Math.random() * 700) * dir;
|
|
340
356
|
const chunks = 2 + Math.round(Math.random() * 2);
|
|
341
357
|
const y0 = await page.evaluate(() => scrollY).catch(() => null);
|
|
358
|
+
// 휴대폰에서는 화면 가운데를 엄지로 민다. 커서가 처음 자리(0,0)에 있으면 휠이 그 자리의 것을 굴린다 —
|
|
359
|
+
// 화면을 덮은 정보 창 대신 그 뒤 페이지가 굴러, 창 맨 아래 Join 이 끝내 아래 메뉴 밑에 있었다(10/21 실험실 r1).
|
|
360
|
+
// 데스크톱도 커서가 아직 한 번도 안 움직였으면 본문 쪽으로 옮겨 굴린다
|
|
361
|
+
const touch = await page.evaluate(() => matchMedia("(pointer: coarse)").matches).catch(() => false);
|
|
362
|
+
const vp0 = page.viewportSize() || { width: 1200, height: 800 };
|
|
363
|
+
// 흐린 막 위에 창이 떠 있으면 사람은 그 창 위에서 굴린다 — 주소로 바로 열린 데스크톱 정보 창은 뒤 페이지가
|
|
364
|
+
// 안 잠겨 휠이 뒤 목록을 굴렸고, 창 맨 아래 Join 을 끝내 못 봤다(10/21 실험실 u4)
|
|
365
|
+
const panel = await page
|
|
366
|
+
.evaluate(() => {
|
|
367
|
+
const VW = document.documentElement.clientWidth || innerWidth;
|
|
368
|
+
const fixedCtx = (n) => {
|
|
369
|
+
for (let p = n; p && p !== document.body; p = p.parentElement) if (getComputedStyle(p).position === "fixed") return true;
|
|
370
|
+
return false;
|
|
371
|
+
};
|
|
372
|
+
// 화면에 붙은 큰 판 — 흐린 막(데스크톱 정보 창 뒤)이든 화면을 덮은 불투명한 창(모바일 정보 창)이든
|
|
373
|
+
let overlay = false;
|
|
374
|
+
for (const n of document.body.querySelectorAll("*")) {
|
|
375
|
+
const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
|
|
376
|
+
const a = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
|
|
377
|
+
if (!a) continue;
|
|
378
|
+
const r = n.getBoundingClientRect();
|
|
379
|
+
if ((a < 0.9 ? r.width >= VW * 0.5 : r.width >= VW * 0.9) && r.height >= innerHeight * 0.6 && fixedCtx(n)) {
|
|
380
|
+
overlay = true;
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (!overlay) return null;
|
|
385
|
+
let best = null;
|
|
386
|
+
let area = 0;
|
|
387
|
+
for (const n of document.body.querySelectorAll("*")) {
|
|
388
|
+
if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100 || !/auto|scroll/.test(getComputedStyle(n).overflowY) || !fixedCtx(n)) continue;
|
|
389
|
+
const r = n.getBoundingClientRect();
|
|
390
|
+
const cx = r.left + r.width / 2;
|
|
391
|
+
const cy = Math.min(r.top + r.height / 2, innerHeight - 5);
|
|
392
|
+
const h = document.elementFromPoint(cx, cy);
|
|
393
|
+
if (!h || !(n === h || n.contains(h))) continue; // 위에 떠 있는 칸만
|
|
394
|
+
const a = r.width * r.height;
|
|
395
|
+
if (a > area) (area = a), (best = { x: cx, y: cy });
|
|
396
|
+
}
|
|
397
|
+
return best;
|
|
398
|
+
})
|
|
399
|
+
.catch(() => null);
|
|
400
|
+
if (panel) await moveTo(page, state, panel.x + rand(-30, 30), panel.y + rand(-40, 40));
|
|
401
|
+
else if (touch || !state.mouse || (state.mouse.x < 5 && state.mouse.y < 5)) {
|
|
402
|
+
await moveTo(page, state, vp0.width * rand(0.4, 0.6), vp0.height * rand(0.4, 0.6));
|
|
403
|
+
}
|
|
342
404
|
for (let i = 0; i < chunks; i++) {
|
|
343
405
|
await page.mouse.wheel(0, total / chunks);
|
|
344
406
|
await sleep(rand(80, 260));
|
|
345
407
|
// 커서가 메뉴·고정 막대 위에 있으면 휠이 페이지를 안 굴린다. 사람은 커서를 본문 쪽으로 옮기고 다시 굴린다
|
|
346
408
|
// (10/13: 약관 링크를 누른 뒤 세 번 굴려도 0px)
|
|
347
409
|
if (i === 0 && y0 !== null && (await page.evaluate(() => scrollY).catch(() => null)) === y0) {
|
|
410
|
+
// 페이지가 잠겼고 화면 한쪽 칸만 굴러가면(데스크톱 정보 창) 사람은 그 칸 위로 커서를 옮긴다(10/21 실험실 r4)
|
|
411
|
+
const pane = await page
|
|
412
|
+
.evaluate(() => {
|
|
413
|
+
// 페이지가 그냥 끝에 닿은 것이면 안쪽 칸을 찾지 않는다
|
|
414
|
+
if (document.documentElement.scrollHeight > innerHeight + 2 && getComputedStyle(document.body).overflowY !== "hidden") return null;
|
|
415
|
+
let best = null;
|
|
416
|
+
let area = 0;
|
|
417
|
+
for (const n of document.body.querySelectorAll("*")) {
|
|
418
|
+
if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100 || n.scrollTop + n.clientHeight >= n.scrollHeight - 2) continue;
|
|
419
|
+
if (!/auto|scroll/.test(getComputedStyle(n).overflowY)) continue;
|
|
420
|
+
const r = n.getBoundingClientRect();
|
|
421
|
+
const a = Math.max(0, Math.min(r.right, innerWidth) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, innerHeight) - Math.max(r.top, 0));
|
|
422
|
+
if (a > area) (area = a), (best = { x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
|
423
|
+
}
|
|
424
|
+
return best;
|
|
425
|
+
})
|
|
426
|
+
.catch(() => null);
|
|
348
427
|
const vp = page.viewportSize() || { width: 1200, height: 800 };
|
|
349
|
-
await moveTo(page, state,
|
|
428
|
+
if (pane) await moveTo(page, state, pane.x + rand(-30, 30), pane.y + rand(-40, 40));
|
|
429
|
+
else await moveTo(page, state, vp.width * rand(0.45, 0.6), vp.height * rand(0.45, 0.65));
|
|
350
430
|
await page.mouse.wheel(0, total / chunks);
|
|
351
431
|
await sleep(rand(80, 260));
|
|
352
432
|
}
|
|
@@ -357,7 +437,9 @@ async function scrollLikePerson(page, state, dir = 1) {
|
|
|
357
437
|
const after = await pos();
|
|
358
438
|
const moved = after && y0 !== null ? after.y - y0 : 0;
|
|
359
439
|
const atEnd = after && (dir > 0 ? after.y >= after.max - 2 : after.y <= 2);
|
|
360
|
-
|
|
440
|
+
// 창 안을 굴렸으면 되돌리지 않는다 — 뒤 페이지가 움직인 만큼 되돌리다 창 안이 맨 위로 돌아가,
|
|
441
|
+
// 창 맨 아래 Join 이 아래 메뉴 밑에 다시 갇혔다(10/21 실험실 x4)
|
|
442
|
+
if (!panel && Math.sign(moved) === Math.sign(dir) && Math.abs(moved) > 120 && !atEnd && Math.random() < 0.25) {
|
|
361
443
|
await sleep(rand(150, 500));
|
|
362
444
|
await page.mouse.wheel(0, -moved * (0.15 + Math.random() * 0.2));
|
|
363
445
|
}
|
package/src/browser/page.js
CHANGED
|
@@ -58,6 +58,15 @@ async function listControls(page, max = 60) {
|
|
|
58
58
|
}
|
|
59
59
|
return op <= 0.45;
|
|
60
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* 화면 끝에 몇 px 만 걸친 것은 사람 눈에 아직 없다 — 창 맨 아래 Join 이 4px 걸쳐 "화면 안" 으로 나오고
|
|
63
|
+
* 화면 글에는 없어 서로 어긋났다(10/21 실험실 r2·s1). 반 넘게(긴 것은 16px 넘게) 보여야 화면 안이다
|
|
64
|
+
*/
|
|
65
|
+
const whereOf = (r) => {
|
|
66
|
+
const shown = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0);
|
|
67
|
+
if (shown >= Math.min(r.height / 2, 16) && r.right > 0 && r.left < VW) return "in view";
|
|
68
|
+
return (r.top + r.bottom) / 2 >= innerHeight / 2 ? "below" : "above";
|
|
69
|
+
};
|
|
61
70
|
const nameOf = (el) =>
|
|
62
71
|
(el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || el.getAttribute("placeholder") || "")
|
|
63
72
|
.replace(/\s+/g, " ")
|
|
@@ -78,8 +87,8 @@ async function listControls(page, max = 60) {
|
|
|
78
87
|
const key = tag + "|" + label + "|" + (href || `@${Math.round(r.left)},${Math.round(r.top + scrollY)}`);
|
|
79
88
|
if (seen.has(key)) continue;
|
|
80
89
|
seen.add(key);
|
|
81
|
-
const
|
|
82
|
-
const
|
|
90
|
+
const where = whereOf(r);
|
|
91
|
+
const inView = where === "in view";
|
|
83
92
|
cand.push({ el, tag, label, href: href ? href.split(/[?#]/)[0] : "", where, rank: inView ? 0 : where === "below" ? 1 : 2, dim: dimmed(el), edge: (r.left + r.right) / 2 > VW - 1 || (r.left + r.right) / 2 < 1 });
|
|
84
93
|
// 성능용 안전 상한. 이건 사람에게 보여줄 예산이 아니다
|
|
85
94
|
if (cand.length >= 800) break;
|
|
@@ -142,14 +151,17 @@ async function listControls(page, max = 60) {
|
|
|
142
151
|
const r = el.getBoundingClientRect();
|
|
143
152
|
// 작은 크기는 글자 없는 것에만 따진다. 날짜 "1" 은 폭이 5px 이라 빠졌다(10/14 실험실 달력)
|
|
144
153
|
if (text ? r.width < 2 || r.height < 6 : r.width < 8 || r.height < 8) continue;
|
|
154
|
+
// 글자 없이 화면 반을 덮는 것은 아이콘이 아니라 창 뒤의 막이다 — "(icon 0,0)" 으로 나와 방문자가 뭔지 몰랐다(10/21 실험실 q5).
|
|
155
|
+
// 막 뒤의 것을 누르면 손이 막을 누른다
|
|
156
|
+
if (!text && r.width * r.height > VW * innerHeight * 0.25) continue;
|
|
145
157
|
const hint = text ? null : iconHint(el);
|
|
146
158
|
const label = text || (hint ? `(icon: ${hint})` : `(icon ${Math.round(r.left)},${Math.round(r.top)})`);
|
|
147
159
|
// 같은 말인 칩이 여럿인 UI 가 흔하다(태그 목록). 자리까지 봐야 서로 다른 조작으로 남는다
|
|
148
160
|
const key = `pointer|${label}|${Math.round(r.left)},${Math.round(r.top + scrollY)}`;
|
|
149
161
|
if (seen.has(key)) continue;
|
|
150
162
|
seen.add(key);
|
|
151
|
-
const
|
|
152
|
-
const
|
|
163
|
+
const where = whereOf(r);
|
|
164
|
+
const inView = where === "in view";
|
|
153
165
|
cand.push({ el, tag: el.tagName.toLowerCase(), label, href: "", where, rank: inView ? 0 : where === "below" ? 1 : 2, size: `${Math.round(r.width)}x${Math.round(r.height)}`, dim: dimmed(el), edge: (r.left + r.right) / 2 > VW - 1 || (r.left + r.right) / 2 < 1 });
|
|
154
166
|
}
|
|
155
167
|
|
|
@@ -160,6 +172,83 @@ async function listControls(page, max = 60) {
|
|
|
160
172
|
* 지금 화면에 보이는 것부터 남기고, 같은 순위 안에서는 문서 순서를 지킨다(sort 는 안정 정렬).
|
|
161
173
|
* 2026-09-21 여기어때에서 드러났다. 블루태그스 Week/Month 토글도 같은 결의 문제였다.
|
|
162
174
|
*/
|
|
175
|
+
/**
|
|
176
|
+
* 화면을 통째로 덮은 층(모바일의 전체 화면 정보 창) 밑에 깔린 것은 사람 눈에 없다. 목록에 넣으면 방문자가
|
|
177
|
+
* 밑의 카드를 누르고 "덮였다" 만 세 번 듣고 나갔다(10/21 bluetags p1937, /community?info= 모바일).
|
|
178
|
+
* 고정 머리줄·아래 메뉴처럼 좁은 막대에 가린 것은 남긴다 — 손이 굴려서 누른다
|
|
179
|
+
*/
|
|
180
|
+
const buried = (el) => {
|
|
181
|
+
const r = el.getBoundingClientRect();
|
|
182
|
+
const x = Math.min(Math.max((r.left + r.right) / 2, 1), VW - 1);
|
|
183
|
+
const y = Math.min(Math.max((r.top + r.bottom) / 2, 1), innerHeight - 1);
|
|
184
|
+
// 위에서부터 겹친 것을 다 본다 — 맨 위가 다른 작은 것(사이트의 설문 창)이어도 그 밑에 덮은 층이 있다
|
|
185
|
+
for (const h of document.elementsFromPoint(x, y)) {
|
|
186
|
+
if (h === el || el.contains(h) || h.contains(el)) return false;
|
|
187
|
+
if (h.closest(ownSel)) continue;
|
|
188
|
+
// 맞은 것에서 올라가며 처음 칠이 있는 것. 반투명한 뒤판(데스크톱 정보 창 옆의 흐린 막)은 비쳐 보이고
|
|
189
|
+
// 누르면 창이 닫힌다 — 남긴다. 칠 없는 것은 투명하니 그 밑을 본다
|
|
190
|
+
for (let n = h; n && n !== document.body && !n.contains(el); n = n.parentElement) {
|
|
191
|
+
const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
|
|
192
|
+
const alpha = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
|
|
193
|
+
if (!alpha) continue;
|
|
194
|
+
const lr = n.getBoundingClientRect();
|
|
195
|
+
const big = Math.min(lr.right, VW) - Math.max(lr.left, 0) >= VW * 0.9 && Math.min(lr.bottom, innerHeight) - Math.max(lr.top, 0) >= innerHeight * 0.6;
|
|
196
|
+
// 반투명한 큰 막 밑 = 열린 창 뒤에 흐리게 보이는 것. 사람은 창이 떠 있는 줄 안다 — 방문자도 알아야
|
|
197
|
+
// 뒤의 카드를 눌러 창만 닫히는 걸 이해한다(10/21 실험실 q3)
|
|
198
|
+
if (big && alpha < 0.9) return "dim";
|
|
199
|
+
if (big) {
|
|
200
|
+
covers.add(n);
|
|
201
|
+
return "under";
|
|
202
|
+
}
|
|
203
|
+
break; // 작은 것(사이트의 설문 창, 고정 막대) — 그 밑을 본다
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
};
|
|
208
|
+
const covers = new Set();
|
|
209
|
+
for (let k = cand.length - 1; k >= 0; k--) {
|
|
210
|
+
if (cand[k].where !== "in view") continue;
|
|
211
|
+
const b = buried(cand[k].el);
|
|
212
|
+
if (b === "under") cand.splice(k, 1);
|
|
213
|
+
else if (b === "dim") cand[k].behind = true;
|
|
214
|
+
}
|
|
215
|
+
// 덮은 층이 화면에 붙어 있으면(fixed) 굴려도 그대로 덮여 있다 — 그 밖의 위·아래 것도 안 보인다.
|
|
216
|
+
// 남기면 "위에 있다" 고 나와 방문자가 올려 보고 누르다 덮였다를 듣는다(10/21 실험실 q2)
|
|
217
|
+
const fixedCovers = [...covers].filter((n) => {
|
|
218
|
+
for (let p = n; p && p !== document.body; p = p.parentElement) if (getComputedStyle(p).position === "fixed") return true;
|
|
219
|
+
return false;
|
|
220
|
+
});
|
|
221
|
+
if (fixedCovers.length) for (let k = cand.length - 1; k >= 0; k--) if (cand[k].where !== "in view" && !fixedCovers.some((n) => n.contains(cand[k].el))) cand.splice(k, 1);
|
|
222
|
+
// 화면을 덮은 창이 있으면 굴리는 건 그 창이다. 뒤 페이지의 위치를 말하면 "아래에 더 있다" 는데 굴려도 화면이 그대로였다(10/21 실험실 r1)
|
|
223
|
+
// 페이지가 잠겨 있으면(창이 뜨며 사이트가 막음) 화면에서 굴러가는 가장 큰 칸 — 데스크톱 정보 창 안쪽(10/21 실험실 r4)
|
|
224
|
+
// 흐린 막 위에 창이 떠 있으면 사람은 그 창을 굴린다 — 주소로 바로 열린 창은 뒤 페이지가 안 잠겨
|
|
225
|
+
// 위치·"아래에 더" 가 뒤 페이지 기준이라 창 맨 아래 Join 을 끝내 못 봤다(10/21 실험실 u4)
|
|
226
|
+
const dimOpen = cand.some((c) => c.behind);
|
|
227
|
+
const pageLocked = dimOpen || document.documentElement.scrollHeight <= innerHeight + 2 || getComputedStyle(document.body).overflowY === "hidden";
|
|
228
|
+
let roll = { y: scrollY, height: document.documentElement.scrollHeight, viewport: innerHeight };
|
|
229
|
+
// 굴리는 칸이 페이지가 아니면 "아래에 더 있다" 도 그 칸 안의 것만 센다 — 잠긴 페이지 뒤 목록을 세면 끝에 닿아도 "더 있다" 였다
|
|
230
|
+
let pane = null;
|
|
231
|
+
if (fixedCovers.length || pageLocked) {
|
|
232
|
+
roll = { y: 0, height: innerHeight, viewport: innerHeight };
|
|
233
|
+
pane = fixedCovers[0] || null;
|
|
234
|
+
let best = 0;
|
|
235
|
+
for (const n of fixedCovers.length ? [fixedCovers[0], ...fixedCovers[0].querySelectorAll("*")] : document.body.querySelectorAll("*")) {
|
|
236
|
+
if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100) continue;
|
|
237
|
+
if (!/auto|scroll/.test(getComputedStyle(n).overflowY)) continue;
|
|
238
|
+
// 창이 떠 있으면 창 쪽(흐리지 않은 것을 품은 칸)만
|
|
239
|
+
// 뒤 목록까지 품은 바깥 칸(페이지 틀)은 창이 아니다 — 그걸 굴리면 뒤 목록이 굴렀다
|
|
240
|
+
if (dimOpen && (!cand.some((c) => !c.behind && c.where === "in view" && n.contains(c.el)) || cand.some((c) => c.behind && n.contains(c.el)))) continue;
|
|
241
|
+
const r = n.getBoundingClientRect();
|
|
242
|
+
const area = Math.max(0, Math.min(r.right, VW) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, innerHeight) - Math.max(r.top, 0));
|
|
243
|
+
if (area > best) {
|
|
244
|
+
best = area;
|
|
245
|
+
pane = n;
|
|
246
|
+
roll = { y: n.scrollTop, height: n.scrollHeight, viewport: n.clientHeight };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// 창이 떠 있으면 창 밖의 위·아래 것은 굴려도 안 온다 — 뒤 목록의 필터가 "위에 있다" 로 남았다(10/21 실험실 u1)
|
|
251
|
+
if (pageLocked || fixedCovers.length) for (let k = cand.length - 1; k >= 0; k--) if (cand[k].where !== "in view" && !(pane && pane.contains(cand[k].el))) cand.splice(k, 1);
|
|
163
252
|
cand.sort((a, b) => a.rank - b.rank);
|
|
164
253
|
/**
|
|
165
254
|
* **화면 아래는 보여주지 않는다.** 사람은 내려 봐야 거기 뭐가 있는지 안다.
|
|
@@ -204,17 +293,23 @@ async function listControls(page, max = 60) {
|
|
|
204
293
|
const picked = pool.slice(0, max);
|
|
205
294
|
const groups = {};
|
|
206
295
|
for (const c of picked) (groups[c.label] = groups[c.label] || []).push(c);
|
|
207
|
-
const nearAt = (c, d) => {
|
|
296
|
+
const nearAt = (c, d, g) => {
|
|
208
297
|
let p = c.el.parentElement;
|
|
209
298
|
for (let k = 0; k < d && p && p !== document.body; k++) p = p.parentElement;
|
|
210
|
-
|
|
299
|
+
if (!p) return "";
|
|
300
|
+
// 같은 이름의 다른 항목까지 감싼 덩어리의 글자는 그 항목의 곁이 아니다 — 맨 아래 "Bluetags" 에
|
|
301
|
+
// 페이지 전체의 첫 글(맨 위 카드)이 붙었다(10/21 실험실 q4·s4)
|
|
302
|
+
if (g.some((o) => o !== c && p.contains(o.el))) return "";
|
|
303
|
+
// 제 이름은 낱말째로만 뺀다 — 이름이 "0" 인 항목에서 날짜 "20" 의 0 이 빠져 "Oct 2" 가 됐다(10/21 실험실 w5)
|
|
304
|
+
const own = new RegExp(`(^|\\s)${c.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=\\s|$)`);
|
|
305
|
+
return (p.innerText || "").replace(/\s+/g, " ").replace(own, "$1").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
211
306
|
};
|
|
212
307
|
for (const [label, g] of Object.entries(groups)) {
|
|
213
308
|
if (g.length < 2) continue;
|
|
214
309
|
// 묶음 안에서 서로 다르게 읽히는 가장 가까운 층을 고른다. 없으면 순서로 가른다
|
|
215
310
|
let hints = null;
|
|
216
311
|
for (let d = 0; d < 6 && !hints; d++) {
|
|
217
|
-
const h = g.map((c) => nearAt(c, d));
|
|
312
|
+
const h = g.map((c) => nearAt(c, d, g));
|
|
218
313
|
if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
|
|
219
314
|
}
|
|
220
315
|
g.forEach((c, k) => (c.label = hints ? `${label} (${hints[k]})` : `${label} (${k + 1} of ${g.length})`));
|
|
@@ -223,7 +318,7 @@ async function listControls(page, max = 60) {
|
|
|
223
318
|
const out = picked.map((c, i) => {
|
|
224
319
|
els.push(c.el);
|
|
225
320
|
// 반 넘게 화면 옆으로 나간 것. 누르면 손이 "잘렸다" 고 한다 — 목록도 같은 말을 해야 한다(10/15 실험실 x87: 공유 막대가 화면을 넓혀 아래 메뉴가 밀렸다)
|
|
226
|
-
return { i, tag: c.tag, label: c.label, href: c.href, where: c.where, ...(c.dim ? { dim: true } : {}), ...(c.edge ? { edge: true } : {}) };
|
|
321
|
+
return { i, tag: c.tag, label: c.label, href: c.href, where: c.where, ...(c.dim ? { dim: true } : {}), ...(c.edge ? { edge: true } : {}), ...(c.behind ? { behind: true } : {}) };
|
|
227
322
|
});
|
|
228
323
|
Object.defineProperty(window, "__ebMcpEls", { value: els, configurable: true, enumerable: false, writable: true });
|
|
229
324
|
return {
|
|
@@ -238,12 +333,17 @@ async function listControls(page, max = 60) {
|
|
|
238
333
|
/** 그중 이름 없는 작은 아이콘 수 */
|
|
239
334
|
droppedIcons: iconsLeft,
|
|
240
335
|
/** 화면 아래에 더 있는 조작 수. 무엇인지는 말하지 않는다 */
|
|
241
|
-
below,
|
|
336
|
+
below: pane ? cand.filter((c) => c.where === "below" && pane.contains(c.el)).length : below,
|
|
242
337
|
path: location.pathname + location.search,
|
|
243
338
|
title: (document.title || "").slice(0, 120),
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
339
|
+
/** 화면을 덮은 창이 떠 있다 — 위치는 그 창 기준이다 */
|
|
340
|
+
cover: fixedCovers.length > 0,
|
|
341
|
+
/** 굴리는 것이 페이지가 아니라 떠 있는 창 안쪽이다 */
|
|
342
|
+
// 본문이 안쪽 칸에서 굴러가는 앱형 사이트도 페이지가 잠겨 보인다 — 창이 실제로 떠 있을 때만
|
|
343
|
+
inPanel: !!pane && (fixedCovers.length > 0 || cand.some((c) => c.behind)),
|
|
344
|
+
scrollY: Math.round(roll.y),
|
|
345
|
+
height: roll.height,
|
|
346
|
+
viewport: roll.viewport,
|
|
247
347
|
};
|
|
248
348
|
},
|
|
249
349
|
{ ownSel: OWN_SEL, max },
|
|
@@ -272,8 +372,38 @@ async function visibleText(page, max = 2500) {
|
|
|
272
372
|
}
|
|
273
373
|
return false;
|
|
274
374
|
};
|
|
375
|
+
/**
|
|
376
|
+
* listControls 의 buried 와 같다 — 화면을 통째로 덮은 불투명한 층(모바일 전체 화면 정보 창) 밑의 글자는 안 보인다.
|
|
377
|
+
* 남기면 창 글자와 밑의 목록이 한 덩어리로 섞여 새 페이지인지 창인지 모른다(10/21 실험실 q4)
|
|
378
|
+
*/
|
|
379
|
+
// false | "under"(안 보임) | "dim"(열린 창 뒤에 흐리게 보임)
|
|
380
|
+
const buriedCache = new Map();
|
|
381
|
+
const buried = (el, r) => {
|
|
382
|
+
if (buriedCache.has(el)) return buriedCache.get(el);
|
|
383
|
+
let res = false;
|
|
384
|
+
const x = Math.min(Math.max((r.left + r.right) / 2, 1), VW - 1);
|
|
385
|
+
const y = Math.min(Math.max((r.top + r.bottom) / 2, 1), innerHeight - 1);
|
|
386
|
+
outer: for (const h of document.elementsFromPoint(x, y)) {
|
|
387
|
+
if (h === el || el.contains(h) || h.contains(el)) break;
|
|
388
|
+
if (h.closest(ownSel)) continue;
|
|
389
|
+
for (let n = h; n && n !== document.body && !n.contains(el); n = n.parentElement) {
|
|
390
|
+
const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
|
|
391
|
+
const alpha = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
|
|
392
|
+
if (!alpha) continue;
|
|
393
|
+
const lr = n.getBoundingClientRect();
|
|
394
|
+
if (Math.min(lr.right, VW) - Math.max(lr.left, 0) >= VW * 0.9 && Math.min(lr.bottom, innerHeight) - Math.max(lr.top, 0) >= innerHeight * 0.6) {
|
|
395
|
+
res = alpha >= 0.9 ? "under" : "dim";
|
|
396
|
+
break outer;
|
|
397
|
+
}
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
buriedCache.set(el, res);
|
|
402
|
+
return res;
|
|
403
|
+
};
|
|
275
404
|
const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
|
|
276
405
|
const range = document.createRange();
|
|
406
|
+
const behind = [];
|
|
277
407
|
let n = 0;
|
|
278
408
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
279
409
|
if (++n > 20000) break;
|
|
@@ -288,15 +418,30 @@ async function visibleText(page, max = 2500) {
|
|
|
288
418
|
const st = getComputedStyle(el);
|
|
289
419
|
if (st.visibility === "hidden" || st.opacity === "0") continue;
|
|
290
420
|
if (clippedX(el, r)) continue;
|
|
421
|
+
const b = buried(el, r);
|
|
422
|
+
if (b === "under") continue;
|
|
423
|
+
// 열린 창 뒤에 흐리게 비치는 글은 따로 모은다 — 섞으면 창이 어디서 끝나는지 모른다(10/21 실험실 q3·r4·s1·t1)
|
|
424
|
+
if (b === "dim") {
|
|
425
|
+
behind.push(txt);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
291
428
|
if (lastTop !== null && Math.abs(r.top - lastTop) > 4) out.push("\n");
|
|
292
429
|
else if (out.length) out.push(" ");
|
|
293
430
|
out.push(txt);
|
|
294
431
|
lastTop = r.top;
|
|
295
432
|
}
|
|
433
|
+
// 뒤의 흐린 글은 짧게 — 길면 글자 한도를 먹어 창 쪽 글이 단어 중간에 잘렸다(10/21 실험실 u1)
|
|
434
|
+
if (behind.length) {
|
|
435
|
+
const b = behind.join(" · ");
|
|
436
|
+
out.push("\n\n[Behind the open panel, dimmed:]\n", b.length > 500 ? b.slice(0, b.lastIndexOf(" ", 500)) + " …" : b);
|
|
437
|
+
}
|
|
296
438
|
return out.join("");
|
|
297
439
|
}, OWN_SEL)
|
|
298
440
|
.catch(() => "");
|
|
299
|
-
|
|
441
|
+
const s = t.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim();
|
|
442
|
+
if (s.length <= max) return s;
|
|
443
|
+
// 넘치면 낱말 사이에서 자르고 잘렸다고 적는다 — "disap" 처럼 끊기면 화면이 그렇게 생긴 줄 안다(10/21 실험실 u1)
|
|
444
|
+
return s.slice(0, Math.max(s.lastIndexOf(" ", max), s.lastIndexOf("\n", max), max - 40)) + " … (more text on screen not shown)";
|
|
300
445
|
}
|
|
301
446
|
|
|
302
447
|
/** 번호로 그 요소를 집는다. 화면이 바뀌었으면 null */
|
package/src/run/visit.js
CHANGED
|
@@ -181,7 +181,9 @@ function renderScreen(look) {
|
|
|
181
181
|
"```",
|
|
182
182
|
"",
|
|
183
183
|
"## Things you can use (number · what it says · where)",
|
|
184
|
-
...view.items.map((it) => `[${it.i}] ${it.label || "(no text)"}${it.href ? ` → ${it.href}` : ""}${it.dim ? " · looks greyed out" : ""}${it.edge ? " · cut off at the edge of the screen" : ""} · ${it.where}`),
|
|
184
|
+
...view.items.map((it) => `[${it.i}] ${it.label || "(no text)"}${it.href ? ` → ${it.href}` : ""}${it.dim ? " · looks greyed out" : ""}${it.edge ? " · cut off at the edge of the screen" : ""}${it.behind ? " · dimmed, behind the open panel" : ""} · ${it.where}`),
|
|
185
|
+
// 떠 있는 창 뒤가 흐리게 비치는 화면. 글로 옮기면 창과 뒤 페이지가 한 덩어리로 섞인다(10/21 실험실 q3)
|
|
186
|
+
...(view.items.some((it) => it.behind) ? ["(A panel is open on top of the page; the page behind it is dimmed.)"] : []),
|
|
185
187
|
// 화면 안에 있는데 목록에 못 넣은 것. "아래에 더 있다" 고 하면 내려 봐도 못 찾는다
|
|
186
188
|
// 화면 안에서 빠진 것과 지나온 위쪽에서 빠진 것을 따로 말한다 — 한 숫자로 말하면 "화면에 너무 많다" 가 거짓이 된다
|
|
187
189
|
...(view.droppedInView
|
|
@@ -192,11 +194,18 @@ function renderScreen(look) {
|
|
|
192
194
|
? [`(${view.dropped} more things further up the page are not listed — scroll up to use them.)`]
|
|
193
195
|
: []),
|
|
194
196
|
// 아래에 더 있다는 것만. 무엇인지는 내려 봐야 안다
|
|
195
|
-
|
|
196
|
-
|
|
197
|
+
// 굴릴 데가 없는데 "아래에 더 있다" 고 하면 모순이다 — 창이 뜨며 사이트가 페이지 스크롤을 잠근 때(10/21 실험실 r2·r4)
|
|
198
|
+
...(view.height - view.viewport <= 2
|
|
199
|
+
? ["(Nothing on this screen scrolls right now.)"]
|
|
200
|
+
// 끝까지 내려왔으면 "아래에 더" 라고 하지 않는다 — 화면 밖 아래에 걸린 것이 있어도 굴려서 못 온다(10/21 실험실 t3)
|
|
201
|
+
: view.below && view.height - view.viewport - view.scrollY > 2
|
|
202
|
+
? [view.inPanel ? "(The open panel goes on below. Scroll down to see the rest of it.)" : `(The page goes on below. Scroll down to see what is there.)`]
|
|
203
|
+
: view.height - view.viewport - view.scrollY > Math.max(40, view.viewport * 0.25)
|
|
204
|
+
? [view.inPanel ? "(There is more of the open panel below.)" : "(There is more of the page below.)"]
|
|
205
|
+
// 조금 남은 것(화면 맨 아래 버튼의 나머지, 여백)을 "더 있다" 고 하면 굴려도 새것이 없었다(10/21 실험실 s2·u2·w2)
|
|
197
206
|
: view.height - view.viewport - view.scrollY > 40
|
|
198
|
-
? ["(
|
|
199
|
-
: ["(This is the bottom of the page.)"]),
|
|
207
|
+
? ["(Nearly the bottom — only a little more below.)"]
|
|
208
|
+
: [view.inPanel ? "(This is the bottom of the open panel.)" : "(This is the bottom of the page.)"]),
|
|
200
209
|
].join("\n");
|
|
201
210
|
}
|
|
202
211
|
|