@usercase/mcp 0.2.3 → 0.2.4-next.0

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.
@@ -1,647 +1,647 @@
1
- "use strict";
2
- /**
3
- * 지금 화면에 무엇이 보이는가 — 자유 탐방이 다음 행동을 정하는 재료.
4
- *
5
- * **화면만 본다.** 저장소 코드를 읽어 "여기 이런 버튼이 있다" 를 미리 알지 않는다.
6
- * 코드를 보고 누르면 사람이 못 찾는 버튼을 바로 찾아버려 QA 값이 사라진다(AUTO-EXPLORE).
7
- *
8
- * 의존성 없음. Playwright page 객체만 받는다.
9
- */
10
-
11
- /** 우리 것(질문 카드·위젯)은 사이트 화면이 아니다 */
12
- const OWN_SEL = "#eb-insight,#eb-plugin";
13
-
14
- /** 그 사람이 지금 누를 수 있는 것들. 번호는 이번 화면에서만 유효하다 */
15
- async function listControls(page, max = 60) {
16
- return page.evaluate(
17
- ({ ownSel, max }) => {
18
- // 보이는 화면 폭. 옆으로 넘친 페이지(모바일에서 창이 삐져나감)는 innerWidth 가 배치 폭으로 늘어나 화면 밖을 "보인다" 고 했다(10/15)
19
- const VW = (window.visualViewport && visualViewport.width) || document.documentElement.clientWidth || innerWidth;
20
- const SEL = 'a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="switch"],[role="radio"],summary,input:not([type="hidden"]),select,textarea';
21
- const vis = (el) => {
22
- if (!el.getClientRects().length) return false;
23
- // 크기가 0 인 것은 사람 눈에 없다. 모바일에서 접혀 있는 검색칸이 여기 걸린다 —
24
- // 목록에 남기면 봇이 "보이니까 칠 수 있다" 고 여기고 입력이 실패한다(2026-09-18)
25
- const r = el.getBoundingClientRect();
26
- if (r.width < 2 || r.height < 2) return false;
27
- const st = getComputedStyle(el);
28
- if (st.visibility === "hidden" || st.display === "none" || st.opacity === "0") return false;
29
- // 화면 옆으로 완전히 나간 것은 보이지 않는다. 위아래와 달리 굴려서 오지도 않는다(10/15 모바일 공유 창)
30
- if (r.left >= VW || r.right <= 0) return false;
31
- return !clippedX(el, r);
32
- };
33
- /**
34
- * 가로로 넘쳐 잘린 틀(회전 배너·가로 목록) 밖에 있으면 사람 눈에 없다. 목록에 넣으면 방문자가 누르고
35
- * "화면에 안 보인다" 를 듣는다(10/14 bluetags /project 배너, 19개 중 9개가 틀 밖). 세로로 넘친 목록은 그대로 둔다 —
36
- * 안쪽을 굴리는 손이 아직 없어 빼면 아예 못 닿는다
37
- */
38
- function clippedX(el, r) {
39
- for (let p = el.parentElement, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
40
- const ox = getComputedStyle(p).overflowX;
41
- if (ox === "visible") continue;
42
- const pr = p.getBoundingClientRect();
43
- if (r.right <= pr.left + 1 || r.left >= pr.right - 1) return true;
44
- }
45
- return false;
46
- }
47
- /**
48
- * 사람 눈에 흐리거나 막힌 것. 날짜 없는 카드의 "Add to calendar" 는 흐리고(opacity 0.3) 커서가 금지 표시라
49
- * 사람은 안 눌리는 줄 아는데, 방문자는 몰라서 세 번 눌렀다(10/15 bluetags 100명 p87)
50
- */
51
- const dimmed = (el) => {
52
- if (el.disabled || el.getAttribute("aria-disabled") === "true") return true;
53
- let op = 1;
54
- for (let p = el, d = 0; p && p !== document.body && d < 12; p = p.parentElement, d++) {
55
- const st = getComputedStyle(p);
56
- if (d < 3 && st.cursor === "not-allowed") return true;
57
- op *= parseFloat(st.opacity) || 1;
58
- }
59
- return op <= 0.45;
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
- };
70
- /**
71
- * 체크박스·라디오는 상태와 붙은 글로 부른다. value 로 부르면 기본값 "on" 이 이름이 되어, 방문자가 약관 체크박스를
72
- * "Notification Settings 링크" 로 알고 두 번 눌렀다 — 체크했다가 도로 풀었다(10/22 bluetags /signup p2018)
73
- */
74
- const boxOf = (el) => {
75
- const role = el.getAttribute("role");
76
- const native = el.tagName === "INPUT" && (el.type === "checkbox" || el.type === "radio");
77
- if (!native && role !== "checkbox" && role !== "switch" && role !== "radio") return "";
78
- const on = native ? el.checked : el.getAttribute("aria-checked") === "true";
79
- const kind = (native ? el.type : role) === "radio" ? "option" : role === "switch" ? "switch" : "tick box";
80
- // <label> 이 없으면 곁의 글(같은 틀 안)을 쓴다 — 약관 체크박스는 글이 옆 칸에 따로 있다
81
- const beside = () => {
82
- const p = el.parentElement;
83
- return p && p.querySelectorAll("input,button,a[href],select,textarea").length <= 1 ? p.innerText : "";
84
- };
85
- const said = (el.getAttribute("aria-label") || (el.labels && el.labels[0] && el.labels[0].innerText) || (native ? "" : el.innerText) || beside() || "").replace(/\s+/g, " ").trim();
86
- return `${kind} (${kind === "switch" ? (on ? "on" : "off") : on ? "ticked" : "not ticked"})${said ? " " + said : ""}`;
87
- };
88
- const nameOf = (el) =>
89
- (boxOf(el) || el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || el.getAttribute("placeholder") || "")
90
- .replace(/\s+/g, " ")
91
- .trim()
92
- .slice(0, 60);
93
-
94
- const cand = [];
95
- const seen = new Set();
96
- for (const el of document.querySelectorAll(SEL)) {
97
- if (el.closest(ownSel) || !vis(el)) continue;
98
- const tag = el.tagName.toLowerCase();
99
- const href = el.getAttribute("href") || "";
100
- const label = nameOf(el) || (tag === "a" && href ? `(icon link ${href.split(/[?#]/)[0]})` : "");
101
- if (!label && !["input", "select", "textarea"].includes(tag)) continue;
102
- const r = el.getBoundingClientRect();
103
- // 같은 곳으로 가는 링크 둘은 하나로 친다. 주소 없는 버튼은 자리로 가른다 — 같은 말 "Post" 가
104
- // 댓글 칸과 답글 칸에 하나씩 있는데 뒤의 것이 빠져 답글을 못 올렸다(10/15 bluetags 100명 p95)
105
- const key = tag + "|" + label + "|" + (href || `@${Math.round(r.left)},${Math.round(r.top + scrollY)}`);
106
- if (seen.has(key)) continue;
107
- seen.add(key);
108
- const where = whereOf(r);
109
- const inView = where === "in view";
110
- 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 });
111
- // 성능용 안전 상한. 이건 사람에게 보여줄 예산이 아니다
112
- if (cand.length >= 800) break;
113
- }
114
- /**
115
- * 태그로만 모으면 **사람 눈에 눌리는 것이 통째로 빠진다.** 요즘 사이트는 목록 제목·필터 칩·탭을
116
- * onClick 달린 div 로 만든다. 블루태그스에서는 커뮤니티 제목과 달력의 Week/Month 가 그래서 안 잡혔고,
117
- * 봇은 그걸 누를 수 있는 줄도 몰랐다(2026-09-21). 커서가 손가락인 것을 사람 기준으로 한 번 더 줍는다.
118
- */
119
- const pointerSel = "div,span,h1,h2,h3,h4,li,p,td,label,img,svg";
120
- /**
121
- * 누를 수 있나: 커서가 손가락이거나, React 가 클릭을 붙여 둔 것.
122
- * 커서를 안 바꾼 버튼이 있다 — 카드의 공유 아이콘이 그래서 아예 안 보였다(10/15 실험실 p18).
123
- * React(Next.js 포함)는 요소에 __reactProps 를 남긴다. 다른 틀에서는 커서만 본다
124
- */
125
- const reactClick = (n) => {
126
- for (const k in n) if (k.startsWith("__reactProps") && n[k] && typeof n[k].onClick === "function") return true;
127
- return false;
128
- };
129
- const clickable = (n) => getComputedStyle(n).cursor === "pointer" || reactClick(n);
130
- /**
131
- * 글자 없는 아이콘의 생김새 힌트. 사람은 아이콘 모양으로 안다 — 그림을 못 보니 이름을 빌린다:
132
- * title·aria-label, 안쪽 svg 의 <title>·<use href="#icon-x">. 없으면 자리만 알린다
133
- */
134
- const iconHint = (n) => {
135
- const t = n.getAttribute("title") || n.getAttribute("aria-label") || n.querySelector("title")?.textContent;
136
- if (t && t.trim()) return t.trim().slice(0, 30);
137
- const use = n.querySelector("use");
138
- const ref = use && (use.getAttribute("href") || use.getAttribute("xlink:href"));
139
- if (ref) {
140
- const w = ref.replace(/^.*#/, "").replace(/^(icon|ic|i)[-_]/i, "").replace(/[-_]+/g, " ").trim();
141
- if (/^[a-z][a-z ]{2,24}$/i.test(w)) return w.toLowerCase();
142
- }
143
- // 클래스 이름은 쓰지 않는다. 아이콘의 뜻이 아니라 속한 덩어리 이름일 때가 많다 — 달력 화살표가 "calendar" 가 됐다(10/15)
144
- return null;
145
- };
146
- // getComputedStyle 은 레이아웃을 강제로 계산시킨다. 큰 사이트에서는 요소가 수천 개라 훑는 양 자체를 막는다
147
- let scanned = 0;
148
- for (const el of document.querySelectorAll(pointerSel)) {
149
- if (cand.length >= 800 || ++scanned > 4000) break;
150
- if (el.closest(ownSel) || !vis(el)) continue;
151
- if (el.closest(SEL)) continue; // 이미 위에서 잡은 조작 안이다
152
- if (!clickable(el)) continue;
153
- // 커서가 손가락인 겉껍데기가 통째로 잡히면 목록이 같은 말로 가득 찬다. 가장 안쪽만 남긴다.
154
- // 다만 글자를 직접 가진 것은 남긴다 — 아이콘+글자 버튼("☆ Follow")에서 아이콘만 "(icon …)" 으로 남고
155
- // 버튼이 빠졌다(10/14 실험실 p9: "Follow 가 글자로만 보인다"). 그 안의 아이콘은 아래에서 뺀다
156
- const ownText = (n) => Array.from(n.childNodes).some((c) => c.nodeType === 3 && c.nodeValue.trim());
157
- // 안쪽이 목록에 못 드는 작은 것(8px 미만 화살표 그림)이면 겉을 남긴다 — 달력 "다음 달" 버튼이 통째로 빠졌다(10/14 실험실 p29)
158
- const listable = (c) => {
159
- const b = c.getBoundingClientRect();
160
- // 목록 후보가 될 수 있는 것만 — svg 안의 path 도 커서를 물려받는데, path 는 후보가 아니라 공유 아이콘이 통째로 빠졌다(10/15 p18)
161
- return c.matches(pointerSel) && clickable(c) && c.getClientRects().length && b.width >= 8 && b.height >= 8;
162
- };
163
- if (!ownText(el) && Array.from(el.children).some(listable)) continue;
164
- // 진짜 조작(링크·버튼)을 감싼 껍데기 — 그건 위에서 이미 잡았다. 껍데기를 따로 올리면 주소 없는 같은 말이 하나 더 생겨,
165
- // 방문자가 그걸 누르면 바깥 사이트로 가는 줄 모르고 "눌러도 그대로" 로 찍혔다(10/23 실험실 Q3, 회원가입 "(see details)")
166
- // 껍데기만 뺀다 — 안의 조작이 거의 다 덮는 것. 안에 작은 버튼(북마크)이 든 카드는 카드대로 남는다
167
- const inner = !ownText(el) && el.querySelector(SEL);
168
- if (inner) {
169
- const a = el.getBoundingClientRect();
170
- const b = inner.getBoundingClientRect();
171
- if (b.width * b.height >= a.width * a.height * 0.6) continue;
172
- }
173
- const up = el.parentElement;
174
- // 글자를 직접 가진 버튼 안의 조각(아이콘, "Event 0" 의 숫자 "0")은 그 버튼의 일부다
175
- if (up && ownText(up) && clickable(up)) continue;
176
- const text = (el.innerText || el.getAttribute("aria-label") || el.getAttribute("alt") || "").replace(/\s+/g, " ").trim();
177
- if (text.length > 60) continue; // 카드 전체를 감싼 것. 그 안의 제목이 따로 잡힌다
178
- const r = el.getBoundingClientRect();
179
- // 작은 크기는 글자 없는 것에만 따진다. 날짜 "1" 은 폭이 5px 이라 빠졌다(10/14 실험실 달력)
180
- if (text ? r.width < 2 || r.height < 6 : r.width < 8 || r.height < 8) continue;
181
- // 글자 없이 화면 반을 덮는 것은 아이콘이 아니라 창 뒤의 막이다 — "(icon 0,0)" 으로 나와 방문자가 뭔지 몰랐다(10/21 실험실 q5).
182
- // 막 뒤의 것을 누르면 손이 막을 누른다
183
- if (!text && r.width * r.height > VW * innerHeight * 0.25) continue;
184
- const hint = text ? null : iconHint(el);
185
- const label = text || (hint ? `(icon: ${hint})` : `(icon ${Math.round(r.left)},${Math.round(r.top)})`);
186
- // 같은 말인 칩이 여럿인 UI 가 흔하다(태그 목록). 자리까지 봐야 서로 다른 조작으로 남는다
187
- const key = `pointer|${label}|${Math.round(r.left)},${Math.round(r.top + scrollY)}`;
188
- if (seen.has(key)) continue;
189
- seen.add(key);
190
- const where = whereOf(r);
191
- const inView = where === "in view";
192
- 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 });
193
- }
194
-
195
- /**
196
- * **문서 순서로 자르면 안 된다.** 머리말·메뉴·배너가 예산을 다 먹고 본문이 통째로 안 보인다.
197
- * 링크가 많은 서비스 사이트에서는 60번째 전에 헤더와 이벤트 배너만으로 끝난다 —
198
- * 그러면 봇은 사람이 보고 있는 것을 못 본 채로 다음 걸음을 고른다.
199
- * 지금 화면에 보이는 것부터 남기고, 같은 순위 안에서는 문서 순서를 지킨다(sort 는 안정 정렬).
200
- * 2026-09-21 여기어때에서 드러났다. 블루태그스 Week/Month 토글도 같은 결의 문제였다.
201
- */
202
- /**
203
- * 화면을 통째로 덮은 층(모바일의 전체 화면 정보 창) 밑에 깔린 것은 사람 눈에 없다. 목록에 넣으면 방문자가
204
- * 밑의 카드를 누르고 "덮였다" 만 세 번 듣고 나갔다(10/21 bluetags p1937, /community?info= 모바일).
205
- * 고정 머리줄·아래 메뉴처럼 좁은 막대에 가린 것은 남긴다 — 손이 굴려서 누른다
206
- */
207
- const buried = (el) => {
208
- const r = el.getBoundingClientRect();
209
- const x = Math.min(Math.max((r.left + r.right) / 2, 1), VW - 1);
210
- const y = Math.min(Math.max((r.top + r.bottom) / 2, 1), innerHeight - 1);
211
- // 위에서부터 겹친 것을 다 본다 — 맨 위가 다른 작은 것(사이트의 설문 창)이어도 그 밑에 덮은 층이 있다
212
- for (const h of document.elementsFromPoint(x, y)) {
213
- if (h === el || el.contains(h) || h.contains(el)) return false;
214
- if (h.closest(ownSel)) continue;
215
- // 맞은 것에서 올라가며 처음 칠이 있는 것. 반투명한 뒤판(데스크톱 정보 창 옆의 흐린 막)은 비쳐 보이고
216
- // 누르면 창이 닫힌다 — 남긴다. 칠 없는 것은 투명하니 그 밑을 본다
217
- for (let n = h; n && n !== document.body && !n.contains(el); n = n.parentElement) {
218
- const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
219
- const alpha = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
220
- if (!alpha) continue;
221
- const lr = n.getBoundingClientRect();
222
- 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;
223
- // 반투명한 큰 막 밑 = 열린 창 뒤에 흐리게 보이는 것. 사람은 창이 떠 있는 줄 안다 — 방문자도 알아야
224
- // 뒤의 카드를 눌러 창만 닫히는 걸 이해한다(10/21 실험실 q3)
225
- if (big && alpha < 0.9) return "dim";
226
- if (big) {
227
- covers.add(n);
228
- return "under";
229
- }
230
- break; // 작은 것(사이트의 설문 창, 고정 막대) — 그 밑을 본다
231
- }
232
- }
233
- return false;
234
- };
235
- const covers = new Set();
236
- for (let k = cand.length - 1; k >= 0; k--) {
237
- if (cand[k].where !== "in view") continue;
238
- const b = buried(cand[k].el);
239
- if (b === "under") cand.splice(k, 1);
240
- else if (b === "dim") cand[k].behind = true;
241
- }
242
- // 덮은 층이 화면에 붙어 있으면(fixed) 굴려도 그대로 덮여 있다 — 그 밖의 위·아래 것도 안 보인다.
243
- // 남기면 "위에 있다" 고 나와 방문자가 올려 보고 누르다 덮였다를 듣는다(10/21 실험실 q2)
244
- const fixedCovers = [...covers].filter((n) => {
245
- for (let p = n; p && p !== document.body; p = p.parentElement) if (getComputedStyle(p).position === "fixed") return true;
246
- return false;
247
- });
248
- 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);
249
- // 화면을 덮은 창이 있으면 굴리는 건 그 창이다. 뒤 페이지의 위치를 말하면 "아래에 더 있다" 는데 굴려도 화면이 그대로였다(10/21 실험실 r1)
250
- // 페이지가 잠겨 있으면(창이 뜨며 사이트가 막음) 화면에서 굴러가는 가장 큰 칸 — 데스크톱 정보 창 안쪽(10/21 실험실 r4)
251
- // 흐린 막 위에 창이 떠 있으면 사람은 그 창을 굴린다 — 주소로 바로 열린 창은 뒤 페이지가 안 잠겨
252
- // 위치·"아래에 더" 가 뒤 페이지 기준이라 창 맨 아래 Join 을 끝내 못 봤다(10/21 실험실 u4)
253
- const dimOpen = cand.some((c) => c.behind);
254
- const pageLocked = dimOpen || document.documentElement.scrollHeight <= innerHeight + 2 || getComputedStyle(document.body).overflowY === "hidden";
255
- let roll = { y: scrollY, height: document.documentElement.scrollHeight, viewport: innerHeight };
256
- // 굴리는 칸이 페이지가 아니면 "아래에 더 있다" 도 그 칸 안의 것만 센다 — 잠긴 페이지 뒤 목록을 세면 끝에 닿아도 "더 있다" 였다
257
- let pane = null;
258
- if (fixedCovers.length || pageLocked) {
259
- roll = { y: 0, height: innerHeight, viewport: innerHeight };
260
- pane = fixedCovers[0] || null;
261
- let best = 0;
262
- for (const n of fixedCovers.length ? [fixedCovers[0], ...fixedCovers[0].querySelectorAll("*")] : document.body.querySelectorAll("*")) {
263
- if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100) continue;
264
- if (!/auto|scroll/.test(getComputedStyle(n).overflowY)) continue;
265
- // 창이 떠 있으면 창 쪽(흐리지 않은 것을 품은 칸)만
266
- // 뒤 목록까지 품은 바깥 칸(페이지 틀)은 창이 아니다 — 그걸 굴리면 뒤 목록이 굴렀다
267
- 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;
268
- const r = n.getBoundingClientRect();
269
- 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));
270
- if (area > best) {
271
- best = area;
272
- pane = n;
273
- roll = { y: n.scrollTop, height: n.scrollHeight, viewport: n.clientHeight };
274
- }
275
- }
276
- }
277
- // 창이 떠 있으면 창 밖의 위·아래 것은 굴려도 안 온다 — 뒤 목록의 필터가 "위에 있다" 로 남았다(10/21 실험실 u1)
278
- 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);
279
- cand.sort((a, b) => a.rank - b.rank);
280
- /**
281
- * **화면 아래는 보여주지 않는다.** 사람은 내려 봐야 거기 뭐가 있는지 안다.
282
- * 이름까지 알려주면 봇이 스크롤 없이 바로 누른다 — 10/13 bluetags 105명 중 79명이 한 번도 안 굴렸다.
283
- * 지나온 위쪽은 이미 본 것이라 남긴다.
284
- */
285
- const below = cand.filter((c) => c.where === "below").length;
286
- const shown = cand.filter((c) => c.where !== "below");
287
- /**
288
- * 같은 이름이 여럿이면 어느 것인지 모른다 — "Project logo image" 가 다섯 개(10/14 실험실). 사람은 로고 옆 이름으로 가른다.
289
- * 겹치는 것에만 가장 가까운 덩어리의 다른 글자를 붙인다
290
- */
291
- // 넘치면 이름 없는 아이콘부터 뺀다. 달력 칸마다 아이콘이 서너 개라 60개를 다 먹고 날짜·버튼이 잘렸다(10/14 실험실 p29)
292
- let pool = shown;
293
- let iconsLeft = 0;
294
- // 화면 안 입력칸(글 쓰는 곳)과, 그 둘레 48px 안에 있는 것
295
- for (const c of shown) c.field = c.where === "in view" && ["input", "textarea"].includes(c.tag);
296
- const fieldBoxes = shown.filter((c) => c.field).map((c) => c.el.getBoundingClientRect());
297
- const nearField = (c) => {
298
- if (!fieldBoxes.length) return false;
299
- const r = c.el.getBoundingClientRect();
300
- return fieldBoxes.some((f) => r.right >= f.left - 48 && r.left <= f.right + 48 && r.bottom >= f.top - 48 && r.top <= f.bottom + 48);
301
- };
302
- // 화면 안의 것만으로 넘칠 때만 — 지나온 위쪽 때문에 넘친 거면 위쪽을 자르면 된다. 아이콘까지 빼면 보이는 것이 빠진다(10/15)
303
- if (pool.filter((c) => c.where === "in view").length > max) {
304
- // 같은 이름으로 되풀이되는 그림도 아이콘처럼 먼저 뺀다 — 카드마다의 "Project logo image" 17개가 자리를 먹어
305
- // 화면 아래 카드들이 빠졌다(10/15 실험실 y38, 로그인한 데스크톱 /community). 그림 옆에 같은 카드의 이름이 따로 있다
306
- const imgCount = {};
307
- for (const c of pool) if (c.tag === "img") imgCount[c.label] = (imgCount[c.label] || 0) + 1;
308
- const isIcon = (c) => /^\(icon \d/.test(c.label) || (c.tag === "img" && imgCount[c.label] >= 4);
309
- // 화면 안의 것끼리만 센다. 위쪽 것까지 세면 자리가 있어도 아이콘을 다 뺐다
310
- const inView = pool.filter((c) => c.where === "in view");
311
- const named = inView.filter((c) => !isIcon(c)).length;
312
- // 같은 크기로 여럿 되풀이되는 아이콘(달력 칸마다의 표시)부터 뺀다. 한두 개뿐인 것(이전·다음 화살표)은 남긴다
313
- const bySize = {};
314
- for (const c of inView) if (isIcon(c)) bySize[c.size] = (bySize[c.size] || 0) + 1;
315
- const ranked = inView
316
- .filter(isIcon)
317
- .map((c, k) => ({ c, k, rep: bySize[c.size] }))
318
- .sort((a, b) => a.rep - b.rep || a.k - b.k);
319
- // 한두 개뿐인 아이콘(이전·다음 화살표, 닫기)은 이름 있는 것만으로 넘쳐도 남긴다 — 일정이 꽉 찬 달에서
320
- // 이름 있는 칸만 100개가 넘어 화살표가 빠지고, 달을 못 넘겼다(10/20 실험실 p11, bluetags 2023년 9월)
321
- const keep = new Set([
322
- ...ranked.filter((x) => x.rep <= 2).slice(0, 4).map((x) => x.c),
323
- ...ranked.slice(0, Math.max(0, max - named)).map((x) => x.c),
324
- ]);
325
- // 입력칸 바로 옆 아이콘(보내기·첨부·검색·지우기)은 남긴다 — 채팅방은 왼쪽 방 목록 아이콘과 크기가 같아
326
- // 보내기·첨부가 같이 빠졌고, 방문자는 "보내기 버튼이 없다" 며 못 보냈다(10/21 bluetags 82fd)
327
- for (const c of inView) if (isIcon(c) && nearField(c)) keep.add(c);
328
- pool = pool.filter((c) => c.where !== "in view" || !isIcon(c) || keep.has(c));
329
- iconsLeft = shown.length - pool.length;
330
- }
331
- // 입력칸과 그 곁 아이콘은 앞으로 — 60 개로 자를 때 페이지 아래쪽 채팅칸이 잘리지 않게
332
- if (pool.length > max) {
333
- const front = pool.filter((c) => c.where === "in view" && (c.field || nearField(c)));
334
- pool = [...front, ...pool.filter((c) => !front.includes(c))];
335
- }
336
- const picked = pool.slice(0, max);
337
- const groups = {};
338
- for (const c of picked) (groups[c.label] = groups[c.label] || []).push(c);
339
- const nearAt = (c, d, g) => {
340
- let p = c.el.parentElement;
341
- for (let k = 0; k < d && p && p !== document.body; k++) p = p.parentElement;
342
- if (!p) return "";
343
- // 같은 이름의 다른 항목까지 감싼 덩어리의 글자는 그 항목의 곁이 아니다 — 맨 아래 "Bluetags" 에
344
- // 페이지 전체의 첫 글(맨 위 카드)이 붙었다(10/21 실험실 q4·s4)
345
- if (g.some((o) => o !== c && p.contains(o.el))) return "";
346
- // 제 이름은 낱말째로만 뺀다 — 이름이 "0" 인 항목에서 날짜 "20" 의 0 이 빠져 "Oct 2" 가 됐다(10/21 실험실 w5)
347
- const own = new RegExp(`(^|\\s)${c.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=\\s|$)`);
348
- // 곁의 입력칸 안내 문구도 본다 — 댓글칸 Post 와 답글칸 Post 는 둘레 글자가 없어 "1 of 2" 가 됐다(10/21 실험실 m1)
349
- const hints = [...p.querySelectorAll("input[placeholder],textarea[placeholder]")].map((n) => n.getAttribute("placeholder")).join(" ");
350
- return `${hints} ${p.innerText || ""}`.replace(/\s+/g, " ").replace(own, "$1").replace(/\s+/g, " ").trim().slice(0, 40);
351
- };
352
- for (const [label, g] of Object.entries(groups)) {
353
- if (g.length < 2) continue;
354
- // 묶음 안에서 서로 다르게 읽히는 가장 가까운 층을 고른다. 없으면 순서로 가른다
355
- let hints = null;
356
- for (let d = 0; d < 6 && !hints; d++) {
357
- const h = g.map((c) => nearAt(c, d, g));
358
- if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
359
- }
360
- // 층을 하나로 맞추면 안 갈리는 것 — 댓글칸 Post 는 바로 위에, 답글칸 Post 는 한 층 더 위에 칸이 있었다(10/21 실험실 m1).
361
- // 항목마다 가장 가까운 곁 글을 따로 찾는다
362
- if (!hints) {
363
- // 둘레 글이 없으면 가장 가까운 입력칸의 안내 문구 — 댓글칸 Post 는 곁 덩어리에 답글칸 Post 까지 들어 있어 끝내 못 갈랐다(10/21 실험실 m1·f1)
364
- const fieldHint = (c) => {
365
- const r = c.el.getBoundingClientRect();
366
- let best = null;
367
- let dist = 160;
368
- for (const f of document.querySelectorAll("input[placeholder],textarea[placeholder]")) {
369
- const b = f.getBoundingClientRect();
370
- if (!b.width || !b.height) continue;
371
- const dx = Math.max(b.left - r.right, r.left - b.right, 0);
372
- const dy = Math.max(b.top - r.bottom, r.top - b.bottom, 0);
373
- const dd = Math.hypot(dx, dy);
374
- if (dd < dist) (dist = dd), (best = f.getAttribute("placeholder"));
375
- }
376
- return best ? `the "${best}" box` : "";
377
- };
378
- const h = g.map((c) => {
379
- for (let d = 0; d < 6; d++) {
380
- const x = nearAt(c, d, g);
381
- if (x.length >= 2) return x;
382
- }
383
- return fieldHint(c);
384
- });
385
- if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
386
- }
387
- // "near:" — 괄호 속이 그 항목의 내용이 아니라 곁의 글이라는 표시. 없으면 "27 (28 29 30 1 2 3)" 이 날짜 여럿을 품은 칸처럼 읽혔다(10/21 실험실 j4·h1)
388
- g.forEach((c, k) => (c.label = hints ? `${label} (near: ${hints[k]})` : `${label} (${k + 1} of ${g.length})`));
389
- }
390
- const els = [];
391
- const out = picked.map((c, i) => {
392
- els.push(c.el);
393
- // 반 넘게 화면 옆으로 나간 것. 누르면 손이 "잘렸다" 고 한다 — 목록도 같은 말을 해야 한다(10/15 실험실 x87: 공유 막대가 화면을 넓혀 아래 메뉴가 밀렸다)
394
- 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 } : {}), ...(c.where === "in view" && !c.field && nearField(c) ? { nearField: true } : {}) };
395
- });
396
- Object.defineProperty(window, "__ebMcpEls", { value: els, configurable: true, enumerable: false, writable: true });
397
- return {
398
- items: out,
399
- /** 예산에 안 들어간 조작 수. 0 이 아니면 봇이 못 본 것이 있다는 뜻이다 */
400
- dropped: Math.max(0, shown.length - out.length),
401
- /**
402
- * 그중 지금 화면 안의 것. 나머지는 지나온 위쪽이다 — 긴 목록을 내려가면 위쪽만으로 예산이 넘쳐
403
- * "화면에 너무 많다" 고 잘못 말했다(10/15 bluetags 100명, 넘침 39번 거의 다 위쪽)
404
- */
405
- droppedInView: Math.max(0, shown.filter((c) => c.where === "in view").length - picked.filter((c) => c.where === "in view").length),
406
- /** 그중 이름 없는 작은 아이콘 수 */
407
- droppedIcons: iconsLeft,
408
- /** 화면 아래에 더 있는 조작 수. 무엇인지는 말하지 않는다 */
409
- below: pane ? cand.filter((c) => c.where === "below" && pane.contains(c.el)).length : below,
410
- path: location.pathname + location.search,
411
- title: (document.title || "").slice(0, 120),
412
- /** 화면을 덮은 창이 떠 있다 — 위치는 그 창 기준이다 */
413
- cover: fixedCovers.length > 0,
414
- /** 창이 떠 있다(굴러가는 칸이 없어도) — 위치 기준이 페이지에서 바뀐다 */
415
- panelOpen: fixedCovers.length > 0 || dimOpen,
416
- /** 굴리는 것이 페이지가 아니라 떠 있는 창 안쪽이다 */
417
- // 본문이 안쪽 칸에서 굴러가는 앱형 사이트도 페이지가 잠겨 보인다 — 창이 실제로 떠 있을 때만
418
- inPanel: !!pane && (fixedCovers.length > 0 || cand.some((c) => c.behind)),
419
- scrollY: Math.round(roll.y),
420
- height: roll.height,
421
- viewport: roll.viewport,
422
- };
423
- },
424
- { ownSel: OWN_SEL, max },
425
- );
426
- }
427
-
428
- /**
429
- * 지금 화면(뷰포트)에 보이는 글자만. 페이지 전체 innerText 를 주면 내려 보지 않고도 아래 내용을 안다.
430
- * 글자 조각마다 자리를 보고, 줄이 바뀌면 줄을 바꾼다.
431
- */
432
- async function visibleText(page, max = 2500) {
433
- const t = await page
434
- .evaluate((ownSel) => {
435
- const VW = (window.visualViewport && visualViewport.width) || document.documentElement.clientWidth || innerWidth;
436
- const out = [];
437
- let lastTop = null;
438
- // listControls 의 clippedX 와 같다 — 가로로 잘린 틀 밖의 글자는 안 보인다
439
- const clipCache = new Map();
440
- const clippedX = (el, r) => {
441
- for (let p = el.parentElement, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
442
- let ox = clipCache.get(p);
443
- if (ox === undefined) clipCache.set(p, (ox = getComputedStyle(p).overflowX));
444
- if (ox === "visible") continue;
445
- const pr = p.getBoundingClientRect();
446
- if (r.right <= pr.left + 1 || r.left >= pr.right - 1) return true;
447
- }
448
- return false;
449
- };
450
- /**
451
- * listControls 의 buried 와 같다 — 화면을 통째로 덮은 불투명한 층(모바일 전체 화면 정보 창) 밑의 글자는 안 보인다.
452
- * 남기면 창 글자와 밑의 목록이 한 덩어리로 섞여 새 페이지인지 창인지 모른다(10/21 실험실 q4)
453
- */
454
- // false | "under"(안 보임) | "dim"(열린 창 뒤에 흐리게 보임)
455
- const buriedCache = new Map();
456
- const buried = (el, r) => {
457
- if (buriedCache.has(el)) return buriedCache.get(el);
458
- let res = false;
459
- const x = Math.min(Math.max((r.left + r.right) / 2, 1), VW - 1);
460
- const y = Math.min(Math.max((r.top + r.bottom) / 2, 1), innerHeight - 1);
461
- outer: for (const h of document.elementsFromPoint(x, y)) {
462
- if (h === el || el.contains(h) || h.contains(el)) break;
463
- if (h.closest(ownSel)) continue;
464
- for (let n = h; n && n !== document.body && !n.contains(el); n = n.parentElement) {
465
- const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
466
- const alpha = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
467
- if (!alpha) continue;
468
- const lr = n.getBoundingClientRect();
469
- 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) {
470
- res = alpha >= 0.9 ? "under" : "dim";
471
- break outer;
472
- }
473
- break;
474
- }
475
- }
476
- buriedCache.set(el, res);
477
- return res;
478
- };
479
- const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
480
- const range = document.createRange();
481
- const behind = [];
482
- let n = 0;
483
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
484
- if (++n > 20000) break;
485
- const txt = node.nodeValue.replace(/\s+/g, " ").trim();
486
- if (!txt) continue;
487
- const el = node.parentElement;
488
- if (!el || el.closest(ownSel) || el.closest("script,style,noscript,template")) continue;
489
- range.selectNodeContents(node);
490
- const r = range.getBoundingClientRect();
491
- if (r.width < 1 || r.height < 1) continue;
492
- if (r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= VW) continue;
493
- const st = getComputedStyle(el);
494
- if (st.visibility === "hidden" || st.opacity === "0") continue;
495
- if (clippedX(el, r)) continue;
496
- const b = buried(el, r);
497
- if (b === "under") continue;
498
- // 열린 창 뒤에 흐리게 비치는 글은 따로 모은다 — 섞으면 창이 어디서 끝나는지 모른다(10/21 실험실 q3·r4·s1·t1)
499
- if (b === "dim") {
500
- behind.push(txt);
501
- continue;
502
- }
503
- if (lastTop !== null && Math.abs(r.top - lastTop) > 4) out.push("\n");
504
- else if (out.length) out.push(" ");
505
- out.push(txt);
506
- lastTop = r.top;
507
- }
508
- // 빈 입력칸의 흐린 안내 글(placeholder)도 사람 눈에 보인다 — 없으면 "Your Reply" 칸이 목록에만 있고 화면에는 없었다(10/21 실험실 G1).
509
- // 글 사이 자리를 찾기 어려워 끝에 모은다
510
- const boxes = [];
511
- for (const f of document.querySelectorAll("input[placeholder],textarea[placeholder]")) {
512
- if (f.closest(ownSel) || f.value) continue;
513
- const r = f.getBoundingClientRect();
514
- if (r.width < 2 || r.height < 2 || r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= VW) continue;
515
- if (getComputedStyle(f).visibility === "hidden") continue;
516
- if (buried(f, r) === "under") continue;
517
- // 끝에 모으니 어디 있는 칸인지 몰랐다(10/21 실험실 H3) — 대략 자리를 붙인다
518
- const mid = (r.top + r.bottom) / 2;
519
- const where = mid < innerHeight / 3 ? "near the top" : mid > (innerHeight * 2) / 3 ? "near the bottom" : "in the middle";
520
- boxes.push(`[empty box ${where}: "${f.getAttribute("placeholder").trim().slice(0, 40)}"]`);
521
- }
522
- if (boxes.length) out.push("\n", boxes.join(" "));
523
- // 뒤의 흐린 글은 짧게 — 길면 글자 한도를 먹어 창 쪽 글이 단어 중간에 잘렸다(10/21 실험실 u1)
524
- if (behind.length) {
525
- const b = behind.join(" · ");
526
- out.push("\n\n[Behind the open panel, dimmed:]\n", b.length > 500 ? b.slice(0, b.lastIndexOf(" ", 500)) + " …" : b);
527
- }
528
- return out.join("");
529
- }, OWN_SEL)
530
- .catch(() => "");
531
- const s = t.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim();
532
- if (s.length <= max) return s;
533
- // 넘치면 낱말 사이에서 자르고 잘렸다고 적는다 — "disap" 처럼 끊기면 화면이 그렇게 생긴 줄 안다(10/21 실험실 u1)
534
- return s.slice(0, Math.max(s.lastIndexOf(" ", max), s.lastIndexOf("\n", max), max - 40)) + " … (more text on screen not shown)";
535
- }
536
-
537
- /** 번호로 그 요소를 집는다. 화면이 바뀌었으면 null */
538
- async function handleAt(page, index) {
539
- const handle = await page.evaluateHandle((i) => (window.__ebMcpEls || [])[i] || null, index);
540
- const el = handle.asElement();
541
- if (!el) return null;
542
- const ok = await el.evaluate((n) => !!n && n.isConnected).catch(() => false);
543
- return ok ? el : null;
544
- }
545
-
546
- /**
547
- * 남의 사이트에 흔적을 남기지 않는다(AUTO-EXPLORE "지켜야 할 것").
548
- * 고객이 자기 사이트를 봐 달라는 QA 에서는 로그인·결제가 오히려 봐야 할 핵심이라 옵션으로 연다.
549
- */
550
- const ACCOUNT_WORDS = /sign\s?in|sign\s?up|log\s?in|login|register|회원가입|로그인/i;
551
- /** 돈이 나가거나 지우는 것. 쓰기를 보내기로 골라도(policy.writes) 막는다 */
552
- // 단어로 본다 — "subscribe" 를 그냥 찾으면 "Subscribed" 탭(구독한 목록 보기)까지 막혔다(10/14 실험실 p9)
553
- const PAY_WORDS = /결제|구매|주문|checkout|\bpay\b|\bsubscribe\b|삭제|\bdelete\b/i;
554
-
555
- /**
556
- * 무엇을 열어 줘도 누르지 않는 것. 로그인 세션을 여럿이 같이 쓰므로(PLAN.md "로그인")
557
- * 한 명이 로그아웃하면 뒤 사람들이 다 끊기고, 탈퇴·비번 변경은 고객의 데모 계정을 망가뜨린다.
558
- */
559
- const NEVER_WORDS = /log\s?out|logout|sign\s?out|로그아웃|탈퇴|delete\s+account|계정\s?삭제|비밀번호\s?변경|change\s+password|reset\s+password/i;
560
-
561
- /**
562
- * @param policy 고객이 정한 금지 구역(policy.js). 사이트마다 다른 것은 여기서 온다 —
563
- * "예약하기" 처럼 기본 목록에 없는 버튼(2026-09-21 여기어때)
564
- */
565
- function clickBlocked(item, { allowAccounts = false, policy = null } = {}) {
566
- // 같은 이름을 가르려고 붙인 "(near: …)"·"(2 of 3)" 은 떼고 본다 — 붙이면 40 자를 넘어 "Sign out" 같은 걸 못 막을 수 있다
567
- item = { ...item, label: String(item.label || "").replace(/ \((near: .*|\d+ of \d+)\)$/, "") };
568
- if (policy) {
569
- if (policy.labelBlocked(item.label)) return `blocked by policy ("${item.label}")`;
570
- if (item.href) {
571
- try {
572
- const why = policy.pathReason(new URL(item.href, "http://x").pathname);
573
- if (why) return `${why} — ${item.href}`;
574
- } catch (e) {}
575
- }
576
- }
577
- if ((item.label && item.label.length <= 40 && NEVER_WORDS.test(item.label)) || (item.href && NEVER_WORDS.test(item.href))) {
578
- return `would sign out or damage the account ("${item.label || item.href}")`;
579
- }
580
- const short = item.label && item.label.length <= 40;
581
- if ((short && PAY_WORDS.test(item.label)) || (item.href && PAY_WORDS.test(item.href))) {
582
- return `looks like a payment or delete action ("${item.label || item.href}")`;
583
- }
584
- // 정책이 있으면(MCP 방문은 늘 있다) 로그인 화면을 보러 가는 것은 막지 않는다 — 사람도 무엇을 받는지 보러 간다.
585
- // 실제로 로그인·가입하는 건 이미 막혀 있다: 이메일·비밀번호 칸은 못 치고(policy fields), 보내는 요청은 끊는다
586
- if (allowAccounts || policy) return null;
587
- if (short && ACCOUNT_WORDS.test(item.label)) return `looks like an account action ("${item.label}")`;
588
- if (item.href && ACCOUNT_WORDS.test(item.href)) return `account link (${item.href})`;
589
- return null;
590
- }
591
-
592
- /**
593
- * 이름 없는 아이콘의 그림. 사람은 모양(클립·종이비행기·X)으로 아는데 방문자는 "(icon 190,785)" 만 받아
594
- * 채팅 보내기 대신 사진 첨부를 눌렀다(10/21 bluetags 2b72). 화면 안의 이름 없는 아이콘만 잘라 번호를 달아 한 장으로 붙인다.
595
- * 없으면 null. 붙이는 판은 방문과 따로 연 빈 창이라 사이트에 아무것도 안 보낸다
596
- *
597
- * @param page 방문 중인 페이지(listControls 를 막 부른 뒤 — 번호가 그 목록 기준이다)
598
- * @param items listControls().items
599
- * @param browser 붙이는 판을 열 브라우저
600
- */
601
- async function iconPicture(page, items, browser, max = 16) {
602
- // 숫자뿐인 것(하트·말풍선 옆의 좋아요·답글 수)도 — 글자가 "0" 이라 이름이 있는 줄 알았는데 무엇의 수인지 몰랐다(10/21 실험실 i4)
603
- const icons = items
604
- .filter((it) => it.where === "in view" && (/^\(icon[ ]/.test(it.label) || /^\d{1,5}( \(|$)/.test(it.label)))
605
- // 입력칸 곁(보내기·첨부)을 먼저 — 아이콘이 많은 화면에서 16 개 안에 들게
606
- .sort((a, b) => (b.nearField ? 1 : 0) - (a.nearField ? 1 : 0))
607
- .slice(0, max);
608
- if (!icons.length || !browser) return null;
609
- const vp = page.viewportSize() || { width: 1280, height: 860 };
610
- const crops = [];
611
- for (const it of icons) {
612
- const el = await handleAt(page, it.i);
613
- const box = el && (await el.boundingBox().catch(() => null));
614
- if (!box || box.width < 2 || box.height < 2) continue;
615
- // 둘레를 조금 넣어 자른다 — 아이콘만 딱 자르면 테두리·배경이 없어 모양이 안 읽힌다
616
- const pad = 4;
617
- const x = Math.max(0, box.x - pad);
618
- const y = Math.max(0, box.y - pad);
619
- const clip = { x, y, width: Math.min(vp.width - x, box.width + pad * 2), height: Math.min(vp.height - y, box.height + pad * 2) };
620
- if (clip.width < 2 || clip.height < 2 || clip.width > 160 || clip.height > 160) continue;
621
- const png = await page.screenshot({ clip, animations: "disabled", timeout: 3000 }).catch(() => null);
622
- if (png) crops.push({ i: it.i, png, w: clip.width, h: clip.height });
623
- }
624
- if (!crops.length) return null;
625
- const ctx = await browser.newContext({ viewport: { width: 640, height: 200 } });
626
- try {
627
- const sheet = await ctx.newPage();
628
- // 작은 아이콘은 키운다(높이 48px 쯤) — 24px 그대로는 모양이 뭉개져 읽힌다
629
- const cells = crops
630
- .map((c) => {
631
- const k = Math.max(1, Math.min(4, 48 / c.h));
632
- return `<figure><figcaption>[${c.i}]</figcaption><img src="data:image/png;base64,${c.png.toString("base64")}" width="${Math.round(c.w * k)}" height="${Math.round(c.h * k)}"></figure>`;
633
- })
634
- .join("");
635
- await sheet.setContent(
636
- `<!doctype html><html><body style="margin:0;padding:8px;background:#fff;font:bold 14px sans-serif;display:flex;flex-wrap:wrap;gap:12px;width:620px">` +
637
- `<style>figure{margin:0;display:flex;flex-direction:column;align-items:center;gap:4px;padding:4px;border:1px solid #ddd}img{display:block}</style>${cells}</body></html>`,
638
- );
639
- return await sheet.screenshot({ fullPage: true, timeout: 5000 });
640
- } catch {
641
- return null;
642
- } finally {
643
- await ctx.close().catch(() => {});
644
- }
645
- }
646
-
647
- module.exports = { OWN_SEL, listControls, visibleText, handleAt, clickBlocked, iconPicture };
1
+ "use strict";
2
+ /**
3
+ * 지금 화면에 무엇이 보이는가 — 자유 탐방이 다음 행동을 정하는 재료.
4
+ *
5
+ * **화면만 본다.** 저장소 코드를 읽어 "여기 이런 버튼이 있다" 를 미리 알지 않는다.
6
+ * 코드를 보고 누르면 사람이 못 찾는 버튼을 바로 찾아버려 QA 값이 사라진다(AUTO-EXPLORE).
7
+ *
8
+ * 의존성 없음. Playwright page 객체만 받는다.
9
+ */
10
+
11
+ /** 우리 것(질문 카드·위젯)은 사이트 화면이 아니다 */
12
+ const OWN_SEL = "#eb-insight,#eb-plugin";
13
+
14
+ /** 그 사람이 지금 누를 수 있는 것들. 번호는 이번 화면에서만 유효하다 */
15
+ async function listControls(page, max = 60) {
16
+ return page.evaluate(
17
+ ({ ownSel, max }) => {
18
+ // 보이는 화면 폭. 옆으로 넘친 페이지(모바일에서 창이 삐져나감)는 innerWidth 가 배치 폭으로 늘어나 화면 밖을 "보인다" 고 했다(10/15)
19
+ const VW = (window.visualViewport && visualViewport.width) || document.documentElement.clientWidth || innerWidth;
20
+ const SEL = 'a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="switch"],[role="radio"],summary,input:not([type="hidden"]),select,textarea';
21
+ const vis = (el) => {
22
+ if (!el.getClientRects().length) return false;
23
+ // 크기가 0 인 것은 사람 눈에 없다. 모바일에서 접혀 있는 검색칸이 여기 걸린다 —
24
+ // 목록에 남기면 봇이 "보이니까 칠 수 있다" 고 여기고 입력이 실패한다(2026-09-18)
25
+ const r = el.getBoundingClientRect();
26
+ if (r.width < 2 || r.height < 2) return false;
27
+ const st = getComputedStyle(el);
28
+ if (st.visibility === "hidden" || st.display === "none" || st.opacity === "0") return false;
29
+ // 화면 옆으로 완전히 나간 것은 보이지 않는다. 위아래와 달리 굴려서 오지도 않는다(10/15 모바일 공유 창)
30
+ if (r.left >= VW || r.right <= 0) return false;
31
+ return !clippedX(el, r);
32
+ };
33
+ /**
34
+ * 가로로 넘쳐 잘린 틀(회전 배너·가로 목록) 밖에 있으면 사람 눈에 없다. 목록에 넣으면 방문자가 누르고
35
+ * "화면에 안 보인다" 를 듣는다(10/14 bluetags /project 배너, 19개 중 9개가 틀 밖). 세로로 넘친 목록은 그대로 둔다 —
36
+ * 안쪽을 굴리는 손이 아직 없어 빼면 아예 못 닿는다
37
+ */
38
+ function clippedX(el, r) {
39
+ for (let p = el.parentElement, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
40
+ const ox = getComputedStyle(p).overflowX;
41
+ if (ox === "visible") continue;
42
+ const pr = p.getBoundingClientRect();
43
+ if (r.right <= pr.left + 1 || r.left >= pr.right - 1) return true;
44
+ }
45
+ return false;
46
+ }
47
+ /**
48
+ * 사람 눈에 흐리거나 막힌 것. 날짜 없는 카드의 "Add to calendar" 는 흐리고(opacity 0.3) 커서가 금지 표시라
49
+ * 사람은 안 눌리는 줄 아는데, 방문자는 몰라서 세 번 눌렀다(10/15 bluetags 100명 p87)
50
+ */
51
+ const dimmed = (el) => {
52
+ if (el.disabled || el.getAttribute("aria-disabled") === "true") return true;
53
+ let op = 1;
54
+ for (let p = el, d = 0; p && p !== document.body && d < 12; p = p.parentElement, d++) {
55
+ const st = getComputedStyle(p);
56
+ if (d < 3 && st.cursor === "not-allowed") return true;
57
+ op *= parseFloat(st.opacity) || 1;
58
+ }
59
+ return op <= 0.45;
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
+ };
70
+ /**
71
+ * 체크박스·라디오는 상태와 붙은 글로 부른다. value 로 부르면 기본값 "on" 이 이름이 되어, 방문자가 약관 체크박스를
72
+ * "Notification Settings 링크" 로 알고 두 번 눌렀다 — 체크했다가 도로 풀었다(10/22 bluetags /signup p2018)
73
+ */
74
+ const boxOf = (el) => {
75
+ const role = el.getAttribute("role");
76
+ const native = el.tagName === "INPUT" && (el.type === "checkbox" || el.type === "radio");
77
+ if (!native && role !== "checkbox" && role !== "switch" && role !== "radio") return "";
78
+ const on = native ? el.checked : el.getAttribute("aria-checked") === "true";
79
+ const kind = (native ? el.type : role) === "radio" ? "option" : role === "switch" ? "switch" : "tick box";
80
+ // <label> 이 없으면 곁의 글(같은 틀 안)을 쓴다 — 약관 체크박스는 글이 옆 칸에 따로 있다
81
+ const beside = () => {
82
+ const p = el.parentElement;
83
+ return p && p.querySelectorAll("input,button,a[href],select,textarea").length <= 1 ? p.innerText : "";
84
+ };
85
+ const said = (el.getAttribute("aria-label") || (el.labels && el.labels[0] && el.labels[0].innerText) || (native ? "" : el.innerText) || beside() || "").replace(/\s+/g, " ").trim();
86
+ return `${kind} (${kind === "switch" ? (on ? "on" : "off") : on ? "ticked" : "not ticked"})${said ? " " + said : ""}`;
87
+ };
88
+ const nameOf = (el) =>
89
+ (boxOf(el) || el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || el.getAttribute("placeholder") || "")
90
+ .replace(/\s+/g, " ")
91
+ .trim()
92
+ .slice(0, 60);
93
+
94
+ const cand = [];
95
+ const seen = new Set();
96
+ for (const el of document.querySelectorAll(SEL)) {
97
+ if (el.closest(ownSel) || !vis(el)) continue;
98
+ const tag = el.tagName.toLowerCase();
99
+ const href = el.getAttribute("href") || "";
100
+ const label = nameOf(el) || (tag === "a" && href ? `(icon link ${href.split(/[?#]/)[0]})` : "");
101
+ if (!label && !["input", "select", "textarea"].includes(tag)) continue;
102
+ const r = el.getBoundingClientRect();
103
+ // 같은 곳으로 가는 링크 둘은 하나로 친다. 주소 없는 버튼은 자리로 가른다 — 같은 말 "Post" 가
104
+ // 댓글 칸과 답글 칸에 하나씩 있는데 뒤의 것이 빠져 답글을 못 올렸다(10/15 bluetags 100명 p95)
105
+ const key = tag + "|" + label + "|" + (href || `@${Math.round(r.left)},${Math.round(r.top + scrollY)}`);
106
+ if (seen.has(key)) continue;
107
+ seen.add(key);
108
+ const where = whereOf(r);
109
+ const inView = where === "in view";
110
+ 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 });
111
+ // 성능용 안전 상한. 이건 사람에게 보여줄 예산이 아니다
112
+ if (cand.length >= 800) break;
113
+ }
114
+ /**
115
+ * 태그로만 모으면 **사람 눈에 눌리는 것이 통째로 빠진다.** 요즘 사이트는 목록 제목·필터 칩·탭을
116
+ * onClick 달린 div 로 만든다. 블루태그스에서는 커뮤니티 제목과 달력의 Week/Month 가 그래서 안 잡혔고,
117
+ * 봇은 그걸 누를 수 있는 줄도 몰랐다(2026-09-21). 커서가 손가락인 것을 사람 기준으로 한 번 더 줍는다.
118
+ */
119
+ const pointerSel = "div,span,h1,h2,h3,h4,li,p,td,label,img,svg";
120
+ /**
121
+ * 누를 수 있나: 커서가 손가락이거나, React 가 클릭을 붙여 둔 것.
122
+ * 커서를 안 바꾼 버튼이 있다 — 카드의 공유 아이콘이 그래서 아예 안 보였다(10/15 실험실 p18).
123
+ * React(Next.js 포함)는 요소에 __reactProps 를 남긴다. 다른 틀에서는 커서만 본다
124
+ */
125
+ const reactClick = (n) => {
126
+ for (const k in n) if (k.startsWith("__reactProps") && n[k] && typeof n[k].onClick === "function") return true;
127
+ return false;
128
+ };
129
+ const clickable = (n) => getComputedStyle(n).cursor === "pointer" || reactClick(n);
130
+ /**
131
+ * 글자 없는 아이콘의 생김새 힌트. 사람은 아이콘 모양으로 안다 — 그림을 못 보니 이름을 빌린다:
132
+ * title·aria-label, 안쪽 svg 의 <title>·<use href="#icon-x">. 없으면 자리만 알린다
133
+ */
134
+ const iconHint = (n) => {
135
+ const t = n.getAttribute("title") || n.getAttribute("aria-label") || n.querySelector("title")?.textContent;
136
+ if (t && t.trim()) return t.trim().slice(0, 30);
137
+ const use = n.querySelector("use");
138
+ const ref = use && (use.getAttribute("href") || use.getAttribute("xlink:href"));
139
+ if (ref) {
140
+ const w = ref.replace(/^.*#/, "").replace(/^(icon|ic|i)[-_]/i, "").replace(/[-_]+/g, " ").trim();
141
+ if (/^[a-z][a-z ]{2,24}$/i.test(w)) return w.toLowerCase();
142
+ }
143
+ // 클래스 이름은 쓰지 않는다. 아이콘의 뜻이 아니라 속한 덩어리 이름일 때가 많다 — 달력 화살표가 "calendar" 가 됐다(10/15)
144
+ return null;
145
+ };
146
+ // getComputedStyle 은 레이아웃을 강제로 계산시킨다. 큰 사이트에서는 요소가 수천 개라 훑는 양 자체를 막는다
147
+ let scanned = 0;
148
+ for (const el of document.querySelectorAll(pointerSel)) {
149
+ if (cand.length >= 800 || ++scanned > 4000) break;
150
+ if (el.closest(ownSel) || !vis(el)) continue;
151
+ if (el.closest(SEL)) continue; // 이미 위에서 잡은 조작 안이다
152
+ if (!clickable(el)) continue;
153
+ // 커서가 손가락인 겉껍데기가 통째로 잡히면 목록이 같은 말로 가득 찬다. 가장 안쪽만 남긴다.
154
+ // 다만 글자를 직접 가진 것은 남긴다 — 아이콘+글자 버튼("☆ Follow")에서 아이콘만 "(icon …)" 으로 남고
155
+ // 버튼이 빠졌다(10/14 실험실 p9: "Follow 가 글자로만 보인다"). 그 안의 아이콘은 아래에서 뺀다
156
+ const ownText = (n) => Array.from(n.childNodes).some((c) => c.nodeType === 3 && c.nodeValue.trim());
157
+ // 안쪽이 목록에 못 드는 작은 것(8px 미만 화살표 그림)이면 겉을 남긴다 — 달력 "다음 달" 버튼이 통째로 빠졌다(10/14 실험실 p29)
158
+ const listable = (c) => {
159
+ const b = c.getBoundingClientRect();
160
+ // 목록 후보가 될 수 있는 것만 — svg 안의 path 도 커서를 물려받는데, path 는 후보가 아니라 공유 아이콘이 통째로 빠졌다(10/15 p18)
161
+ return c.matches(pointerSel) && clickable(c) && c.getClientRects().length && b.width >= 8 && b.height >= 8;
162
+ };
163
+ if (!ownText(el) && Array.from(el.children).some(listable)) continue;
164
+ // 진짜 조작(링크·버튼)을 감싼 껍데기 — 그건 위에서 이미 잡았다. 껍데기를 따로 올리면 주소 없는 같은 말이 하나 더 생겨,
165
+ // 방문자가 그걸 누르면 바깥 사이트로 가는 줄 모르고 "눌러도 그대로" 로 찍혔다(10/23 실험실 Q3, 회원가입 "(see details)")
166
+ // 껍데기만 뺀다 — 안의 조작이 거의 다 덮는 것. 안에 작은 버튼(북마크)이 든 카드는 카드대로 남는다
167
+ const inner = !ownText(el) && el.querySelector(SEL);
168
+ if (inner) {
169
+ const a = el.getBoundingClientRect();
170
+ const b = inner.getBoundingClientRect();
171
+ if (b.width * b.height >= a.width * a.height * 0.6) continue;
172
+ }
173
+ const up = el.parentElement;
174
+ // 글자를 직접 가진 버튼 안의 조각(아이콘, "Event 0" 의 숫자 "0")은 그 버튼의 일부다
175
+ if (up && ownText(up) && clickable(up)) continue;
176
+ const text = (el.innerText || el.getAttribute("aria-label") || el.getAttribute("alt") || "").replace(/\s+/g, " ").trim();
177
+ if (text.length > 60) continue; // 카드 전체를 감싼 것. 그 안의 제목이 따로 잡힌다
178
+ const r = el.getBoundingClientRect();
179
+ // 작은 크기는 글자 없는 것에만 따진다. 날짜 "1" 은 폭이 5px 이라 빠졌다(10/14 실험실 달력)
180
+ if (text ? r.width < 2 || r.height < 6 : r.width < 8 || r.height < 8) continue;
181
+ // 글자 없이 화면 반을 덮는 것은 아이콘이 아니라 창 뒤의 막이다 — "(icon 0,0)" 으로 나와 방문자가 뭔지 몰랐다(10/21 실험실 q5).
182
+ // 막 뒤의 것을 누르면 손이 막을 누른다
183
+ if (!text && r.width * r.height > VW * innerHeight * 0.25) continue;
184
+ const hint = text ? null : iconHint(el);
185
+ const label = text || (hint ? `(icon: ${hint})` : `(icon ${Math.round(r.left)},${Math.round(r.top)})`);
186
+ // 같은 말인 칩이 여럿인 UI 가 흔하다(태그 목록). 자리까지 봐야 서로 다른 조작으로 남는다
187
+ const key = `pointer|${label}|${Math.round(r.left)},${Math.round(r.top + scrollY)}`;
188
+ if (seen.has(key)) continue;
189
+ seen.add(key);
190
+ const where = whereOf(r);
191
+ const inView = where === "in view";
192
+ 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 });
193
+ }
194
+
195
+ /**
196
+ * **문서 순서로 자르면 안 된다.** 머리말·메뉴·배너가 예산을 다 먹고 본문이 통째로 안 보인다.
197
+ * 링크가 많은 서비스 사이트에서는 60번째 전에 헤더와 이벤트 배너만으로 끝난다 —
198
+ * 그러면 봇은 사람이 보고 있는 것을 못 본 채로 다음 걸음을 고른다.
199
+ * 지금 화면에 보이는 것부터 남기고, 같은 순위 안에서는 문서 순서를 지킨다(sort 는 안정 정렬).
200
+ * 2026-09-21 여기어때에서 드러났다. 블루태그스 Week/Month 토글도 같은 결의 문제였다.
201
+ */
202
+ /**
203
+ * 화면을 통째로 덮은 층(모바일의 전체 화면 정보 창) 밑에 깔린 것은 사람 눈에 없다. 목록에 넣으면 방문자가
204
+ * 밑의 카드를 누르고 "덮였다" 만 세 번 듣고 나갔다(10/21 bluetags p1937, /community?info= 모바일).
205
+ * 고정 머리줄·아래 메뉴처럼 좁은 막대에 가린 것은 남긴다 — 손이 굴려서 누른다
206
+ */
207
+ const buried = (el) => {
208
+ const r = el.getBoundingClientRect();
209
+ const x = Math.min(Math.max((r.left + r.right) / 2, 1), VW - 1);
210
+ const y = Math.min(Math.max((r.top + r.bottom) / 2, 1), innerHeight - 1);
211
+ // 위에서부터 겹친 것을 다 본다 — 맨 위가 다른 작은 것(사이트의 설문 창)이어도 그 밑에 덮은 층이 있다
212
+ for (const h of document.elementsFromPoint(x, y)) {
213
+ if (h === el || el.contains(h) || h.contains(el)) return false;
214
+ if (h.closest(ownSel)) continue;
215
+ // 맞은 것에서 올라가며 처음 칠이 있는 것. 반투명한 뒤판(데스크톱 정보 창 옆의 흐린 막)은 비쳐 보이고
216
+ // 누르면 창이 닫힌다 — 남긴다. 칠 없는 것은 투명하니 그 밑을 본다
217
+ for (let n = h; n && n !== document.body && !n.contains(el); n = n.parentElement) {
218
+ const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
219
+ const alpha = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
220
+ if (!alpha) continue;
221
+ const lr = n.getBoundingClientRect();
222
+ 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;
223
+ // 반투명한 큰 막 밑 = 열린 창 뒤에 흐리게 보이는 것. 사람은 창이 떠 있는 줄 안다 — 방문자도 알아야
224
+ // 뒤의 카드를 눌러 창만 닫히는 걸 이해한다(10/21 실험실 q3)
225
+ if (big && alpha < 0.9) return "dim";
226
+ if (big) {
227
+ covers.add(n);
228
+ return "under";
229
+ }
230
+ break; // 작은 것(사이트의 설문 창, 고정 막대) — 그 밑을 본다
231
+ }
232
+ }
233
+ return false;
234
+ };
235
+ const covers = new Set();
236
+ for (let k = cand.length - 1; k >= 0; k--) {
237
+ if (cand[k].where !== "in view") continue;
238
+ const b = buried(cand[k].el);
239
+ if (b === "under") cand.splice(k, 1);
240
+ else if (b === "dim") cand[k].behind = true;
241
+ }
242
+ // 덮은 층이 화면에 붙어 있으면(fixed) 굴려도 그대로 덮여 있다 — 그 밖의 위·아래 것도 안 보인다.
243
+ // 남기면 "위에 있다" 고 나와 방문자가 올려 보고 누르다 덮였다를 듣는다(10/21 실험실 q2)
244
+ const fixedCovers = [...covers].filter((n) => {
245
+ for (let p = n; p && p !== document.body; p = p.parentElement) if (getComputedStyle(p).position === "fixed") return true;
246
+ return false;
247
+ });
248
+ 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);
249
+ // 화면을 덮은 창이 있으면 굴리는 건 그 창이다. 뒤 페이지의 위치를 말하면 "아래에 더 있다" 는데 굴려도 화면이 그대로였다(10/21 실험실 r1)
250
+ // 페이지가 잠겨 있으면(창이 뜨며 사이트가 막음) 화면에서 굴러가는 가장 큰 칸 — 데스크톱 정보 창 안쪽(10/21 실험실 r4)
251
+ // 흐린 막 위에 창이 떠 있으면 사람은 그 창을 굴린다 — 주소로 바로 열린 창은 뒤 페이지가 안 잠겨
252
+ // 위치·"아래에 더" 가 뒤 페이지 기준이라 창 맨 아래 Join 을 끝내 못 봤다(10/21 실험실 u4)
253
+ const dimOpen = cand.some((c) => c.behind);
254
+ const pageLocked = dimOpen || document.documentElement.scrollHeight <= innerHeight + 2 || getComputedStyle(document.body).overflowY === "hidden";
255
+ let roll = { y: scrollY, height: document.documentElement.scrollHeight, viewport: innerHeight };
256
+ // 굴리는 칸이 페이지가 아니면 "아래에 더 있다" 도 그 칸 안의 것만 센다 — 잠긴 페이지 뒤 목록을 세면 끝에 닿아도 "더 있다" 였다
257
+ let pane = null;
258
+ if (fixedCovers.length || pageLocked) {
259
+ roll = { y: 0, height: innerHeight, viewport: innerHeight };
260
+ pane = fixedCovers[0] || null;
261
+ let best = 0;
262
+ for (const n of fixedCovers.length ? [fixedCovers[0], ...fixedCovers[0].querySelectorAll("*")] : document.body.querySelectorAll("*")) {
263
+ if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100) continue;
264
+ if (!/auto|scroll/.test(getComputedStyle(n).overflowY)) continue;
265
+ // 창이 떠 있으면 창 쪽(흐리지 않은 것을 품은 칸)만
266
+ // 뒤 목록까지 품은 바깥 칸(페이지 틀)은 창이 아니다 — 그걸 굴리면 뒤 목록이 굴렀다
267
+ 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;
268
+ const r = n.getBoundingClientRect();
269
+ 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));
270
+ if (area > best) {
271
+ best = area;
272
+ pane = n;
273
+ roll = { y: n.scrollTop, height: n.scrollHeight, viewport: n.clientHeight };
274
+ }
275
+ }
276
+ }
277
+ // 창이 떠 있으면 창 밖의 위·아래 것은 굴려도 안 온다 — 뒤 목록의 필터가 "위에 있다" 로 남았다(10/21 실험실 u1)
278
+ 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);
279
+ cand.sort((a, b) => a.rank - b.rank);
280
+ /**
281
+ * **화면 아래는 보여주지 않는다.** 사람은 내려 봐야 거기 뭐가 있는지 안다.
282
+ * 이름까지 알려주면 봇이 스크롤 없이 바로 누른다 — 10/13 bluetags 105명 중 79명이 한 번도 안 굴렸다.
283
+ * 지나온 위쪽은 이미 본 것이라 남긴다.
284
+ */
285
+ const below = cand.filter((c) => c.where === "below").length;
286
+ const shown = cand.filter((c) => c.where !== "below");
287
+ /**
288
+ * 같은 이름이 여럿이면 어느 것인지 모른다 — "Project logo image" 가 다섯 개(10/14 실험실). 사람은 로고 옆 이름으로 가른다.
289
+ * 겹치는 것에만 가장 가까운 덩어리의 다른 글자를 붙인다
290
+ */
291
+ // 넘치면 이름 없는 아이콘부터 뺀다. 달력 칸마다 아이콘이 서너 개라 60개를 다 먹고 날짜·버튼이 잘렸다(10/14 실험실 p29)
292
+ let pool = shown;
293
+ let iconsLeft = 0;
294
+ // 화면 안 입력칸(글 쓰는 곳)과, 그 둘레 48px 안에 있는 것
295
+ for (const c of shown) c.field = c.where === "in view" && ["input", "textarea"].includes(c.tag);
296
+ const fieldBoxes = shown.filter((c) => c.field).map((c) => c.el.getBoundingClientRect());
297
+ const nearField = (c) => {
298
+ if (!fieldBoxes.length) return false;
299
+ const r = c.el.getBoundingClientRect();
300
+ return fieldBoxes.some((f) => r.right >= f.left - 48 && r.left <= f.right + 48 && r.bottom >= f.top - 48 && r.top <= f.bottom + 48);
301
+ };
302
+ // 화면 안의 것만으로 넘칠 때만 — 지나온 위쪽 때문에 넘친 거면 위쪽을 자르면 된다. 아이콘까지 빼면 보이는 것이 빠진다(10/15)
303
+ if (pool.filter((c) => c.where === "in view").length > max) {
304
+ // 같은 이름으로 되풀이되는 그림도 아이콘처럼 먼저 뺀다 — 카드마다의 "Project logo image" 17개가 자리를 먹어
305
+ // 화면 아래 카드들이 빠졌다(10/15 실험실 y38, 로그인한 데스크톱 /community). 그림 옆에 같은 카드의 이름이 따로 있다
306
+ const imgCount = {};
307
+ for (const c of pool) if (c.tag === "img") imgCount[c.label] = (imgCount[c.label] || 0) + 1;
308
+ const isIcon = (c) => /^\(icon \d/.test(c.label) || (c.tag === "img" && imgCount[c.label] >= 4);
309
+ // 화면 안의 것끼리만 센다. 위쪽 것까지 세면 자리가 있어도 아이콘을 다 뺐다
310
+ const inView = pool.filter((c) => c.where === "in view");
311
+ const named = inView.filter((c) => !isIcon(c)).length;
312
+ // 같은 크기로 여럿 되풀이되는 아이콘(달력 칸마다의 표시)부터 뺀다. 한두 개뿐인 것(이전·다음 화살표)은 남긴다
313
+ const bySize = {};
314
+ for (const c of inView) if (isIcon(c)) bySize[c.size] = (bySize[c.size] || 0) + 1;
315
+ const ranked = inView
316
+ .filter(isIcon)
317
+ .map((c, k) => ({ c, k, rep: bySize[c.size] }))
318
+ .sort((a, b) => a.rep - b.rep || a.k - b.k);
319
+ // 한두 개뿐인 아이콘(이전·다음 화살표, 닫기)은 이름 있는 것만으로 넘쳐도 남긴다 — 일정이 꽉 찬 달에서
320
+ // 이름 있는 칸만 100개가 넘어 화살표가 빠지고, 달을 못 넘겼다(10/20 실험실 p11, bluetags 2023년 9월)
321
+ const keep = new Set([
322
+ ...ranked.filter((x) => x.rep <= 2).slice(0, 4).map((x) => x.c),
323
+ ...ranked.slice(0, Math.max(0, max - named)).map((x) => x.c),
324
+ ]);
325
+ // 입력칸 바로 옆 아이콘(보내기·첨부·검색·지우기)은 남긴다 — 채팅방은 왼쪽 방 목록 아이콘과 크기가 같아
326
+ // 보내기·첨부가 같이 빠졌고, 방문자는 "보내기 버튼이 없다" 며 못 보냈다(10/21 bluetags 82fd)
327
+ for (const c of inView) if (isIcon(c) && nearField(c)) keep.add(c);
328
+ pool = pool.filter((c) => c.where !== "in view" || !isIcon(c) || keep.has(c));
329
+ iconsLeft = shown.length - pool.length;
330
+ }
331
+ // 입력칸과 그 곁 아이콘은 앞으로 — 60 개로 자를 때 페이지 아래쪽 채팅칸이 잘리지 않게
332
+ if (pool.length > max) {
333
+ const front = pool.filter((c) => c.where === "in view" && (c.field || nearField(c)));
334
+ pool = [...front, ...pool.filter((c) => !front.includes(c))];
335
+ }
336
+ const picked = pool.slice(0, max);
337
+ const groups = {};
338
+ for (const c of picked) (groups[c.label] = groups[c.label] || []).push(c);
339
+ const nearAt = (c, d, g) => {
340
+ let p = c.el.parentElement;
341
+ for (let k = 0; k < d && p && p !== document.body; k++) p = p.parentElement;
342
+ if (!p) return "";
343
+ // 같은 이름의 다른 항목까지 감싼 덩어리의 글자는 그 항목의 곁이 아니다 — 맨 아래 "Bluetags" 에
344
+ // 페이지 전체의 첫 글(맨 위 카드)이 붙었다(10/21 실험실 q4·s4)
345
+ if (g.some((o) => o !== c && p.contains(o.el))) return "";
346
+ // 제 이름은 낱말째로만 뺀다 — 이름이 "0" 인 항목에서 날짜 "20" 의 0 이 빠져 "Oct 2" 가 됐다(10/21 실험실 w5)
347
+ const own = new RegExp(`(^|\\s)${c.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=\\s|$)`);
348
+ // 곁의 입력칸 안내 문구도 본다 — 댓글칸 Post 와 답글칸 Post 는 둘레 글자가 없어 "1 of 2" 가 됐다(10/21 실험실 m1)
349
+ const hints = [...p.querySelectorAll("input[placeholder],textarea[placeholder]")].map((n) => n.getAttribute("placeholder")).join(" ");
350
+ return `${hints} ${p.innerText || ""}`.replace(/\s+/g, " ").replace(own, "$1").replace(/\s+/g, " ").trim().slice(0, 40);
351
+ };
352
+ for (const [label, g] of Object.entries(groups)) {
353
+ if (g.length < 2) continue;
354
+ // 묶음 안에서 서로 다르게 읽히는 가장 가까운 층을 고른다. 없으면 순서로 가른다
355
+ let hints = null;
356
+ for (let d = 0; d < 6 && !hints; d++) {
357
+ const h = g.map((c) => nearAt(c, d, g));
358
+ if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
359
+ }
360
+ // 층을 하나로 맞추면 안 갈리는 것 — 댓글칸 Post 는 바로 위에, 답글칸 Post 는 한 층 더 위에 칸이 있었다(10/21 실험실 m1).
361
+ // 항목마다 가장 가까운 곁 글을 따로 찾는다
362
+ if (!hints) {
363
+ // 둘레 글이 없으면 가장 가까운 입력칸의 안내 문구 — 댓글칸 Post 는 곁 덩어리에 답글칸 Post 까지 들어 있어 끝내 못 갈랐다(10/21 실험실 m1·f1)
364
+ const fieldHint = (c) => {
365
+ const r = c.el.getBoundingClientRect();
366
+ let best = null;
367
+ let dist = 160;
368
+ for (const f of document.querySelectorAll("input[placeholder],textarea[placeholder]")) {
369
+ const b = f.getBoundingClientRect();
370
+ if (!b.width || !b.height) continue;
371
+ const dx = Math.max(b.left - r.right, r.left - b.right, 0);
372
+ const dy = Math.max(b.top - r.bottom, r.top - b.bottom, 0);
373
+ const dd = Math.hypot(dx, dy);
374
+ if (dd < dist) (dist = dd), (best = f.getAttribute("placeholder"));
375
+ }
376
+ return best ? `the "${best}" box` : "";
377
+ };
378
+ const h = g.map((c) => {
379
+ for (let d = 0; d < 6; d++) {
380
+ const x = nearAt(c, d, g);
381
+ if (x.length >= 2) return x;
382
+ }
383
+ return fieldHint(c);
384
+ });
385
+ if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
386
+ }
387
+ // "near:" — 괄호 속이 그 항목의 내용이 아니라 곁의 글이라는 표시. 없으면 "27 (28 29 30 1 2 3)" 이 날짜 여럿을 품은 칸처럼 읽혔다(10/21 실험실 j4·h1)
388
+ g.forEach((c, k) => (c.label = hints ? `${label} (near: ${hints[k]})` : `${label} (${k + 1} of ${g.length})`));
389
+ }
390
+ const els = [];
391
+ const out = picked.map((c, i) => {
392
+ els.push(c.el);
393
+ // 반 넘게 화면 옆으로 나간 것. 누르면 손이 "잘렸다" 고 한다 — 목록도 같은 말을 해야 한다(10/15 실험실 x87: 공유 막대가 화면을 넓혀 아래 메뉴가 밀렸다)
394
+ 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 } : {}), ...(c.where === "in view" && !c.field && nearField(c) ? { nearField: true } : {}) };
395
+ });
396
+ Object.defineProperty(window, "__ebMcpEls", { value: els, configurable: true, enumerable: false, writable: true });
397
+ return {
398
+ items: out,
399
+ /** 예산에 안 들어간 조작 수. 0 이 아니면 봇이 못 본 것이 있다는 뜻이다 */
400
+ dropped: Math.max(0, shown.length - out.length),
401
+ /**
402
+ * 그중 지금 화면 안의 것. 나머지는 지나온 위쪽이다 — 긴 목록을 내려가면 위쪽만으로 예산이 넘쳐
403
+ * "화면에 너무 많다" 고 잘못 말했다(10/15 bluetags 100명, 넘침 39번 거의 다 위쪽)
404
+ */
405
+ droppedInView: Math.max(0, shown.filter((c) => c.where === "in view").length - picked.filter((c) => c.where === "in view").length),
406
+ /** 그중 이름 없는 작은 아이콘 수 */
407
+ droppedIcons: iconsLeft,
408
+ /** 화면 아래에 더 있는 조작 수. 무엇인지는 말하지 않는다 */
409
+ below: pane ? cand.filter((c) => c.where === "below" && pane.contains(c.el)).length : below,
410
+ path: location.pathname + location.search,
411
+ title: (document.title || "").slice(0, 120),
412
+ /** 화면을 덮은 창이 떠 있다 — 위치는 그 창 기준이다 */
413
+ cover: fixedCovers.length > 0,
414
+ /** 창이 떠 있다(굴러가는 칸이 없어도) — 위치 기준이 페이지에서 바뀐다 */
415
+ panelOpen: fixedCovers.length > 0 || dimOpen,
416
+ /** 굴리는 것이 페이지가 아니라 떠 있는 창 안쪽이다 */
417
+ // 본문이 안쪽 칸에서 굴러가는 앱형 사이트도 페이지가 잠겨 보인다 — 창이 실제로 떠 있을 때만
418
+ inPanel: !!pane && (fixedCovers.length > 0 || cand.some((c) => c.behind)),
419
+ scrollY: Math.round(roll.y),
420
+ height: roll.height,
421
+ viewport: roll.viewport,
422
+ };
423
+ },
424
+ { ownSel: OWN_SEL, max },
425
+ );
426
+ }
427
+
428
+ /**
429
+ * 지금 화면(뷰포트)에 보이는 글자만. 페이지 전체 innerText 를 주면 내려 보지 않고도 아래 내용을 안다.
430
+ * 글자 조각마다 자리를 보고, 줄이 바뀌면 줄을 바꾼다.
431
+ */
432
+ async function visibleText(page, max = 2500) {
433
+ const t = await page
434
+ .evaluate((ownSel) => {
435
+ const VW = (window.visualViewport && visualViewport.width) || document.documentElement.clientWidth || innerWidth;
436
+ const out = [];
437
+ let lastTop = null;
438
+ // listControls 의 clippedX 와 같다 — 가로로 잘린 틀 밖의 글자는 안 보인다
439
+ const clipCache = new Map();
440
+ const clippedX = (el, r) => {
441
+ for (let p = el.parentElement, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
442
+ let ox = clipCache.get(p);
443
+ if (ox === undefined) clipCache.set(p, (ox = getComputedStyle(p).overflowX));
444
+ if (ox === "visible") continue;
445
+ const pr = p.getBoundingClientRect();
446
+ if (r.right <= pr.left + 1 || r.left >= pr.right - 1) return true;
447
+ }
448
+ return false;
449
+ };
450
+ /**
451
+ * listControls 의 buried 와 같다 — 화면을 통째로 덮은 불투명한 층(모바일 전체 화면 정보 창) 밑의 글자는 안 보인다.
452
+ * 남기면 창 글자와 밑의 목록이 한 덩어리로 섞여 새 페이지인지 창인지 모른다(10/21 실험실 q4)
453
+ */
454
+ // false | "under"(안 보임) | "dim"(열린 창 뒤에 흐리게 보임)
455
+ const buriedCache = new Map();
456
+ const buried = (el, r) => {
457
+ if (buriedCache.has(el)) return buriedCache.get(el);
458
+ let res = false;
459
+ const x = Math.min(Math.max((r.left + r.right) / 2, 1), VW - 1);
460
+ const y = Math.min(Math.max((r.top + r.bottom) / 2, 1), innerHeight - 1);
461
+ outer: for (const h of document.elementsFromPoint(x, y)) {
462
+ if (h === el || el.contains(h) || h.contains(el)) break;
463
+ if (h.closest(ownSel)) continue;
464
+ for (let n = h; n && n !== document.body && !n.contains(el); n = n.parentElement) {
465
+ const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
466
+ const alpha = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
467
+ if (!alpha) continue;
468
+ const lr = n.getBoundingClientRect();
469
+ 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) {
470
+ res = alpha >= 0.9 ? "under" : "dim";
471
+ break outer;
472
+ }
473
+ break;
474
+ }
475
+ }
476
+ buriedCache.set(el, res);
477
+ return res;
478
+ };
479
+ const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
480
+ const range = document.createRange();
481
+ const behind = [];
482
+ let n = 0;
483
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
484
+ if (++n > 20000) break;
485
+ const txt = node.nodeValue.replace(/\s+/g, " ").trim();
486
+ if (!txt) continue;
487
+ const el = node.parentElement;
488
+ if (!el || el.closest(ownSel) || el.closest("script,style,noscript,template")) continue;
489
+ range.selectNodeContents(node);
490
+ const r = range.getBoundingClientRect();
491
+ if (r.width < 1 || r.height < 1) continue;
492
+ if (r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= VW) continue;
493
+ const st = getComputedStyle(el);
494
+ if (st.visibility === "hidden" || st.opacity === "0") continue;
495
+ if (clippedX(el, r)) continue;
496
+ const b = buried(el, r);
497
+ if (b === "under") continue;
498
+ // 열린 창 뒤에 흐리게 비치는 글은 따로 모은다 — 섞으면 창이 어디서 끝나는지 모른다(10/21 실험실 q3·r4·s1·t1)
499
+ if (b === "dim") {
500
+ behind.push(txt);
501
+ continue;
502
+ }
503
+ if (lastTop !== null && Math.abs(r.top - lastTop) > 4) out.push("\n");
504
+ else if (out.length) out.push(" ");
505
+ out.push(txt);
506
+ lastTop = r.top;
507
+ }
508
+ // 빈 입력칸의 흐린 안내 글(placeholder)도 사람 눈에 보인다 — 없으면 "Your Reply" 칸이 목록에만 있고 화면에는 없었다(10/21 실험실 G1).
509
+ // 글 사이 자리를 찾기 어려워 끝에 모은다
510
+ const boxes = [];
511
+ for (const f of document.querySelectorAll("input[placeholder],textarea[placeholder]")) {
512
+ if (f.closest(ownSel) || f.value) continue;
513
+ const r = f.getBoundingClientRect();
514
+ if (r.width < 2 || r.height < 2 || r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= VW) continue;
515
+ if (getComputedStyle(f).visibility === "hidden") continue;
516
+ if (buried(f, r) === "under") continue;
517
+ // 끝에 모으니 어디 있는 칸인지 몰랐다(10/21 실험실 H3) — 대략 자리를 붙인다
518
+ const mid = (r.top + r.bottom) / 2;
519
+ const where = mid < innerHeight / 3 ? "near the top" : mid > (innerHeight * 2) / 3 ? "near the bottom" : "in the middle";
520
+ boxes.push(`[empty box ${where}: "${f.getAttribute("placeholder").trim().slice(0, 40)}"]`);
521
+ }
522
+ if (boxes.length) out.push("\n", boxes.join(" "));
523
+ // 뒤의 흐린 글은 짧게 — 길면 글자 한도를 먹어 창 쪽 글이 단어 중간에 잘렸다(10/21 실험실 u1)
524
+ if (behind.length) {
525
+ const b = behind.join(" · ");
526
+ out.push("\n\n[Behind the open panel, dimmed:]\n", b.length > 500 ? b.slice(0, b.lastIndexOf(" ", 500)) + " …" : b);
527
+ }
528
+ return out.join("");
529
+ }, OWN_SEL)
530
+ .catch(() => "");
531
+ const s = t.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim();
532
+ if (s.length <= max) return s;
533
+ // 넘치면 낱말 사이에서 자르고 잘렸다고 적는다 — "disap" 처럼 끊기면 화면이 그렇게 생긴 줄 안다(10/21 실험실 u1)
534
+ return s.slice(0, Math.max(s.lastIndexOf(" ", max), s.lastIndexOf("\n", max), max - 40)) + " … (more text on screen not shown)";
535
+ }
536
+
537
+ /** 번호로 그 요소를 집는다. 화면이 바뀌었으면 null */
538
+ async function handleAt(page, index) {
539
+ const handle = await page.evaluateHandle((i) => (window.__ebMcpEls || [])[i] || null, index);
540
+ const el = handle.asElement();
541
+ if (!el) return null;
542
+ const ok = await el.evaluate((n) => !!n && n.isConnected).catch(() => false);
543
+ return ok ? el : null;
544
+ }
545
+
546
+ /**
547
+ * 남의 사이트에 흔적을 남기지 않는다(AUTO-EXPLORE "지켜야 할 것").
548
+ * 고객이 자기 사이트를 봐 달라는 QA 에서는 로그인·결제가 오히려 봐야 할 핵심이라 옵션으로 연다.
549
+ */
550
+ const ACCOUNT_WORDS = /sign\s?in|sign\s?up|log\s?in|login|register|회원가입|로그인/i;
551
+ /** 돈이 나가거나 지우는 것. 쓰기를 보내기로 골라도(policy.writes) 막는다 */
552
+ // 단어로 본다 — "subscribe" 를 그냥 찾으면 "Subscribed" 탭(구독한 목록 보기)까지 막혔다(10/14 실험실 p9)
553
+ const PAY_WORDS = /결제|구매|주문|checkout|\bpay\b|\bsubscribe\b|삭제|\bdelete\b/i;
554
+
555
+ /**
556
+ * 무엇을 열어 줘도 누르지 않는 것. 로그인 세션을 여럿이 같이 쓰므로(PLAN.md "로그인")
557
+ * 한 명이 로그아웃하면 뒤 사람들이 다 끊기고, 탈퇴·비번 변경은 고객의 데모 계정을 망가뜨린다.
558
+ */
559
+ const NEVER_WORDS = /log\s?out|logout|sign\s?out|로그아웃|탈퇴|delete\s+account|계정\s?삭제|비밀번호\s?변경|change\s+password|reset\s+password/i;
560
+
561
+ /**
562
+ * @param policy 고객이 정한 금지 구역(policy.js). 사이트마다 다른 것은 여기서 온다 —
563
+ * "예약하기" 처럼 기본 목록에 없는 버튼(2026-09-21 여기어때)
564
+ */
565
+ function clickBlocked(item, { allowAccounts = false, policy = null } = {}) {
566
+ // 같은 이름을 가르려고 붙인 "(near: …)"·"(2 of 3)" 은 떼고 본다 — 붙이면 40 자를 넘어 "Sign out" 같은 걸 못 막을 수 있다
567
+ item = { ...item, label: String(item.label || "").replace(/ \((near: .*|\d+ of \d+)\)$/, "") };
568
+ if (policy) {
569
+ if (policy.labelBlocked(item.label)) return `blocked by policy ("${item.label}")`;
570
+ if (item.href) {
571
+ try {
572
+ const why = policy.pathReason(new URL(item.href, "http://x").pathname);
573
+ if (why) return `${why} — ${item.href}`;
574
+ } catch (e) {}
575
+ }
576
+ }
577
+ if ((item.label && item.label.length <= 40 && NEVER_WORDS.test(item.label)) || (item.href && NEVER_WORDS.test(item.href))) {
578
+ return `would sign out or damage the account ("${item.label || item.href}")`;
579
+ }
580
+ const short = item.label && item.label.length <= 40;
581
+ if ((short && PAY_WORDS.test(item.label)) || (item.href && PAY_WORDS.test(item.href))) {
582
+ return `looks like a payment or delete action ("${item.label || item.href}")`;
583
+ }
584
+ // 정책이 있으면(MCP 방문은 늘 있다) 로그인 화면을 보러 가는 것은 막지 않는다 — 사람도 무엇을 받는지 보러 간다.
585
+ // 실제로 로그인·가입하는 건 이미 막혀 있다: 이메일·비밀번호 칸은 못 치고(policy fields), 보내는 요청은 끊는다
586
+ if (allowAccounts || policy) return null;
587
+ if (short && ACCOUNT_WORDS.test(item.label)) return `looks like an account action ("${item.label}")`;
588
+ if (item.href && ACCOUNT_WORDS.test(item.href)) return `account link (${item.href})`;
589
+ return null;
590
+ }
591
+
592
+ /**
593
+ * 이름 없는 아이콘의 그림. 사람은 모양(클립·종이비행기·X)으로 아는데 방문자는 "(icon 190,785)" 만 받아
594
+ * 채팅 보내기 대신 사진 첨부를 눌렀다(10/21 bluetags 2b72). 화면 안의 이름 없는 아이콘만 잘라 번호를 달아 한 장으로 붙인다.
595
+ * 없으면 null. 붙이는 판은 방문과 따로 연 빈 창이라 사이트에 아무것도 안 보낸다
596
+ *
597
+ * @param page 방문 중인 페이지(listControls 를 막 부른 뒤 — 번호가 그 목록 기준이다)
598
+ * @param items listControls().items
599
+ * @param browser 붙이는 판을 열 브라우저
600
+ */
601
+ async function iconPicture(page, items, browser, max = 16) {
602
+ // 숫자뿐인 것(하트·말풍선 옆의 좋아요·답글 수)도 — 글자가 "0" 이라 이름이 있는 줄 알았는데 무엇의 수인지 몰랐다(10/21 실험실 i4)
603
+ const icons = items
604
+ .filter((it) => it.where === "in view" && (/^\(icon[ ]/.test(it.label) || /^\d{1,5}( \(|$)/.test(it.label)))
605
+ // 입력칸 곁(보내기·첨부)을 먼저 — 아이콘이 많은 화면에서 16 개 안에 들게
606
+ .sort((a, b) => (b.nearField ? 1 : 0) - (a.nearField ? 1 : 0))
607
+ .slice(0, max);
608
+ if (!icons.length || !browser) return null;
609
+ const vp = page.viewportSize() || { width: 1280, height: 860 };
610
+ const crops = [];
611
+ for (const it of icons) {
612
+ const el = await handleAt(page, it.i);
613
+ const box = el && (await el.boundingBox().catch(() => null));
614
+ if (!box || box.width < 2 || box.height < 2) continue;
615
+ // 둘레를 조금 넣어 자른다 — 아이콘만 딱 자르면 테두리·배경이 없어 모양이 안 읽힌다
616
+ const pad = 4;
617
+ const x = Math.max(0, box.x - pad);
618
+ const y = Math.max(0, box.y - pad);
619
+ const clip = { x, y, width: Math.min(vp.width - x, box.width + pad * 2), height: Math.min(vp.height - y, box.height + pad * 2) };
620
+ if (clip.width < 2 || clip.height < 2 || clip.width > 160 || clip.height > 160) continue;
621
+ const png = await page.screenshot({ clip, animations: "disabled", timeout: 3000 }).catch(() => null);
622
+ if (png) crops.push({ i: it.i, png, w: clip.width, h: clip.height });
623
+ }
624
+ if (!crops.length) return null;
625
+ const ctx = await browser.newContext({ viewport: { width: 640, height: 200 } });
626
+ try {
627
+ const sheet = await ctx.newPage();
628
+ // 작은 아이콘은 키운다(높이 48px 쯤) — 24px 그대로는 모양이 뭉개져 읽힌다
629
+ const cells = crops
630
+ .map((c) => {
631
+ const k = Math.max(1, Math.min(4, 48 / c.h));
632
+ return `<figure><figcaption>[${c.i}]</figcaption><img src="data:image/png;base64,${c.png.toString("base64")}" width="${Math.round(c.w * k)}" height="${Math.round(c.h * k)}"></figure>`;
633
+ })
634
+ .join("");
635
+ await sheet.setContent(
636
+ `<!doctype html><html><body style="margin:0;padding:8px;background:#fff;font:bold 14px sans-serif;display:flex;flex-wrap:wrap;gap:12px;width:620px">` +
637
+ `<style>figure{margin:0;display:flex;flex-direction:column;align-items:center;gap:4px;padding:4px;border:1px solid #ddd}img{display:block}</style>${cells}</body></html>`,
638
+ );
639
+ return await sheet.screenshot({ fullPage: true, timeout: 5000 });
640
+ } catch {
641
+ return null;
642
+ } finally {
643
+ await ctx.close().catch(() => {});
644
+ }
645
+ }
646
+
647
+ module.exports = { OWN_SEL, listControls, visibleText, handleAt, clickBlocked, iconPicture };