@usercase/mcp 0.0.0-stage → 0.2.1-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.
- package/README.md +142 -2
- package/agents/install.md +26 -0
- package/agents/path.md +49 -0
- package/agents/question.md +62 -0
- package/agents/research.md +113 -0
- package/agents/usercase-visitor.md +112 -0
- package/mcp/hub.js +760 -0
- package/mcp/path.js +394 -0
- package/mcp/report.js +207 -0
- package/mcp/server.js +679 -0
- package/mcp/workspace.js +182 -0
- package/package.json +41 -4
- package/src/browser/blocked.js +180 -0
- package/src/browser/card.js +269 -0
- package/src/browser/guard.js +211 -0
- package/src/browser/hand.js +368 -0
- package/src/browser/open.js +59 -0
- package/src/browser/page.js +353 -0
- package/src/browser/policy.js +154 -0
- package/src/browser/real-chrome.js +242 -0
- package/src/browser/robots.js +73 -0
- package/src/browser/signin.js +66 -0
- package/src/persona/people.js +104 -0
- package/src/persona/persona.js +469 -0
- package/src/prompts/answer.md +50 -0
- package/src/prompts/people.md +38 -0
- package/src/prompts/step.md +36 -0
- package/src/run/answer.js +94 -0
- package/src/run/diag.js +64 -0
- package/src/run/entry.js +76 -0
- package/src/run/runner.js +205 -0
- package/src/run/visit.js +693 -0
|
@@ -0,0 +1,353 @@
|
|
|
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"],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
|
+
const nameOf = (el) =>
|
|
62
|
+
(el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || el.getAttribute("placeholder") || "")
|
|
63
|
+
.replace(/\s+/g, " ")
|
|
64
|
+
.trim()
|
|
65
|
+
.slice(0, 60);
|
|
66
|
+
|
|
67
|
+
const cand = [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const el of document.querySelectorAll(SEL)) {
|
|
70
|
+
if (el.closest(ownSel) || !vis(el)) continue;
|
|
71
|
+
const tag = el.tagName.toLowerCase();
|
|
72
|
+
const href = el.getAttribute("href") || "";
|
|
73
|
+
const label = nameOf(el) || (tag === "a" && href ? `(icon link ${href.split(/[?#]/)[0]})` : "");
|
|
74
|
+
if (!label && !["input", "select", "textarea"].includes(tag)) continue;
|
|
75
|
+
const r = el.getBoundingClientRect();
|
|
76
|
+
// 같은 곳으로 가는 링크 둘은 하나로 친다. 주소 없는 버튼은 자리로 가른다 — 같은 말 "Post" 가
|
|
77
|
+
// 댓글 칸과 답글 칸에 하나씩 있는데 뒤의 것이 빠져 답글을 못 올렸다(10/15 bluetags 100명 p95)
|
|
78
|
+
const key = tag + "|" + label + "|" + (href || `@${Math.round(r.left)},${Math.round(r.top + scrollY)}`);
|
|
79
|
+
if (seen.has(key)) continue;
|
|
80
|
+
seen.add(key);
|
|
81
|
+
const inView = r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < VW;
|
|
82
|
+
const where = inView ? "in view" : r.top >= innerHeight ? "below" : "above";
|
|
83
|
+
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
|
+
// 성능용 안전 상한. 이건 사람에게 보여줄 예산이 아니다
|
|
85
|
+
if (cand.length >= 800) break;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 태그로만 모으면 **사람 눈에 눌리는 것이 통째로 빠진다.** 요즘 사이트는 목록 제목·필터 칩·탭을
|
|
89
|
+
* onClick 달린 div 로 만든다. 블루태그스에서는 커뮤니티 제목과 달력의 Week/Month 가 그래서 안 잡혔고,
|
|
90
|
+
* 봇은 그걸 누를 수 있는 줄도 몰랐다(2026-09-21). 커서가 손가락인 것을 사람 기준으로 한 번 더 줍는다.
|
|
91
|
+
*/
|
|
92
|
+
const pointerSel = "div,span,h1,h2,h3,h4,li,p,td,label,img,svg";
|
|
93
|
+
/**
|
|
94
|
+
* 누를 수 있나: 커서가 손가락이거나, React 가 클릭을 붙여 둔 것.
|
|
95
|
+
* 커서를 안 바꾼 버튼이 있다 — 카드의 공유 아이콘이 그래서 아예 안 보였다(10/15 실험실 p18).
|
|
96
|
+
* React(Next.js 포함)는 요소에 __reactProps 를 남긴다. 다른 틀에서는 커서만 본다
|
|
97
|
+
*/
|
|
98
|
+
const reactClick = (n) => {
|
|
99
|
+
for (const k in n) if (k.startsWith("__reactProps") && n[k] && typeof n[k].onClick === "function") return true;
|
|
100
|
+
return false;
|
|
101
|
+
};
|
|
102
|
+
const clickable = (n) => getComputedStyle(n).cursor === "pointer" || reactClick(n);
|
|
103
|
+
/**
|
|
104
|
+
* 글자 없는 아이콘의 생김새 힌트. 사람은 아이콘 모양으로 안다 — 그림을 못 보니 이름을 빌린다:
|
|
105
|
+
* title·aria-label, 안쪽 svg 의 <title>·<use href="#icon-x">. 없으면 자리만 알린다
|
|
106
|
+
*/
|
|
107
|
+
const iconHint = (n) => {
|
|
108
|
+
const t = n.getAttribute("title") || n.getAttribute("aria-label") || n.querySelector("title")?.textContent;
|
|
109
|
+
if (t && t.trim()) return t.trim().slice(0, 30);
|
|
110
|
+
const use = n.querySelector("use");
|
|
111
|
+
const ref = use && (use.getAttribute("href") || use.getAttribute("xlink:href"));
|
|
112
|
+
if (ref) {
|
|
113
|
+
const w = ref.replace(/^.*#/, "").replace(/^(icon|ic|i)[-_]/i, "").replace(/[-_]+/g, " ").trim();
|
|
114
|
+
if (/^[a-z][a-z ]{2,24}$/i.test(w)) return w.toLowerCase();
|
|
115
|
+
}
|
|
116
|
+
// 클래스 이름은 쓰지 않는다. 아이콘의 뜻이 아니라 속한 덩어리 이름일 때가 많다 — 달력 화살표가 "calendar" 가 됐다(10/15)
|
|
117
|
+
return null;
|
|
118
|
+
};
|
|
119
|
+
// getComputedStyle 은 레이아웃을 강제로 계산시킨다. 큰 사이트에서는 요소가 수천 개라 훑는 양 자체를 막는다
|
|
120
|
+
let scanned = 0;
|
|
121
|
+
for (const el of document.querySelectorAll(pointerSel)) {
|
|
122
|
+
if (cand.length >= 800 || ++scanned > 4000) break;
|
|
123
|
+
if (el.closest(ownSel) || !vis(el)) continue;
|
|
124
|
+
if (el.closest(SEL)) continue; // 이미 위에서 잡은 조작 안이다
|
|
125
|
+
if (!clickable(el)) continue;
|
|
126
|
+
// 커서가 손가락인 겉껍데기가 통째로 잡히면 목록이 같은 말로 가득 찬다. 가장 안쪽만 남긴다.
|
|
127
|
+
// 다만 글자를 직접 가진 것은 남긴다 — 아이콘+글자 버튼("☆ Follow")에서 아이콘만 "(icon …)" 으로 남고
|
|
128
|
+
// 버튼이 빠졌다(10/14 실험실 p9: "Follow 가 글자로만 보인다"). 그 안의 아이콘은 아래에서 뺀다
|
|
129
|
+
const ownText = (n) => Array.from(n.childNodes).some((c) => c.nodeType === 3 && c.nodeValue.trim());
|
|
130
|
+
// 안쪽이 목록에 못 드는 작은 것(8px 미만 화살표 그림)이면 겉을 남긴다 — 달력 "다음 달" 버튼이 통째로 빠졌다(10/14 실험실 p29)
|
|
131
|
+
const listable = (c) => {
|
|
132
|
+
const b = c.getBoundingClientRect();
|
|
133
|
+
// 목록 후보가 될 수 있는 것만 — svg 안의 path 도 커서를 물려받는데, path 는 후보가 아니라 공유 아이콘이 통째로 빠졌다(10/15 p18)
|
|
134
|
+
return c.matches(pointerSel) && clickable(c) && c.getClientRects().length && b.width >= 8 && b.height >= 8;
|
|
135
|
+
};
|
|
136
|
+
if (!ownText(el) && Array.from(el.children).some(listable)) continue;
|
|
137
|
+
const up = el.parentElement;
|
|
138
|
+
// 글자를 직접 가진 버튼 안의 조각(아이콘, "Event 0" 의 숫자 "0")은 그 버튼의 일부다
|
|
139
|
+
if (up && ownText(up) && clickable(up)) continue;
|
|
140
|
+
const text = (el.innerText || el.getAttribute("aria-label") || el.getAttribute("alt") || "").replace(/\s+/g, " ").trim();
|
|
141
|
+
if (text.length > 60) continue; // 카드 전체를 감싼 것. 그 안의 제목이 따로 잡힌다
|
|
142
|
+
const r = el.getBoundingClientRect();
|
|
143
|
+
// 작은 크기는 글자 없는 것에만 따진다. 날짜 "1" 은 폭이 5px 이라 빠졌다(10/14 실험실 달력)
|
|
144
|
+
if (text ? r.width < 2 || r.height < 6 : r.width < 8 || r.height < 8) continue;
|
|
145
|
+
const hint = text ? null : iconHint(el);
|
|
146
|
+
const label = text || (hint ? `(icon: ${hint})` : `(icon ${Math.round(r.left)},${Math.round(r.top)})`);
|
|
147
|
+
// 같은 말인 칩이 여럿인 UI 가 흔하다(태그 목록). 자리까지 봐야 서로 다른 조작으로 남는다
|
|
148
|
+
const key = `pointer|${label}|${Math.round(r.left)},${Math.round(r.top + scrollY)}`;
|
|
149
|
+
if (seen.has(key)) continue;
|
|
150
|
+
seen.add(key);
|
|
151
|
+
const inView = r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < VW;
|
|
152
|
+
const where = inView ? "in view" : r.top >= innerHeight ? "below" : "above";
|
|
153
|
+
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
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* **문서 순서로 자르면 안 된다.** 머리말·메뉴·배너가 예산을 다 먹고 본문이 통째로 안 보인다.
|
|
158
|
+
* 링크가 많은 서비스 사이트에서는 60번째 전에 헤더와 이벤트 배너만으로 끝난다 —
|
|
159
|
+
* 그러면 봇은 사람이 보고 있는 것을 못 본 채로 다음 걸음을 고른다.
|
|
160
|
+
* 지금 화면에 보이는 것부터 남기고, 같은 순위 안에서는 문서 순서를 지킨다(sort 는 안정 정렬).
|
|
161
|
+
* 2026-09-21 여기어때에서 드러났다. 블루태그스 Week/Month 토글도 같은 결의 문제였다.
|
|
162
|
+
*/
|
|
163
|
+
cand.sort((a, b) => a.rank - b.rank);
|
|
164
|
+
/**
|
|
165
|
+
* **화면 아래는 보여주지 않는다.** 사람은 내려 봐야 거기 뭐가 있는지 안다.
|
|
166
|
+
* 이름까지 알려주면 봇이 스크롤 없이 바로 누른다 — 10/13 bluetags 105명 중 79명이 한 번도 안 굴렸다.
|
|
167
|
+
* 지나온 위쪽은 이미 본 것이라 남긴다.
|
|
168
|
+
*/
|
|
169
|
+
const below = cand.filter((c) => c.where === "below").length;
|
|
170
|
+
const shown = cand.filter((c) => c.where !== "below");
|
|
171
|
+
/**
|
|
172
|
+
* 같은 이름이 여럿이면 어느 것인지 모른다 — "Project logo image" 가 다섯 개(10/14 실험실). 사람은 로고 옆 이름으로 가른다.
|
|
173
|
+
* 겹치는 것에만 가장 가까운 덩어리의 다른 글자를 붙인다
|
|
174
|
+
*/
|
|
175
|
+
// 넘치면 이름 없는 아이콘부터 뺀다. 달력 칸마다 아이콘이 서너 개라 60개를 다 먹고 날짜·버튼이 잘렸다(10/14 실험실 p29)
|
|
176
|
+
let pool = shown;
|
|
177
|
+
let iconsLeft = 0;
|
|
178
|
+
// 화면 안의 것만으로 넘칠 때만 — 지나온 위쪽 때문에 넘친 거면 위쪽을 자르면 된다. 아이콘까지 빼면 보이는 것이 빠진다(10/15)
|
|
179
|
+
if (pool.filter((c) => c.where === "in view").length > max) {
|
|
180
|
+
// 같은 이름으로 되풀이되는 그림도 아이콘처럼 먼저 뺀다 — 카드마다의 "Project logo image" 17개가 자리를 먹어
|
|
181
|
+
// 화면 아래 카드들이 빠졌다(10/15 실험실 y38, 로그인한 데스크톱 /community). 그림 옆에 같은 카드의 이름이 따로 있다
|
|
182
|
+
const imgCount = {};
|
|
183
|
+
for (const c of pool) if (c.tag === "img") imgCount[c.label] = (imgCount[c.label] || 0) + 1;
|
|
184
|
+
const isIcon = (c) => /^\(icon \d/.test(c.label) || (c.tag === "img" && imgCount[c.label] >= 4);
|
|
185
|
+
// 화면 안의 것끼리만 센다. 위쪽 것까지 세면 자리가 있어도 아이콘을 다 뺐다
|
|
186
|
+
const inView = pool.filter((c) => c.where === "in view");
|
|
187
|
+
const named = inView.filter((c) => !isIcon(c)).length;
|
|
188
|
+
// 같은 크기로 여럿 되풀이되는 아이콘(달력 칸마다의 표시)부터 뺀다. 한두 개뿐인 것(이전·다음 화살표)은 남긴다
|
|
189
|
+
const bySize = {};
|
|
190
|
+
for (const c of inView) if (isIcon(c)) bySize[c.size] = (bySize[c.size] || 0) + 1;
|
|
191
|
+
const keep = new Set(
|
|
192
|
+
inView
|
|
193
|
+
.filter(isIcon)
|
|
194
|
+
.map((c, k) => ({ c, k, rep: bySize[c.size] }))
|
|
195
|
+
.sort((a, b) => a.rep - b.rep || a.k - b.k)
|
|
196
|
+
.slice(0, Math.max(0, max - named))
|
|
197
|
+
.map((x) => x.c),
|
|
198
|
+
);
|
|
199
|
+
pool = pool.filter((c) => c.where !== "in view" || !isIcon(c) || keep.has(c));
|
|
200
|
+
iconsLeft = shown.length - pool.length;
|
|
201
|
+
}
|
|
202
|
+
const picked = pool.slice(0, max);
|
|
203
|
+
const groups = {};
|
|
204
|
+
for (const c of picked) (groups[c.label] = groups[c.label] || []).push(c);
|
|
205
|
+
const nearAt = (c, d) => {
|
|
206
|
+
let p = c.el.parentElement;
|
|
207
|
+
for (let k = 0; k < d && p && p !== document.body; k++) p = p.parentElement;
|
|
208
|
+
return p ? (p.innerText || "").replace(/\s+/g, " ").replace(c.label, "").trim().slice(0, 40) : "";
|
|
209
|
+
};
|
|
210
|
+
for (const [label, g] of Object.entries(groups)) {
|
|
211
|
+
if (g.length < 2) continue;
|
|
212
|
+
// 묶음 안에서 서로 다르게 읽히는 가장 가까운 층을 고른다. 없으면 순서로 가른다
|
|
213
|
+
let hints = null;
|
|
214
|
+
for (let d = 0; d < 6 && !hints; d++) {
|
|
215
|
+
const h = g.map((c) => nearAt(c, d));
|
|
216
|
+
if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
|
|
217
|
+
}
|
|
218
|
+
g.forEach((c, k) => (c.label = hints ? `${label} (${hints[k]})` : `${label} (${k + 1} of ${g.length})`));
|
|
219
|
+
}
|
|
220
|
+
const els = [];
|
|
221
|
+
const out = picked.map((c, i) => {
|
|
222
|
+
els.push(c.el);
|
|
223
|
+
// 반 넘게 화면 옆으로 나간 것. 누르면 손이 "잘렸다" 고 한다 — 목록도 같은 말을 해야 한다(10/15 실험실 x87: 공유 막대가 화면을 넓혀 아래 메뉴가 밀렸다)
|
|
224
|
+
return { i, tag: c.tag, label: c.label, href: c.href, where: c.where, ...(c.dim ? { dim: true } : {}), ...(c.edge ? { edge: true } : {}) };
|
|
225
|
+
});
|
|
226
|
+
Object.defineProperty(window, "__ebMcpEls", { value: els, configurable: true, enumerable: false, writable: true });
|
|
227
|
+
return {
|
|
228
|
+
items: out,
|
|
229
|
+
/** 예산에 안 들어간 조작 수. 0 이 아니면 봇이 못 본 것이 있다는 뜻이다 */
|
|
230
|
+
dropped: Math.max(0, shown.length - out.length),
|
|
231
|
+
/**
|
|
232
|
+
* 그중 지금 화면 안의 것. 나머지는 지나온 위쪽이다 — 긴 목록을 내려가면 위쪽만으로 예산이 넘쳐
|
|
233
|
+
* "화면에 너무 많다" 고 잘못 말했다(10/15 bluetags 100명, 넘침 39번 거의 다 위쪽)
|
|
234
|
+
*/
|
|
235
|
+
droppedInView: Math.max(0, shown.filter((c) => c.where === "in view").length - picked.filter((c) => c.where === "in view").length),
|
|
236
|
+
/** 그중 이름 없는 작은 아이콘 수 */
|
|
237
|
+
droppedIcons: iconsLeft,
|
|
238
|
+
/** 화면 아래에 더 있는 조작 수. 무엇인지는 말하지 않는다 */
|
|
239
|
+
below,
|
|
240
|
+
path: location.pathname + location.search,
|
|
241
|
+
title: (document.title || "").slice(0, 120),
|
|
242
|
+
scrollY: Math.round(scrollY),
|
|
243
|
+
height: document.documentElement.scrollHeight,
|
|
244
|
+
viewport: innerHeight,
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
{ ownSel: OWN_SEL, max },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 지금 화면(뷰포트)에 보이는 글자만. 페이지 전체 innerText 를 주면 내려 보지 않고도 아래 내용을 안다.
|
|
253
|
+
* 글자 조각마다 자리를 보고, 줄이 바뀌면 줄을 바꾼다.
|
|
254
|
+
*/
|
|
255
|
+
async function visibleText(page, max = 2500) {
|
|
256
|
+
const t = await page
|
|
257
|
+
.evaluate((ownSel) => {
|
|
258
|
+
const VW = (window.visualViewport && visualViewport.width) || document.documentElement.clientWidth || innerWidth;
|
|
259
|
+
const out = [];
|
|
260
|
+
let lastTop = null;
|
|
261
|
+
// listControls 의 clippedX 와 같다 — 가로로 잘린 틀 밖의 글자는 안 보인다
|
|
262
|
+
const clipCache = new Map();
|
|
263
|
+
const clippedX = (el, r) => {
|
|
264
|
+
for (let p = el.parentElement, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
|
|
265
|
+
let ox = clipCache.get(p);
|
|
266
|
+
if (ox === undefined) clipCache.set(p, (ox = getComputedStyle(p).overflowX));
|
|
267
|
+
if (ox === "visible") continue;
|
|
268
|
+
const pr = p.getBoundingClientRect();
|
|
269
|
+
if (r.right <= pr.left + 1 || r.left >= pr.right - 1) return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
};
|
|
273
|
+
const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
|
|
274
|
+
const range = document.createRange();
|
|
275
|
+
let n = 0;
|
|
276
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
277
|
+
if (++n > 20000) break;
|
|
278
|
+
const txt = node.nodeValue.replace(/\s+/g, " ").trim();
|
|
279
|
+
if (!txt) continue;
|
|
280
|
+
const el = node.parentElement;
|
|
281
|
+
if (!el || el.closest(ownSel) || el.closest("script,style,noscript,template")) continue;
|
|
282
|
+
range.selectNodeContents(node);
|
|
283
|
+
const r = range.getBoundingClientRect();
|
|
284
|
+
if (r.width < 1 || r.height < 1) continue;
|
|
285
|
+
if (r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= VW) continue;
|
|
286
|
+
const st = getComputedStyle(el);
|
|
287
|
+
if (st.visibility === "hidden" || st.opacity === "0") continue;
|
|
288
|
+
if (clippedX(el, r)) continue;
|
|
289
|
+
if (lastTop !== null && Math.abs(r.top - lastTop) > 4) out.push("\n");
|
|
290
|
+
else if (out.length) out.push(" ");
|
|
291
|
+
out.push(txt);
|
|
292
|
+
lastTop = r.top;
|
|
293
|
+
}
|
|
294
|
+
return out.join("");
|
|
295
|
+
}, OWN_SEL)
|
|
296
|
+
.catch(() => "");
|
|
297
|
+
return t.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim().slice(0, max);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 번호로 그 요소를 집는다. 화면이 바뀌었으면 null */
|
|
301
|
+
async function handleAt(page, index) {
|
|
302
|
+
const handle = await page.evaluateHandle((i) => (window.__ebMcpEls || [])[i] || null, index);
|
|
303
|
+
const el = handle.asElement();
|
|
304
|
+
if (!el) return null;
|
|
305
|
+
const ok = await el.evaluate((n) => !!n && n.isConnected).catch(() => false);
|
|
306
|
+
return ok ? el : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 남의 사이트에 흔적을 남기지 않는다(AUTO-EXPLORE "지켜야 할 것").
|
|
311
|
+
* 고객이 자기 사이트를 봐 달라는 QA 에서는 로그인·결제가 오히려 봐야 할 핵심이라 옵션으로 연다.
|
|
312
|
+
*/
|
|
313
|
+
const ACCOUNT_WORDS = /sign\s?in|sign\s?up|log\s?in|login|register|회원가입|로그인/i;
|
|
314
|
+
/** 돈이 나가거나 지우는 것. 쓰기를 보내기로 골라도(policy.writes) 막는다 */
|
|
315
|
+
// 단어로 본다 — "subscribe" 를 그냥 찾으면 "Subscribed" 탭(구독한 목록 보기)까지 막혔다(10/14 실험실 p9)
|
|
316
|
+
const PAY_WORDS = /결제|구매|주문|checkout|\bpay\b|\bsubscribe\b|삭제|\bdelete\b/i;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 무엇을 열어 줘도 누르지 않는 것. 로그인 세션을 여럿이 같이 쓰므로(PLAN.md "로그인")
|
|
320
|
+
* 한 명이 로그아웃하면 뒤 사람들이 다 끊기고, 탈퇴·비번 변경은 고객의 데모 계정을 망가뜨린다.
|
|
321
|
+
*/
|
|
322
|
+
const NEVER_WORDS = /log\s?out|logout|sign\s?out|로그아웃|탈퇴|delete\s+account|계정\s?삭제|비밀번호\s?변경|change\s+password|reset\s+password/i;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param policy 고객이 정한 금지 구역(policy.js). 사이트마다 다른 것은 여기서 온다 —
|
|
326
|
+
* "예약하기" 처럼 기본 목록에 없는 버튼(2026-09-21 여기어때)
|
|
327
|
+
*/
|
|
328
|
+
function clickBlocked(item, { allowAccounts = false, policy = null } = {}) {
|
|
329
|
+
if (policy) {
|
|
330
|
+
if (policy.labelBlocked(item.label)) return `blocked by policy ("${item.label}")`;
|
|
331
|
+
if (item.href) {
|
|
332
|
+
try {
|
|
333
|
+
const why = policy.pathReason(new URL(item.href, "http://x").pathname);
|
|
334
|
+
if (why) return `${why} — ${item.href}`;
|
|
335
|
+
} catch (e) {}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if ((item.label && item.label.length <= 40 && NEVER_WORDS.test(item.label)) || (item.href && NEVER_WORDS.test(item.href))) {
|
|
339
|
+
return `would sign out or damage the account ("${item.label || item.href}")`;
|
|
340
|
+
}
|
|
341
|
+
const short = item.label && item.label.length <= 40;
|
|
342
|
+
if ((short && PAY_WORDS.test(item.label)) || (item.href && PAY_WORDS.test(item.href))) {
|
|
343
|
+
return `looks like a payment or delete action ("${item.label || item.href}")`;
|
|
344
|
+
}
|
|
345
|
+
// 정책이 있으면(MCP 방문은 늘 있다) 로그인 화면을 보러 가는 것은 막지 않는다 — 사람도 무엇을 받는지 보러 간다.
|
|
346
|
+
// 실제로 로그인·가입하는 건 이미 막혀 있다: 이메일·비밀번호 칸은 못 치고(policy fields), 보내는 요청은 끊는다
|
|
347
|
+
if (allowAccounts || policy) return null;
|
|
348
|
+
if (short && ACCOUNT_WORDS.test(item.label)) return `looks like an account action ("${item.label}")`;
|
|
349
|
+
if (item.href && ACCOUNT_WORDS.test(item.href)) return `account link (${item.href})`;
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
module.exports = { OWN_SEL, listControls, visibleText, handleAt, clickBlocked };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 무엇을 하지 않게 할지. **코드가 강제하는 선이다.**
|
|
4
|
+
*
|
|
5
|
+
* 기본값: 결제·삭제·로그아웃 같은 것은 누르지 않고, 사이트에 쓰는 요청(GET 아닌 것)은 보내지 않는다.
|
|
6
|
+
* 고객이 자기 사이트를 봐 달라고 할 때는 그중 일부가 오히려 봐야 할 핵심이라(로그인 뒤 화면, 폼 제출)
|
|
7
|
+
* 그 선을 고객이 정한다. 환경변수 `USERCASE_POLICY` 에 아래 모양의 JSON 파일 경로를 준다.
|
|
8
|
+
*
|
|
9
|
+
* 사이트마다 다른 금지 구역이 여기에 들어간다. 2026-09-21 여기어때에서 기본 목록에 "예약하기" 가 없어
|
|
10
|
+
* 가상 사용자가 실제 예약을 시도할 뻔했고, 정책 파일로 막았다.
|
|
11
|
+
*
|
|
12
|
+
* 파일 모습 (전부 선택):
|
|
13
|
+
* {
|
|
14
|
+
* "blockPaths": ["^/admin"], // 기본에 더한다
|
|
15
|
+
* "blockLabels": "지금 시작|무료 체험", // 기본에 더한다(정규식 한 줄)
|
|
16
|
+
* "blockFields": "사업자번호",
|
|
17
|
+
* "allowPaths": ["^/signin", "^/checkout"], // 기본에서 뺀다. 막는 것보다 세다
|
|
18
|
+
* "allowLabels": "log ?in|sign ?in|submit",
|
|
19
|
+
* "allowFields": "e-?mail|user ?name",
|
|
20
|
+
* "allowNonGet": true, // 사이트로 가는 POST·PUT 을 연다(폼 제출 테스트)
|
|
21
|
+
* "allowNonGetPaths": ["^/cdn-cgi/"], // 그 경로로 가는 것만 연다. allowNonGet 과 따로 논다
|
|
22
|
+
* "replace": false // true 면 기본값을 버리고 준 것만 쓴다
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* `allowNonGet` 은 **사람이 하는 폼 제출**을 여는 스위치다. 그런데 이 값 하나로 네트워크 단까지
|
|
26
|
+
* 같이 막혀서, 아무 흔적도 안 남는 배경 비콘(봇 판별·사용량 측정)까지 죽는다.
|
|
27
|
+
* 여기어때에서 Cloudflare 봇 판별 비콘(`/cdn-cgi/challenge-platform/...`)이 막혀
|
|
28
|
+
* **검사가 미완으로 남는 것**을 보고 `allowNonGetPaths` 를 나눴다(2026-09-21).
|
|
29
|
+
* 이건 네트워크 요청만 연다 — 폼 제출·textarea 입력은 여전히 `allowNonGet` 이 쥐고 있다.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require("fs");
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 사이트에 쓰는 것을 보낼지(`writes`). 사이트 주인이 고른다 — 그 데이터가 진짜인지는 주인만 안다(10/14).
|
|
36
|
+
* - "block" (기본): 가입·글쓰기·저장·좋아요를 눌러도 사이트로 보내지 않는다(네트워크 단에서 끊고 방문자에게 알린다)
|
|
37
|
+
* - "send": 그것들을 연다. 시험 서버나 망가져도 되는 데이터일 때
|
|
38
|
+
* 어느 쪽이든 아래 ALWAYS 는 막는다 — 돈이 나가거나 되돌릴 수 없는 것
|
|
39
|
+
*/
|
|
40
|
+
const WRITES = ["block", "send"];
|
|
41
|
+
|
|
42
|
+
// 언제나 막는 것. 결제·구독·지갑·삭제, 관리자·API 화면
|
|
43
|
+
const ALWAYS = {
|
|
44
|
+
paths: ["^/api/", "^/admin", "checkout|payment|billing|subscribe"],
|
|
45
|
+
labels:
|
|
46
|
+
"\\b(log ?out|sign ?out|subscribe|checkout|check out|buy|purchase|pay|payment|connect wallet|metamask|delete)\\b|로그아웃|결제|구매|주문|지갑|구독",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 쓰기를 안 보낼 때도 **누르는 것은 막지 않는다.** 쓰기는 네트워크 단에서 끊고(GET 아닌 요청),
|
|
51
|
+
* 방문자에게 "보내지 않았다" 고 알린다(hub notSent). 전에는 글자·주소로 누르기부터 막았는데
|
|
52
|
+
* (watchlist·like·post·save·follow·join·/user·/signin …) 그러면 보기만 하는 화면까지 못 갔고,
|
|
53
|
+
* "Feels like …" 같은 카드 제목도 막혔다(10/14 실험실 — 관심목록 아이콘이 "정책상 막힘").
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULTS = {
|
|
56
|
+
paths: [...ALWAYS.paths],
|
|
57
|
+
labels: ALWAYS.labels,
|
|
58
|
+
// 개인정보 칸은 보내기를 골라도 막는다 — 지어낸 사람의 이메일·전화를 진짜 서버에 넣지 않는다
|
|
59
|
+
fields:
|
|
60
|
+
"password|passwd|e-?mail|phone|\\btel\\b|card ?number|cvc|cvv|address|user ?name|full ?name|first ?name|last ?name|nickname|주소|이메일|비밀번호|전화|카드 ?번호|이름|닉네임",
|
|
61
|
+
allowNonGet: false,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const rx = (s, flags) => {
|
|
65
|
+
if (!s) return null;
|
|
66
|
+
try {
|
|
67
|
+
return new RegExp(s, flags);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
throw new Error(`Invalid regular expression: ${s} — ${e.message}`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function build(given, { writes = "block" } = {}) {
|
|
74
|
+
const g = given || {};
|
|
75
|
+
const send = writes === "send";
|
|
76
|
+
const base = g.replace
|
|
77
|
+
? { paths: [], labels: "", fields: "", allowNonGet: false }
|
|
78
|
+
: send
|
|
79
|
+
? { ...DEFAULTS, allowNonGet: true }
|
|
80
|
+
: DEFAULTS;
|
|
81
|
+
|
|
82
|
+
const paths = [...base.paths, ...(Array.isArray(g.blockPaths) ? g.blockPaths : [])];
|
|
83
|
+
const labels = [base.labels, g.blockLabels].filter(Boolean).join("|");
|
|
84
|
+
const fields = [base.fields, g.blockFields].filter(Boolean).join("|");
|
|
85
|
+
|
|
86
|
+
const allowPath = Array.isArray(g.allowPaths) && g.allowPaths.length ? rx(g.allowPaths.join("|"), "i") : null;
|
|
87
|
+
const allowNonGetPath =
|
|
88
|
+
Array.isArray(g.allowNonGetPaths) && g.allowNonGetPaths.length ? rx(g.allowNonGetPaths.join("|"), "i") : null;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
// 막는 것
|
|
92
|
+
blockPaths: paths.map((p) => rx(p, "i")),
|
|
93
|
+
blockLabel: rx(labels, "i"),
|
|
94
|
+
blockField: rx(fields, "i"),
|
|
95
|
+
// 여는 것. 막는 것보다 세다
|
|
96
|
+
allowPath,
|
|
97
|
+
allowLabel: rx(g.allowLabels, "i"),
|
|
98
|
+
allowField: rx(g.allowFields, "i"),
|
|
99
|
+
allowNonGet: !!g.allowNonGet || base.allowNonGet,
|
|
100
|
+
/** 사이트에 쓰는 것을 보내나 — "block" | "send" */
|
|
101
|
+
writes: send ? "send" : "block",
|
|
102
|
+
allowNonGetPath,
|
|
103
|
+
// 사람이 읽을 것
|
|
104
|
+
source: g.__source || "default (browsing someone else's site)",
|
|
105
|
+
raw: { ...g },
|
|
106
|
+
|
|
107
|
+
/** 이 경로로 가도 되나. 막으면 이유를 돌려준다 */
|
|
108
|
+
pathReason(pathname) {
|
|
109
|
+
if (this.allowPath && this.allowPath.test(pathname)) return null;
|
|
110
|
+
const hit = this.blockPaths.find((r) => r.test(pathname));
|
|
111
|
+
return hit ? `blocked path (${hit.source})` : null;
|
|
112
|
+
},
|
|
113
|
+
/** 이 글자를 눌러도 되나 */
|
|
114
|
+
labelBlocked(text) {
|
|
115
|
+
if (!text) return false;
|
|
116
|
+
if (this.allowLabel && this.allowLabel.test(text)) return false;
|
|
117
|
+
return !!(this.blockLabel && this.blockLabel.test(text));
|
|
118
|
+
},
|
|
119
|
+
/**
|
|
120
|
+
* 이 경로로 가는 GET 아닌 요청을 보내도 되나. **네트워크 단에서만 쓴다.**
|
|
121
|
+
* 사람이 누르는 폼 제출은 이걸 안 본다 — 그건 allowNonGet 이 쥔다.
|
|
122
|
+
*/
|
|
123
|
+
nonGetAllowed(pathname) {
|
|
124
|
+
if (this.allowNonGet) return true;
|
|
125
|
+
return !!(this.allowNonGetPath && this.allowNonGetPath.test(pathname));
|
|
126
|
+
},
|
|
127
|
+
/** 이 칸에 쳐도 되나 */
|
|
128
|
+
fieldBlocked(text) {
|
|
129
|
+
if (!text) return false;
|
|
130
|
+
if (this.allowField && this.allowField.test(text)) return false;
|
|
131
|
+
return !!(this.blockField && this.blockField.test(text));
|
|
132
|
+
},
|
|
133
|
+
/** 사람이 읽을 요약 */
|
|
134
|
+
describe() {
|
|
135
|
+
const L = [`Policy: ${this.source}`, ` writes: ${this.writes === "send" ? "sent to the site (sign-ups, posts, saves are real)" : "not sent to the site"}`];
|
|
136
|
+
L.push(` blocked paths: ${this.blockPaths.length}`);
|
|
137
|
+
if (this.allowPath) L.push(` allowed paths: ${this.allowPath.source}`);
|
|
138
|
+
if (this.allowLabel) L.push(` allowed labels: ${this.allowLabel.source}`);
|
|
139
|
+
if (this.allowField) L.push(` allowed fields: ${this.allowField.source}`);
|
|
140
|
+
L.push(` non-GET requests to the site: ${this.allowNonGet ? "allowed (forms submit)" : "blocked"}`);
|
|
141
|
+
if (!this.allowNonGet && this.allowNonGetPath) L.push(` except these paths (beacons): ${this.allowNonGetPath.source}`);
|
|
142
|
+
return L.join("\n");
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function load(file, opts = {}) {
|
|
148
|
+
if (!file || file === "none") return build(null, opts);
|
|
149
|
+
const j = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
150
|
+
j.__source = file;
|
|
151
|
+
return build(j, opts);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { build, load, DEFAULTS, WRITES };
|