@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.
- package/README.md +153 -153
- package/mcp/hub.js +809 -809
- package/package.json +1 -1
- package/src/browser/hand.js +592 -592
- package/src/browser/page.js +647 -647
- package/src/run/diag.js +67 -67
- package/src/run/visit.js +1124 -1124
package/src/browser/hand.js
CHANGED
|
@@ -1,592 +1,592 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* 손 — 마우스가 어떻게 가고, 어디를 겨냥하고, 어떻게 치는가.
|
|
4
|
-
*
|
|
5
|
-
* 정확히 한가운데를 누르고 오타 없이 치면 사람 기록이 아니다. 헛클릭도, 지웠다 다시 치는 것도
|
|
6
|
-
* 사이트가 보는 신호의 일부다. 어디까지 흔들리는지는 그 사람 성향에서 나온다(persona.js Rhythm).
|
|
7
|
-
*
|
|
8
|
-
* 빗나가는 것은 허용하되 **엉뚱한 버튼을 눌러 버리지는 않는다.** 그러면 기록이 거짓이 된다 —
|
|
9
|
-
* 누르기 직전에 알아채고 대상 안으로 끌어오는 쪽이 사람에 가깝다.
|
|
10
|
-
*
|
|
11
|
-
* 의존성 없음. Playwright page 와 Rhythm 을 받는다.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const OWN_SEL = "#eb-insight,#eb-plugin";
|
|
15
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
-
const rand = (a, b) => a + Math.random() * (b - a);
|
|
17
|
-
|
|
18
|
-
/** 커서를 그 자리까지. 직선으로 가지 않고 살짝 휘며, 가끔 지나쳤다 되돌아온다 */
|
|
19
|
-
async function moveTo(page, state, x, y) {
|
|
20
|
-
const vp = page.viewportSize() || { width: 1200, height: 800 };
|
|
21
|
-
const from = state.mouse || { x: vp.width / 2, y: vp.height * 0.4 };
|
|
22
|
-
const h = state.rhythm ? state.rhythm.hand() : { stride: 110, overshoot: 0.2 };
|
|
23
|
-
const dx = x - from.x;
|
|
24
|
-
const dy = y - from.y;
|
|
25
|
-
const dist = Math.hypot(dx, dy);
|
|
26
|
-
if (dist < 2) {
|
|
27
|
-
state.mouse = { x, y };
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
const steps = Math.max(2, Math.min(28, Math.round(dist / h.stride) + 2));
|
|
31
|
-
const bow = Math.min(60, dist * 0.12) * (Math.random() < 0.5 ? -1 : 1);
|
|
32
|
-
await page.mouse.move(from.x + dx * 0.55 - (dy / dist) * bow, from.y + dy * 0.55 + (dx / dist) * bow, {
|
|
33
|
-
steps: Math.max(2, Math.round(steps * 0.6)),
|
|
34
|
-
});
|
|
35
|
-
if (dist > 80 && Math.random() < h.overshoot) {
|
|
36
|
-
const over = 6 + Math.random() * Math.min(40, dist * 0.08);
|
|
37
|
-
await page.mouse.move(x + (dx / dist) * over, y + (dy / dist) * over, { steps: Math.max(2, Math.round(steps * 0.4)) });
|
|
38
|
-
await sleep(40 + Math.random() * 90);
|
|
39
|
-
}
|
|
40
|
-
await page.mouse.move(x, y, { steps: Math.max(2, Math.round(steps * 0.4)) });
|
|
41
|
-
state.mouse = { x, y };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** 어디를 겨냥하나. 한가운데가 아니라 그 사람 정확도만큼 흩어진 점. null 이면 좌표를 포기한다 */
|
|
45
|
-
async function aimAt(page, state, el) {
|
|
46
|
-
const box = await el.boundingBox().catch(() => null);
|
|
47
|
-
if (!box) return null;
|
|
48
|
-
const vp = page.viewportSize() || { width: 1200, height: 800 };
|
|
49
|
-
const cx = box.x + box.width / 2;
|
|
50
|
-
const cy = box.y + box.height / 2;
|
|
51
|
-
const o = state.rhythm ? state.rhythm.aimErrorPx() : { dx: 0, dy: 0 };
|
|
52
|
-
const x = cx + o.dx;
|
|
53
|
-
const y = cy + o.dy;
|
|
54
|
-
const outDist = Math.hypot(Math.max(box.x - x, 0, x - (box.x + box.width)), Math.max(box.y - y, 0, y - (box.y + box.height)));
|
|
55
|
-
const onScreen = (px, py) => px > 1 && py > 1 && px < vp.width - 1 && py < vp.height - 1;
|
|
56
|
-
|
|
57
|
-
const at = async (px, py) => {
|
|
58
|
-
if (!onScreen(px, py)) return "밖";
|
|
59
|
-
return el
|
|
60
|
-
.evaluate((node, [qx, qy, ownSel]) => {
|
|
61
|
-
const hit = document.elementFromPoint(qx, qy);
|
|
62
|
-
if (!hit) return "빈 곳";
|
|
63
|
-
if (node === hit || node.contains(hit)) return "대상";
|
|
64
|
-
if (hit.contains(node)) {
|
|
65
|
-
// 감싼 상자를 누른 것이다. 그 상자가 스스로 눌리는 것(링크·버튼·클릭이 붙은 것)일 때만 같은 조작이다.
|
|
66
|
-
// 크기만 보고 "대상" 으로 쳤더니 달력 Month 아래 여백을 눌러 놓고 눌린 줄 알았다 — 클릭은 안쪽 버튼에만 붙어 있었다(10/15 실험실 p14)
|
|
67
|
-
let selfClick = !!hit.closest('a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],summary,label');
|
|
68
|
-
for (const k in hit) if (k.startsWith("__reactProps") && hit[k] && typeof hit[k].onClick === "function") selfClick = true;
|
|
69
|
-
if (!selfClick) return "빈 곳";
|
|
70
|
-
const a = node.getBoundingClientRect();
|
|
71
|
-
const b = hit.getBoundingClientRect();
|
|
72
|
-
return b.width * b.height <= a.width * a.height * 3 ? "대상" : "빈 곳";
|
|
73
|
-
}
|
|
74
|
-
if (ownSel && hit.closest(ownSel)) return "가림";
|
|
75
|
-
return hit.closest('a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],summary,input,select,textarea,label')
|
|
76
|
-
? "다른 조작"
|
|
77
|
-
: "빈 곳";
|
|
78
|
-
}, [px, py, OWN_SEL])
|
|
79
|
-
.catch(() => "가림");
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
const first = await at(x, y);
|
|
83
|
-
if (first === "대상") return { x, y, missed: false };
|
|
84
|
-
// 대상 밖 빈 곳 — 사람이 하는 헛클릭. 다만 아주 가까운 빗나감은 브라우저가 그 요소로 보내므로
|
|
85
|
-
// "빗나갔다" 고 적고 실제로는 눌리는 거짓 기록이 된다. 그럴 땐 테두리 안으로 끌어온다.
|
|
86
|
-
// 헛클릭은 빈 곳에 떨어질 때만 — 옆 버튼에 떨어지면 그 버튼이 눌려 "Join 이 관심목록으로 보낸다" 같은 가짜 결함이 된다.
|
|
87
|
-
// 끌어온 점도 대상인지 본다 — 아래 고정 메뉴에 깔린 버튼은 끌어온 점이 메뉴라 Watchlist 가 눌렸다(10/15 실험실 y57)
|
|
88
|
-
if (outDist > 0) {
|
|
89
|
-
const margin = state.rhythm ? state.rhythm.missMarginPx() : 6;
|
|
90
|
-
if (outDist >= margin && first === "빈 곳") return { x, y, missed: true };
|
|
91
|
-
const px = Math.min(Math.max(x, box.x + 2), box.x + box.width - 2);
|
|
92
|
-
const py = Math.min(Math.max(y, box.y + 2), box.y + box.height - 2);
|
|
93
|
-
if ((await at(px, py)) === "대상") return { x: px, y: py, missed: false };
|
|
94
|
-
}
|
|
95
|
-
if ((await at(cx, cy)) === "대상") return { x: cx, y: cy, missed: false };
|
|
96
|
-
// 가운데만 가렸으면 사람은 보이는 쪽을 누른다 — 화면에 붙은 창의 Join 버튼 아래쪽이 아래 메뉴에 깔려
|
|
97
|
-
// 굴려도 안 비켜서 "Watchlist 가 덮었다" 가 됐다(10/21 실험실 q2). 가운데에서 가까운 점부터 본다
|
|
98
|
-
const pts = [];
|
|
99
|
-
for (const fy of [0.2, 0.35, 0.5, 0.65, 0.8]) for (const fx of [0.5, 0.3, 0.7, 0.15, 0.85]) pts.push([box.x + box.width * fx, box.y + box.height * fy]);
|
|
100
|
-
pts.sort((a, b) => Math.hypot(a[0] - cx, a[1] - cy) - Math.hypot(b[0] - cx, b[1] - cy));
|
|
101
|
-
for (const [px, py] of pts) if ((await at(px, py)) === "대상") return { x: px, y: py, missed: false };
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* 그 요소 한가운데를 다른 것이 덮고 있나. 아니면 null.
|
|
107
|
-
* { say } 는 눈에 보이는 것(팝업 등)이 덮은 것 — 방문자에게 할 말.
|
|
108
|
-
* { invisible, x, y } 는 글자 없는 막 — 사람 눈에는 안 보이니 사람은 그냥 누르고, 클릭은 그 막에 닿는다
|
|
109
|
-
*/
|
|
110
|
-
async function coveredBy(page, el) {
|
|
111
|
-
const box = await el.boundingBox().catch(() => null);
|
|
112
|
-
const vp = page.viewportSize() || { width: 1200, height: 800 };
|
|
113
|
-
if (!box) return null;
|
|
114
|
-
const cx = box.x + box.width / 2;
|
|
115
|
-
const cy = box.y + box.height / 2;
|
|
116
|
-
if (cx < 1 || cy < 1 || cx > vp.width - 1 || cy > vp.height - 1) return null;
|
|
117
|
-
const hit = await el
|
|
118
|
-
.evaluate(
|
|
119
|
-
(node, [qx, qy, ownSel]) => {
|
|
120
|
-
const h = document.elementFromPoint(qx, qy);
|
|
121
|
-
if (!h || node === h || node.contains(h) || h.contains(node)) return null;
|
|
122
|
-
if (ownSel && h.closest(ownSel)) return { own: true };
|
|
123
|
-
// 덮은 덩어리 — 대화상자·팝업이면 그 전체, 아니면 맞은 요소
|
|
124
|
-
// 덮은 것이 링크·버튼 안이면 눈에 보이는 조작이다. 그 안의 그림(아이콘)만 맞으면 글자가 없어 "투명한 막" 으로 보고
|
|
125
|
-
// 그냥 눌렀다 — 아래 고정 메뉴의 Watchlist 아이콘이 눌려 관심목록으로 갔다(10/15 실험실 y57, 정보 창의 Join)
|
|
126
|
-
const ctl = h.closest('a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"]');
|
|
127
|
-
const box = h.closest('[role="dialog"],[role="alertdialog"],dialog,[aria-modal="true"]') || ctl || h;
|
|
128
|
-
const text = String(box.innerText || box.getAttribute("aria-label") || box.getAttribute("title") || "").replace(/\s+/g, " ").trim();
|
|
129
|
-
return { text: text.slice(0, 80) || (ctl ? "another button" : "") };
|
|
130
|
-
},
|
|
131
|
-
[cx, cy, OWN_SEL],
|
|
132
|
-
)
|
|
133
|
-
.catch(() => null);
|
|
134
|
-
if (!hit) return null;
|
|
135
|
-
if (hit.own) return { say: "the survey card is on top of it" };
|
|
136
|
-
if (hit.text) return { say: `something else on the screen is on top of it ("${hit.text}") — you cannot reach it until that is out of the way` };
|
|
137
|
-
return { invisible: true, x: cx, y: cy };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** 그 점을 지금 누르면 그 요소에 닿나 */
|
|
141
|
-
async function hits(el, x, y) {
|
|
142
|
-
return el
|
|
143
|
-
.evaluate((node, [qx, qy]) => {
|
|
144
|
-
const hit = document.elementFromPoint(qx, qy);
|
|
145
|
-
return !!hit && (node === hit || node.contains(hit) || hit.contains(node));
|
|
146
|
-
}, [x, y])
|
|
147
|
-
.catch(() => false);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* 겨눈 곳으로 가서 누른다. 누르기 직전에 그 자리에 아직 그것이 있나 본다 — 저절로 넘어가는 배너는
|
|
152
|
-
* 커서가 가는 사이 다음 카드가 와서, OKX 를 누르려다 Uniswap 이 열렸다(10/22 실험실 k5). 옮겨 갔으면 다시 겨눈다.
|
|
153
|
-
* 헛클릭(missed)은 원래 빈 곳이라 그대로 누른다. 끝내 못 맞추면 null
|
|
154
|
-
*/
|
|
155
|
-
async function pressAt(page, state, el, aim) {
|
|
156
|
-
for (let t = 0; t < 3 && aim; t++) {
|
|
157
|
-
await moveTo(page, state, aim.x, aim.y);
|
|
158
|
-
await sleep(rand(40, 140));
|
|
159
|
-
if (aim.missed || (await hits(el, aim.x, aim.y))) {
|
|
160
|
-
await page.mouse.click(aim.x, aim.y);
|
|
161
|
-
return aim;
|
|
162
|
-
}
|
|
163
|
-
aim = await aimAt(page, state, el);
|
|
164
|
-
}
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* 화면 안에 보이는 같은 것(쌍둥이). 무한히 도는 배너(slick 등)는 카드마다 복사본을 앞뒤에 깔아 둔다 —
|
|
170
|
-
* 잡은 것이 화면 밖 복사본이면 사람은 화면에 보이는 그 카드를 누른다(10/22 실험실 k5: 같은 카드가 3벌)
|
|
171
|
-
*/
|
|
172
|
-
async function visibleTwin(page, el, vw) {
|
|
173
|
-
const h = await el
|
|
174
|
-
.evaluateHandle((node, w) => {
|
|
175
|
-
const key = (n) => [n.tagName, n.getAttribute("href") || "", (n.textContent || "").replace(/\s+/g, " ").trim()].join("|");
|
|
176
|
-
const k = key(node);
|
|
177
|
-
if (k.length < 8) return null;
|
|
178
|
-
for (const n of document.querySelectorAll(node.tagName)) {
|
|
179
|
-
if (n === node || key(n) !== k) continue;
|
|
180
|
-
const r = n.getBoundingClientRect();
|
|
181
|
-
const cx = r.x + r.width / 2;
|
|
182
|
-
const cy = r.y + r.height / 2;
|
|
183
|
-
if (!(r.width > 0 && cx > 1 && cx < w - 1 && cy > 1 && cy < innerHeight - 1)) continue;
|
|
184
|
-
// 화면 안이어도 잘린 틀 밖이거나 사이드바 밑이면 사람 눈에 없다 — 그 자리를 누르면 그게 맞아야 한다.
|
|
185
|
-
// 안 보면 PC 홈 배너에서 사이드바 밑 복사본을 골라 "덮였다·화면 밖" 으로 끝났다(10/23 실험실 O2)
|
|
186
|
-
const hit = document.elementFromPoint(cx, cy);
|
|
187
|
-
if (hit && (hit === n || n.contains(hit) || hit.contains(n))) return n;
|
|
188
|
-
}
|
|
189
|
-
return null;
|
|
190
|
-
}, vw)
|
|
191
|
-
.catch(() => null);
|
|
192
|
-
const twin = h && h.asElement();
|
|
193
|
-
return twin || null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** 가로로 잘린 줄(회전 배너·가로 목록)의 틀 밖으로 나가 있나 — 가운데가 틀 밖이면 사람 눈에 없다 */
|
|
197
|
-
async function outOfRow(el) {
|
|
198
|
-
return el
|
|
199
|
-
.evaluate((n) => {
|
|
200
|
-
const r = n.getBoundingClientRect();
|
|
201
|
-
const cx = r.x + r.width / 2;
|
|
202
|
-
for (let p = n.parentElement, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
|
|
203
|
-
if (getComputedStyle(p).overflowX === "visible") continue;
|
|
204
|
-
const pr = p.getBoundingClientRect();
|
|
205
|
-
return cx < pr.left || cx > pr.right;
|
|
206
|
-
}
|
|
207
|
-
return false;
|
|
208
|
-
})
|
|
209
|
-
.catch(() => false);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* 저절로 넘어가는 줄(자동 배너)에서 잘린 것 — 사람은 밀지 않고 그 카드가 돌아와 화면 안에 들 때까지 본다.
|
|
214
|
-
* 3.5초 동안 안 움직이면 멈춰 있는 줄이다(null — 밀어야 한다). 8초 안에 화면 가운데쯤 오면 그것(또는 복사본)
|
|
215
|
-
*/
|
|
216
|
-
async function waitComeAround(page, el, vw) {
|
|
217
|
-
const first = await el.boundingBox().catch(() => null);
|
|
218
|
-
if (!first) return null;
|
|
219
|
-
let moved = false;
|
|
220
|
-
for (let i = 0; i < 32; i++) {
|
|
221
|
-
await sleep(250);
|
|
222
|
-
const b = await el.boundingBox().catch(() => null);
|
|
223
|
-
if (!b) return null;
|
|
224
|
-
if (Math.abs(b.x - first.x) > 3) moved = true;
|
|
225
|
-
// 자동 배너는 보통 3초마다 넘어간다
|
|
226
|
-
if (!moved && i >= 14) return null;
|
|
227
|
-
if (!moved) continue;
|
|
228
|
-
const c = b.x + b.width / 2;
|
|
229
|
-
if (c > vw * 0.15 && c < vw * 0.85) return el;
|
|
230
|
-
// 무한 배너는 복사본이 대신 돌아온다
|
|
231
|
-
const twin = await visibleTwin(page, el, vw);
|
|
232
|
-
if (twin) return twin;
|
|
233
|
-
}
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** 손가락으로 옆으로 민다(Chrome 터치 입력). 터치가 없는 화면이면 아무 일 없다 */
|
|
238
|
-
async function touchSwipe(page, x0, x1, y) {
|
|
239
|
-
const cdp = await page.context().newCDPSession(page);
|
|
240
|
-
try {
|
|
241
|
-
const pt = (x) => [{ x: Math.round(x), y: Math.round(y), id: 1 }];
|
|
242
|
-
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: pt(x0) });
|
|
243
|
-
for (let i = 1; i <= 10; i++) {
|
|
244
|
-
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: pt(x0 + ((x1 - x0) * i) / 10) });
|
|
245
|
-
await sleep(rand(12, 28));
|
|
246
|
-
}
|
|
247
|
-
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
248
|
-
} finally {
|
|
249
|
-
await cdp.detach().catch(() => {});
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* 가로로 넘치는 줄을 옆으로 밀어 그 요소를 화면 안으로 가져온다. 됐으면 true.
|
|
255
|
-
* 스크롤되는 줄이면 옆으로 굴리고, 아니면(transform 으로 넘기는 회전 배너) 손가락으로 끌듯 끈다
|
|
256
|
-
*/
|
|
257
|
-
async function swipeIntoView(page, state, el, vw) {
|
|
258
|
-
for (let tries = 0; tries < 4; tries++) {
|
|
259
|
-
const info = await el.evaluate((n) => {
|
|
260
|
-
const r = n.getBoundingClientRect();
|
|
261
|
-
for (let p = n.parentElement, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
|
|
262
|
-
const cs = getComputedStyle(p);
|
|
263
|
-
if (cs.overflowX === "visible") continue;
|
|
264
|
-
const pr = p.getBoundingClientRect();
|
|
265
|
-
if (pr.bottom < 0 || pr.top > innerHeight || pr.width < 40) return null;
|
|
266
|
-
const scrollable = /auto|scroll/.test(cs.overflowX) && p.scrollWidth > p.clientWidth + 2;
|
|
267
|
-
return { scrollable, cx: r.x + r.width / 2, row: { x: pr.x, y: pr.y, w: pr.width, h: pr.height } };
|
|
268
|
-
}
|
|
269
|
-
// 잘린 줄이 없는데 페이지 자체가 화면보다 넓다(모바일 배치가 넘친 사이트) — 사람은 페이지를 옆으로 끌어 본다
|
|
270
|
-
const vwNow = (window.visualViewport && visualViewport.width) || document.documentElement.clientWidth;
|
|
271
|
-
if (document.documentElement.scrollWidth > vwNow + 2) return { page: true, cx: r.x + r.width / 2 };
|
|
272
|
-
return null;
|
|
273
|
-
});
|
|
274
|
-
if (!info) return false;
|
|
275
|
-
if (info.page) {
|
|
276
|
-
await page.evaluate((dx) => window.scrollBy({ left: dx, behavior: "instant" }), info.cx - vw / 2);
|
|
277
|
-
await sleep(rand(300, 600));
|
|
278
|
-
const now = await el.boundingBox().catch(() => null);
|
|
279
|
-
return !!(now && now.x + now.width / 2 > 1 && now.x + now.width / 2 < vw - 1);
|
|
280
|
-
}
|
|
281
|
-
const target = vw / 2;
|
|
282
|
-
if (info.cx > 1 && info.cx < vw - 1 && tries > 0) return true;
|
|
283
|
-
const delta = info.cx - target;
|
|
284
|
-
if (info.scrollable) {
|
|
285
|
-
await el.evaluate((n, dx) => {
|
|
286
|
-
for (let p = n.parentElement; p && p !== document.body; p = p.parentElement) {
|
|
287
|
-
const cs = getComputedStyle(p);
|
|
288
|
-
if (/auto|scroll/.test(cs.overflowX) && p.scrollWidth > p.clientWidth + 2) return p.scrollBy({ left: dx, behavior: "instant" });
|
|
289
|
-
}
|
|
290
|
-
}, delta);
|
|
291
|
-
} else {
|
|
292
|
-
// 줄 가운데를 잡고 반대쪽으로 끈다. 한 번에 다 못 오면 다시 끈다
|
|
293
|
-
const y = Math.min(Math.max(info.row.y + info.row.h / 2, 5), (page.viewportSize() || { height: 800 }).height - 5);
|
|
294
|
-
const x0 = Math.min(Math.max(vw / 2 + Math.sign(delta) * vw * 0.3, 10), vw - 10);
|
|
295
|
-
const x1 = Math.min(Math.max(x0 - Math.sign(delta) * Math.min(Math.abs(delta) + 40, vw * 0.6), 5), vw - 5);
|
|
296
|
-
const before = await el.boundingBox().catch(() => null);
|
|
297
|
-
await moveTo(page, state, x0, y);
|
|
298
|
-
await page.mouse.down();
|
|
299
|
-
for (let i = 1; i <= 8; i++) {
|
|
300
|
-
await page.mouse.move(x0 + ((x1 - x0) * i) / 8, y + rand(-2, 2));
|
|
301
|
-
await sleep(rand(12, 30));
|
|
302
|
-
}
|
|
303
|
-
await page.mouse.up();
|
|
304
|
-
state.mouse = { x: x1, y };
|
|
305
|
-
await sleep(rand(350, 600));
|
|
306
|
-
// 휴대폰 화면에서는 배너가 손가락 밀기에만 반응한다 — 마우스로 안 움직였으면 터치로 민다
|
|
307
|
-
const after = await el.boundingBox().catch(() => null);
|
|
308
|
-
if (before && after && Math.abs(after.x - before.x) < 5) await touchSwipe(page, x0, x1, y).catch(() => {});
|
|
309
|
-
}
|
|
310
|
-
await sleep(rand(450, 800));
|
|
311
|
-
const now = await el.boundingBox().catch(() => null);
|
|
312
|
-
if (now && now.x + now.width / 2 > 1 && now.x + now.width / 2 < vw - 1) return true;
|
|
313
|
-
}
|
|
314
|
-
return false;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* 그 요소를 사람처럼 누른다.
|
|
319
|
-
* @returns { missed: boolean } missed 면 빈 곳을 눌러 아무 일도 안 일어난 것이다
|
|
320
|
-
*/
|
|
321
|
-
async function clickLikePerson(page, state, el) {
|
|
322
|
-
const startY = await page.evaluate(() => scrollY).catch(() => null);
|
|
323
|
-
// 위아래로만 굴린다. 사람은 휴대폰에서 화면을 옆으로 끌어 숨은 버튼을 찾지 않는다 — 옆으로 굴리면 화면 밖 것을 끌어와 눌렀다(10/15)
|
|
324
|
-
await el
|
|
325
|
-
.evaluate((n) => {
|
|
326
|
-
const r = n.getBoundingClientRect();
|
|
327
|
-
if (r.top < 0 || r.bottom > innerHeight) window.scrollBy(0, r.top - Math.max(40, innerHeight * 0.25));
|
|
328
|
-
})
|
|
329
|
-
.catch(() => {});
|
|
330
|
-
let aim = await aimAt(page, state, el);
|
|
331
|
-
if (!aim) {
|
|
332
|
-
// 화면 끝에 걸쳐 고정 막대(위 머리줄·아래 메뉴)에 가린 것은 사람이 조금 더 굴려서 누른다.
|
|
333
|
-
// 이걸 안 하고 "덮였다" 고 하면 모바일 아래 메뉴 밑 항목이 안 눌린다(10/14 bluetags p28, 0.1.12)
|
|
334
|
-
// 창 안(스크롤되는 칸)의 것이면 그 칸을 굴린다 — 페이지만 굴려서는 모바일 정보 창 맨 아래 Join 이
|
|
335
|
-
// 아래 메뉴 밑에서 안 나왔다(10/21 실험실 y2). scrollIntoView 는 감싼 칸들을 모두 굴린다
|
|
336
|
-
await el
|
|
337
|
-
.evaluate((n) => {
|
|
338
|
-
const before = n.getBoundingClientRect().top;
|
|
339
|
-
n.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" });
|
|
340
|
-
if (Math.abs(n.getBoundingClientRect().top - before) < 1) {
|
|
341
|
-
const r = n.getBoundingClientRect();
|
|
342
|
-
window.scrollBy(0, r.top + r.height / 2 - innerHeight / 2);
|
|
343
|
-
}
|
|
344
|
-
})
|
|
345
|
-
.catch(() => {});
|
|
346
|
-
await sleep(rand(150, 350));
|
|
347
|
-
aim = await aimAt(page, state, el);
|
|
348
|
-
}
|
|
349
|
-
if (!aim) {
|
|
350
|
-
// 다른 것이 위에 덮여 있으면 사람은 그게 눈에 보인다. 8초 기다려 "Timeout" 을 전하면
|
|
351
|
-
// 방문자는 화면이 멈췄다고 읽고 나간다(10/14 bluetags p4) — 무엇이 덮었는지 바로 말한다
|
|
352
|
-
const cover = await coveredBy(page, el);
|
|
353
|
-
if (cover && cover.invisible) {
|
|
354
|
-
// 투명한 막. 사람은 못 보니 그 자리를 그대로 누른다 — 클릭은 막에 닿고, 막이 닫히는 사이트가 많다.
|
|
355
|
-
// 안 누르고 "아무 일 없다" 고만 하면 막이 영영 안 닫혀 메뉴가 다 죽은 것처럼 된다(10/14 bluetags 달력 날짜 뒤, 0.1.12~13)
|
|
356
|
-
await moveTo(page, state, cover.x, cover.y);
|
|
357
|
-
await sleep(rand(40, 140));
|
|
358
|
-
await page.mouse.click(cover.x, cover.y);
|
|
359
|
-
// 막이 걷혔으면 클릭은 떠 있던 창을 닫은 것이다. 사람은 그걸 눈으로 안다 — 모르면 "링크가 한 번에 안 된다" 가 된다(10/14 실험실 p3)
|
|
360
|
-
// 닫히는 데 애니메이션이 붙은 창이 있다 — 0.4초에 한 번만 보면 놓쳤다(10/15 실험실 p15). 1.5초까지 본다
|
|
361
|
-
let gone = false;
|
|
362
|
-
for (let i = 0; i < 6 && !gone; i++) {
|
|
363
|
-
await sleep(250);
|
|
364
|
-
const still = await coveredBy(page, el).catch(() => null);
|
|
365
|
-
gone = !(still && still.invisible);
|
|
366
|
-
}
|
|
367
|
-
return { missed: false, closedOverlay: gone };
|
|
368
|
-
}
|
|
369
|
-
// 덮은 것을 못 찾아도 본다 — 틀 밖 자리를 찍으면 배너 틀 자신이 맞아 "안 덮였다" 가 되고, 끝에 Playwright 클릭이
|
|
370
|
-
// "무언가 위에 있다" 로 끝났다(10/23 실험실 P1, 20초 뒤 누르기)
|
|
371
|
-
if (!(cover && /survey card/.test(cover.say || "")) && (await outOfRow(el))) {
|
|
372
|
-
// 배너가 그 사이 넘어가 카드가 틀 밖으로 나갔다. 사람은 화면에 보이는 그 카드(복사본)를 누르거나 돌아오길 기다린다.
|
|
373
|
-
// 틀 밖 자리에 마침 사이드바가 있어 "사이드바가 덮었다" 로 끝났다 — PC 홈 배너가 처음 몇 초 안에 네 칸을 넘긴다(10/23 실험실 O2)
|
|
374
|
-
const vw = (page.viewportSize() || { width: 1200 }).width;
|
|
375
|
-
const twin = (await visibleTwin(page, el, vw)) || (await waitComeAround(page, el, vw));
|
|
376
|
-
const got = twin ? await pressAt(page, state, twin, await aimAt(page, state, twin)) : null;
|
|
377
|
-
if (got) return { missed: got.missed };
|
|
378
|
-
const say = "it slid out of sight as you went to tap it — that row of cards moved on by itself, and it is no longer showing";
|
|
379
|
-
const e = new Error(say);
|
|
380
|
-
e.human = say;
|
|
381
|
-
throw e;
|
|
382
|
-
}
|
|
383
|
-
if (cover) {
|
|
384
|
-
// 못 누른 채 화면만 굴러가 있으면 "안 됐다" 는데 화면이 바뀐다(10/21 실험실 q2) — 사람은 굴리지 않고 덮인 걸 본다
|
|
385
|
-
if (startY !== null) await page.evaluate((y) => window.scrollTo({ top: y, behavior: "instant" }), startY).catch(() => {});
|
|
386
|
-
const e = new Error(cover.say);
|
|
387
|
-
e.human = cover.say;
|
|
388
|
-
throw e;
|
|
389
|
-
}
|
|
390
|
-
// 화면 가장자리 밖으로 잘려 나간 것. 굴려도 옆으로는 안 온다 — 사람은 "잘려 있다" 를 본다.
|
|
391
|
-
// 이걸 Playwright 에 맡기면 "덮였다" 로 나왔다(10/15 실험실 p18: 모바일에서 공유 창 오른쪽 절반이 화면 밖)
|
|
392
|
-
let bb = await el.boundingBox().catch(() => null);
|
|
393
|
-
const vw = (page.viewportSize() || { width: 1200 }).width;
|
|
394
|
-
// 가로로 넘치는 줄(회전 배너·가로 목록) 안에서 잘린 것이면, 사람은 그 줄을 옆으로 밀어 가져온 뒤 누른다
|
|
395
|
-
// (10/15 실험실 p1·p3: 모바일 첫 화면 배너 끝에 걸친 카드를 끝내 못 눌렀다)
|
|
396
|
-
if (bb && (bb.x + bb.width / 2 > vw - 1 || bb.x + bb.width / 2 < 1)) {
|
|
397
|
-
const twin = await visibleTwin(page, el, vw);
|
|
398
|
-
if (twin) {
|
|
399
|
-
const got = await pressAt(page, state, twin, await aimAt(page, state, twin));
|
|
400
|
-
if (got) return { missed: got.missed };
|
|
401
|
-
}
|
|
402
|
-
// 저절로 넘어가는 줄이면 밀지 않고 돌아오길 기다린다. 넘어가는 줄을 밀면 반쯤은 엇나가 "잘렸다" 로 끝났다(10/22 실험실 k5)
|
|
403
|
-
const round = await waitComeAround(page, el, vw);
|
|
404
|
-
if (round) {
|
|
405
|
-
const came = await pressAt(page, state, round, await aimAt(page, state, round));
|
|
406
|
-
if (came) return { missed: came.missed };
|
|
407
|
-
}
|
|
408
|
-
await swipeIntoView(page, state, el, vw).catch(() => false);
|
|
409
|
-
// 무한 배너는 밀고 나면 잡은 것 대신 그 복사본이 화면에 와 있다 — 화면에 든 쪽을 누른다(10/22 실험실 k5)
|
|
410
|
-
bb = await el.boundingBox().catch(() => null);
|
|
411
|
-
const inView = bb && bb.x + bb.width / 2 > 1 && bb.x + bb.width / 2 < vw - 1;
|
|
412
|
-
const target = inView ? el : await visibleTwin(page, el, vw);
|
|
413
|
-
const done = target ? await pressAt(page, state, target, await aimAt(page, state, target)) : null;
|
|
414
|
-
if (done) return { missed: done.missed, swiped: true };
|
|
415
|
-
// 밀어도 못 가져왔다 — 저절로 넘어가는 줄이면 한 번 더 돌아오길 기다린다
|
|
416
|
-
const later = await waitComeAround(page, el, vw);
|
|
417
|
-
const came = later ? await pressAt(page, state, later, await aimAt(page, state, later)) : null;
|
|
418
|
-
if (came) return { missed: came.missed, swiped: true };
|
|
419
|
-
bb = await el.boundingBox().catch(() => null);
|
|
420
|
-
}
|
|
421
|
-
if (bb && (bb.x + bb.width / 2 > vw - 1 || bb.x + bb.width / 2 < 1)) {
|
|
422
|
-
// 보였던 카드가 배너가 넘어가며 나간 것이면 그렇게 말한다 — "화면 밖" 이면 방문자는 처음부터 못 본 줄 안다
|
|
423
|
-
const say = (await outOfRow(el))
|
|
424
|
-
? "it slid out of sight as you went to tap it — that row of cards moved on by itself, and it is no longer showing"
|
|
425
|
-
: bb.x < vw && bb.x + bb.width > 0
|
|
426
|
-
? "it is cut off at the edge of the screen — only a sliver of it shows, and you cannot reach it"
|
|
427
|
-
: "it is off the edge of the screen and you cannot reach it";
|
|
428
|
-
const e = new Error(say);
|
|
429
|
-
e.human = say;
|
|
430
|
-
throw e;
|
|
431
|
-
}
|
|
432
|
-
// 좌표를 포기한다
|
|
433
|
-
await el.click({ timeout: 8000 });
|
|
434
|
-
return { missed: false };
|
|
435
|
-
}
|
|
436
|
-
const done = await pressAt(page, state, el, aim);
|
|
437
|
-
if (done) return { missed: done.missed };
|
|
438
|
-
// 누르려는 사이 자꾸 옮겨 갔다 — 저절로 넘어가는 줄이다. 한 번 돌아오길 기다려 본다
|
|
439
|
-
const vw = (page.viewportSize() || { width: 1200 }).width;
|
|
440
|
-
const twin = await visibleTwin(page, el, vw);
|
|
441
|
-
if (twin) {
|
|
442
|
-
const got = await pressAt(page, state, twin, await aimAt(page, state, twin));
|
|
443
|
-
if (got) return { missed: got.missed };
|
|
444
|
-
}
|
|
445
|
-
const round = await waitComeAround(page, el, vw);
|
|
446
|
-
if (round) {
|
|
447
|
-
const came = await pressAt(page, state, round, await aimAt(page, state, round));
|
|
448
|
-
if (came) return { missed: came.missed };
|
|
449
|
-
}
|
|
450
|
-
const say = "it slid away as you went to tap it — that row moves by itself";
|
|
451
|
-
const e = new Error(say);
|
|
452
|
-
e.human = say;
|
|
453
|
-
throw e;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
const NEAR = { a: "s", s: "d", d: "f", f: "g", g: "h", h: "j", j: "k", k: "l", l: "k", q: "w", w: "e", e: "r", r: "t", t: "y", y: "u", u: "i", i: "o", o: "p", p: "o", z: "x", x: "c", c: "v", v: "b", b: "n", n: "m", m: "n" };
|
|
457
|
-
|
|
458
|
-
/** 사람처럼 친다. 가끔 잘못 치고 알아채서 지운다. 칸에 남는 글자는 언제나 원래 치려던 것이다 */
|
|
459
|
-
async function typeLikePerson(page, state, text) {
|
|
460
|
-
if (!state.rhythm) {
|
|
461
|
-
await page.keyboard.type(text, { delay: rand(70, 160) });
|
|
462
|
-
return 0;
|
|
463
|
-
}
|
|
464
|
-
const h = state.rhythm.hand();
|
|
465
|
-
const wrongFor = (ch, i) => {
|
|
466
|
-
const low = ch.toLowerCase();
|
|
467
|
-
if (NEAR[low]) return ch === low ? NEAR[low] : NEAR[low].toUpperCase();
|
|
468
|
-
const pool = text.replace(new RegExp(ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "");
|
|
469
|
-
return pool ? pool[(i * 7 + 3) % pool.length] : "x";
|
|
470
|
-
};
|
|
471
|
-
let typos = 0;
|
|
472
|
-
for (let i = 0; i < text.length; i++) {
|
|
473
|
-
await sleep(state.rhythm.keyPause());
|
|
474
|
-
if (text.length > 2 && Math.random() < h.typoRate) {
|
|
475
|
-
typos++;
|
|
476
|
-
await page.keyboard.type(wrongFor(text[i], i));
|
|
477
|
-
let extra = 0;
|
|
478
|
-
if (Math.random() < 0.45 && i + 1 < text.length) {
|
|
479
|
-
await sleep(state.rhythm.keyPause());
|
|
480
|
-
await page.keyboard.type(text[i + 1]);
|
|
481
|
-
extra = 1;
|
|
482
|
-
}
|
|
483
|
-
await sleep(180 + Math.random() * 420); // 어? 하는 순간
|
|
484
|
-
for (let k = 0; k <= extra; k++) {
|
|
485
|
-
await page.keyboard.press("Backspace");
|
|
486
|
-
await sleep(60 + Math.random() * 120);
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
await page.keyboard.type(text[i]);
|
|
490
|
-
}
|
|
491
|
-
return typos;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/** 스크롤도 한 번에 정확히 가지 않는다. 지나쳤다 조금 되돌아오기도 한다 */
|
|
495
|
-
async function scrollLikePerson(page, state, dir = 1) {
|
|
496
|
-
const h = state.rhythm ? state.rhythm.hand() : { stride: 110 };
|
|
497
|
-
const total = (300 + Math.random() * 700) * dir;
|
|
498
|
-
const chunks = 2 + Math.round(Math.random() * 2);
|
|
499
|
-
const y0 = await page.evaluate(() => scrollY).catch(() => null);
|
|
500
|
-
// 휴대폰에서는 화면 가운데를 엄지로 민다. 커서가 처음 자리(0,0)에 있으면 휠이 그 자리의 것을 굴린다 —
|
|
501
|
-
// 화면을 덮은 정보 창 대신 그 뒤 페이지가 굴러, 창 맨 아래 Join 이 끝내 아래 메뉴 밑에 있었다(10/21 실험실 r1).
|
|
502
|
-
// 데스크톱도 커서가 아직 한 번도 안 움직였으면 본문 쪽으로 옮겨 굴린다
|
|
503
|
-
const touch = await page.evaluate(() => matchMedia("(pointer: coarse)").matches).catch(() => false);
|
|
504
|
-
const vp0 = page.viewportSize() || { width: 1200, height: 800 };
|
|
505
|
-
// 흐린 막 위에 창이 떠 있으면 사람은 그 창 위에서 굴린다 — 주소로 바로 열린 데스크톱 정보 창은 뒤 페이지가
|
|
506
|
-
// 안 잠겨 휠이 뒤 목록을 굴렸고, 창 맨 아래 Join 을 끝내 못 봤다(10/21 실험실 u4)
|
|
507
|
-
const panel = await page
|
|
508
|
-
.evaluate(() => {
|
|
509
|
-
const VW = document.documentElement.clientWidth || innerWidth;
|
|
510
|
-
const fixedCtx = (n) => {
|
|
511
|
-
for (let p = n; p && p !== document.body; p = p.parentElement) if (getComputedStyle(p).position === "fixed") return true;
|
|
512
|
-
return false;
|
|
513
|
-
};
|
|
514
|
-
// 화면에 붙은 큰 판 — 흐린 막(데스크톱 정보 창 뒤)이든 화면을 덮은 불투명한 창(모바일 정보 창)이든
|
|
515
|
-
let overlay = false;
|
|
516
|
-
for (const n of document.body.querySelectorAll("*")) {
|
|
517
|
-
const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
|
|
518
|
-
const a = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
|
|
519
|
-
if (!a) continue;
|
|
520
|
-
const r = n.getBoundingClientRect();
|
|
521
|
-
if ((a < 0.9 ? r.width >= VW * 0.5 : r.width >= VW * 0.9) && r.height >= innerHeight * 0.6 && fixedCtx(n)) {
|
|
522
|
-
overlay = true;
|
|
523
|
-
break;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
if (!overlay) return null;
|
|
527
|
-
let best = null;
|
|
528
|
-
let area = 0;
|
|
529
|
-
for (const n of document.body.querySelectorAll("*")) {
|
|
530
|
-
if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100 || !/auto|scroll/.test(getComputedStyle(n).overflowY) || !fixedCtx(n)) continue;
|
|
531
|
-
const r = n.getBoundingClientRect();
|
|
532
|
-
const cx = r.left + r.width / 2;
|
|
533
|
-
const cy = Math.min(r.top + r.height / 2, innerHeight - 5);
|
|
534
|
-
const h = document.elementFromPoint(cx, cy);
|
|
535
|
-
if (!h || !(n === h || n.contains(h))) continue; // 위에 떠 있는 칸만
|
|
536
|
-
const a = r.width * r.height;
|
|
537
|
-
if (a > area) (area = a), (best = { x: cx, y: cy });
|
|
538
|
-
}
|
|
539
|
-
return best;
|
|
540
|
-
})
|
|
541
|
-
.catch(() => null);
|
|
542
|
-
if (panel) await moveTo(page, state, panel.x + rand(-30, 30), panel.y + rand(-40, 40));
|
|
543
|
-
else if (touch || !state.mouse || (state.mouse.x < 5 && state.mouse.y < 5)) {
|
|
544
|
-
await moveTo(page, state, vp0.width * rand(0.4, 0.6), vp0.height * rand(0.4, 0.6));
|
|
545
|
-
}
|
|
546
|
-
for (let i = 0; i < chunks; i++) {
|
|
547
|
-
await page.mouse.wheel(0, total / chunks);
|
|
548
|
-
await sleep(rand(80, 260));
|
|
549
|
-
// 커서가 메뉴·고정 막대 위에 있으면 휠이 페이지를 안 굴린다. 사람은 커서를 본문 쪽으로 옮기고 다시 굴린다
|
|
550
|
-
// (10/13: 약관 링크를 누른 뒤 세 번 굴려도 0px)
|
|
551
|
-
if (i === 0 && y0 !== null && (await page.evaluate(() => scrollY).catch(() => null)) === y0) {
|
|
552
|
-
// 페이지가 잠겼고 화면 한쪽 칸만 굴러가면(데스크톱 정보 창) 사람은 그 칸 위로 커서를 옮긴다(10/21 실험실 r4)
|
|
553
|
-
const pane = await page
|
|
554
|
-
.evaluate(() => {
|
|
555
|
-
// 페이지가 그냥 끝에 닿은 것이면 안쪽 칸을 찾지 않는다
|
|
556
|
-
if (document.documentElement.scrollHeight > innerHeight + 2 && getComputedStyle(document.body).overflowY !== "hidden") return null;
|
|
557
|
-
let best = null;
|
|
558
|
-
let area = 0;
|
|
559
|
-
for (const n of document.body.querySelectorAll("*")) {
|
|
560
|
-
if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100 || n.scrollTop + n.clientHeight >= n.scrollHeight - 2) continue;
|
|
561
|
-
if (!/auto|scroll/.test(getComputedStyle(n).overflowY)) continue;
|
|
562
|
-
const r = n.getBoundingClientRect();
|
|
563
|
-
const a = Math.max(0, Math.min(r.right, innerWidth) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, innerHeight) - Math.max(r.top, 0));
|
|
564
|
-
if (a > area) (area = a), (best = { x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
|
565
|
-
}
|
|
566
|
-
return best;
|
|
567
|
-
})
|
|
568
|
-
.catch(() => null);
|
|
569
|
-
const vp = page.viewportSize() || { width: 1200, height: 800 };
|
|
570
|
-
if (pane) await moveTo(page, state, pane.x + rand(-30, 30), pane.y + rand(-40, 40));
|
|
571
|
-
else await moveTo(page, state, vp.width * rand(0.45, 0.6), vp.height * rand(0.45, 0.65));
|
|
572
|
-
await page.mouse.wheel(0, total / chunks);
|
|
573
|
-
await sleep(rand(80, 260));
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
// 가끔 조금 되돌린다(지나쳤다 싶어서). 실제로 움직였고 끝에 닿지 않았을 때만 —
|
|
577
|
-
// 끝에서 되돌리면 "내렸는데 덜 내려가고", 맨 위에서 위로 굴리면 오히려 내려갔다(10/14 실험실)
|
|
578
|
-
const pos = async () => page.evaluate(() => ({ y: scrollY, max: document.documentElement.scrollHeight - innerHeight })).catch(() => null);
|
|
579
|
-
const after = await pos();
|
|
580
|
-
const moved = after && y0 !== null ? after.y - y0 : 0;
|
|
581
|
-
const atEnd = after && (dir > 0 ? after.y >= after.max - 2 : after.y <= 2);
|
|
582
|
-
// 창 안을 굴렸으면 되돌리지 않는다 — 뒤 페이지가 움직인 만큼 되돌리다 창 안이 맨 위로 돌아가,
|
|
583
|
-
// 창 맨 아래 Join 이 아래 메뉴 밑에 다시 갇혔다(10/21 실험실 x4)
|
|
584
|
-
if (!panel && Math.sign(moved) === Math.sign(dir) && Math.abs(moved) > 120 && !atEnd && Math.random() < 0.25) {
|
|
585
|
-
await sleep(rand(150, 500));
|
|
586
|
-
await page.mouse.wheel(0, -moved * (0.15 + Math.random() * 0.2));
|
|
587
|
-
}
|
|
588
|
-
const end = await pos();
|
|
589
|
-
return end && y0 !== null ? Math.round(end.y - y0) : Math.round(total);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
module.exports = { moveTo, aimAt, clickLikePerson, typeLikePerson, scrollLikePerson };
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 손 — 마우스가 어떻게 가고, 어디를 겨냥하고, 어떻게 치는가.
|
|
4
|
+
*
|
|
5
|
+
* 정확히 한가운데를 누르고 오타 없이 치면 사람 기록이 아니다. 헛클릭도, 지웠다 다시 치는 것도
|
|
6
|
+
* 사이트가 보는 신호의 일부다. 어디까지 흔들리는지는 그 사람 성향에서 나온다(persona.js Rhythm).
|
|
7
|
+
*
|
|
8
|
+
* 빗나가는 것은 허용하되 **엉뚱한 버튼을 눌러 버리지는 않는다.** 그러면 기록이 거짓이 된다 —
|
|
9
|
+
* 누르기 직전에 알아채고 대상 안으로 끌어오는 쪽이 사람에 가깝다.
|
|
10
|
+
*
|
|
11
|
+
* 의존성 없음. Playwright page 와 Rhythm 을 받는다.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const OWN_SEL = "#eb-insight,#eb-plugin";
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
const rand = (a, b) => a + Math.random() * (b - a);
|
|
17
|
+
|
|
18
|
+
/** 커서를 그 자리까지. 직선으로 가지 않고 살짝 휘며, 가끔 지나쳤다 되돌아온다 */
|
|
19
|
+
async function moveTo(page, state, x, y) {
|
|
20
|
+
const vp = page.viewportSize() || { width: 1200, height: 800 };
|
|
21
|
+
const from = state.mouse || { x: vp.width / 2, y: vp.height * 0.4 };
|
|
22
|
+
const h = state.rhythm ? state.rhythm.hand() : { stride: 110, overshoot: 0.2 };
|
|
23
|
+
const dx = x - from.x;
|
|
24
|
+
const dy = y - from.y;
|
|
25
|
+
const dist = Math.hypot(dx, dy);
|
|
26
|
+
if (dist < 2) {
|
|
27
|
+
state.mouse = { x, y };
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const steps = Math.max(2, Math.min(28, Math.round(dist / h.stride) + 2));
|
|
31
|
+
const bow = Math.min(60, dist * 0.12) * (Math.random() < 0.5 ? -1 : 1);
|
|
32
|
+
await page.mouse.move(from.x + dx * 0.55 - (dy / dist) * bow, from.y + dy * 0.55 + (dx / dist) * bow, {
|
|
33
|
+
steps: Math.max(2, Math.round(steps * 0.6)),
|
|
34
|
+
});
|
|
35
|
+
if (dist > 80 && Math.random() < h.overshoot) {
|
|
36
|
+
const over = 6 + Math.random() * Math.min(40, dist * 0.08);
|
|
37
|
+
await page.mouse.move(x + (dx / dist) * over, y + (dy / dist) * over, { steps: Math.max(2, Math.round(steps * 0.4)) });
|
|
38
|
+
await sleep(40 + Math.random() * 90);
|
|
39
|
+
}
|
|
40
|
+
await page.mouse.move(x, y, { steps: Math.max(2, Math.round(steps * 0.4)) });
|
|
41
|
+
state.mouse = { x, y };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 어디를 겨냥하나. 한가운데가 아니라 그 사람 정확도만큼 흩어진 점. null 이면 좌표를 포기한다 */
|
|
45
|
+
async function aimAt(page, state, el) {
|
|
46
|
+
const box = await el.boundingBox().catch(() => null);
|
|
47
|
+
if (!box) return null;
|
|
48
|
+
const vp = page.viewportSize() || { width: 1200, height: 800 };
|
|
49
|
+
const cx = box.x + box.width / 2;
|
|
50
|
+
const cy = box.y + box.height / 2;
|
|
51
|
+
const o = state.rhythm ? state.rhythm.aimErrorPx() : { dx: 0, dy: 0 };
|
|
52
|
+
const x = cx + o.dx;
|
|
53
|
+
const y = cy + o.dy;
|
|
54
|
+
const outDist = Math.hypot(Math.max(box.x - x, 0, x - (box.x + box.width)), Math.max(box.y - y, 0, y - (box.y + box.height)));
|
|
55
|
+
const onScreen = (px, py) => px > 1 && py > 1 && px < vp.width - 1 && py < vp.height - 1;
|
|
56
|
+
|
|
57
|
+
const at = async (px, py) => {
|
|
58
|
+
if (!onScreen(px, py)) return "밖";
|
|
59
|
+
return el
|
|
60
|
+
.evaluate((node, [qx, qy, ownSel]) => {
|
|
61
|
+
const hit = document.elementFromPoint(qx, qy);
|
|
62
|
+
if (!hit) return "빈 곳";
|
|
63
|
+
if (node === hit || node.contains(hit)) return "대상";
|
|
64
|
+
if (hit.contains(node)) {
|
|
65
|
+
// 감싼 상자를 누른 것이다. 그 상자가 스스로 눌리는 것(링크·버튼·클릭이 붙은 것)일 때만 같은 조작이다.
|
|
66
|
+
// 크기만 보고 "대상" 으로 쳤더니 달력 Month 아래 여백을 눌러 놓고 눌린 줄 알았다 — 클릭은 안쪽 버튼에만 붙어 있었다(10/15 실험실 p14)
|
|
67
|
+
let selfClick = !!hit.closest('a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],summary,label');
|
|
68
|
+
for (const k in hit) if (k.startsWith("__reactProps") && hit[k] && typeof hit[k].onClick === "function") selfClick = true;
|
|
69
|
+
if (!selfClick) return "빈 곳";
|
|
70
|
+
const a = node.getBoundingClientRect();
|
|
71
|
+
const b = hit.getBoundingClientRect();
|
|
72
|
+
return b.width * b.height <= a.width * a.height * 3 ? "대상" : "빈 곳";
|
|
73
|
+
}
|
|
74
|
+
if (ownSel && hit.closest(ownSel)) return "가림";
|
|
75
|
+
return hit.closest('a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],summary,input,select,textarea,label')
|
|
76
|
+
? "다른 조작"
|
|
77
|
+
: "빈 곳";
|
|
78
|
+
}, [px, py, OWN_SEL])
|
|
79
|
+
.catch(() => "가림");
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const first = await at(x, y);
|
|
83
|
+
if (first === "대상") return { x, y, missed: false };
|
|
84
|
+
// 대상 밖 빈 곳 — 사람이 하는 헛클릭. 다만 아주 가까운 빗나감은 브라우저가 그 요소로 보내므로
|
|
85
|
+
// "빗나갔다" 고 적고 실제로는 눌리는 거짓 기록이 된다. 그럴 땐 테두리 안으로 끌어온다.
|
|
86
|
+
// 헛클릭은 빈 곳에 떨어질 때만 — 옆 버튼에 떨어지면 그 버튼이 눌려 "Join 이 관심목록으로 보낸다" 같은 가짜 결함이 된다.
|
|
87
|
+
// 끌어온 점도 대상인지 본다 — 아래 고정 메뉴에 깔린 버튼은 끌어온 점이 메뉴라 Watchlist 가 눌렸다(10/15 실험실 y57)
|
|
88
|
+
if (outDist > 0) {
|
|
89
|
+
const margin = state.rhythm ? state.rhythm.missMarginPx() : 6;
|
|
90
|
+
if (outDist >= margin && first === "빈 곳") return { x, y, missed: true };
|
|
91
|
+
const px = Math.min(Math.max(x, box.x + 2), box.x + box.width - 2);
|
|
92
|
+
const py = Math.min(Math.max(y, box.y + 2), box.y + box.height - 2);
|
|
93
|
+
if ((await at(px, py)) === "대상") return { x: px, y: py, missed: false };
|
|
94
|
+
}
|
|
95
|
+
if ((await at(cx, cy)) === "대상") return { x: cx, y: cy, missed: false };
|
|
96
|
+
// 가운데만 가렸으면 사람은 보이는 쪽을 누른다 — 화면에 붙은 창의 Join 버튼 아래쪽이 아래 메뉴에 깔려
|
|
97
|
+
// 굴려도 안 비켜서 "Watchlist 가 덮었다" 가 됐다(10/21 실험실 q2). 가운데에서 가까운 점부터 본다
|
|
98
|
+
const pts = [];
|
|
99
|
+
for (const fy of [0.2, 0.35, 0.5, 0.65, 0.8]) for (const fx of [0.5, 0.3, 0.7, 0.15, 0.85]) pts.push([box.x + box.width * fx, box.y + box.height * fy]);
|
|
100
|
+
pts.sort((a, b) => Math.hypot(a[0] - cx, a[1] - cy) - Math.hypot(b[0] - cx, b[1] - cy));
|
|
101
|
+
for (const [px, py] of pts) if ((await at(px, py)) === "대상") return { x: px, y: py, missed: false };
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 그 요소 한가운데를 다른 것이 덮고 있나. 아니면 null.
|
|
107
|
+
* { say } 는 눈에 보이는 것(팝업 등)이 덮은 것 — 방문자에게 할 말.
|
|
108
|
+
* { invisible, x, y } 는 글자 없는 막 — 사람 눈에는 안 보이니 사람은 그냥 누르고, 클릭은 그 막에 닿는다
|
|
109
|
+
*/
|
|
110
|
+
async function coveredBy(page, el) {
|
|
111
|
+
const box = await el.boundingBox().catch(() => null);
|
|
112
|
+
const vp = page.viewportSize() || { width: 1200, height: 800 };
|
|
113
|
+
if (!box) return null;
|
|
114
|
+
const cx = box.x + box.width / 2;
|
|
115
|
+
const cy = box.y + box.height / 2;
|
|
116
|
+
if (cx < 1 || cy < 1 || cx > vp.width - 1 || cy > vp.height - 1) return null;
|
|
117
|
+
const hit = await el
|
|
118
|
+
.evaluate(
|
|
119
|
+
(node, [qx, qy, ownSel]) => {
|
|
120
|
+
const h = document.elementFromPoint(qx, qy);
|
|
121
|
+
if (!h || node === h || node.contains(h) || h.contains(node)) return null;
|
|
122
|
+
if (ownSel && h.closest(ownSel)) return { own: true };
|
|
123
|
+
// 덮은 덩어리 — 대화상자·팝업이면 그 전체, 아니면 맞은 요소
|
|
124
|
+
// 덮은 것이 링크·버튼 안이면 눈에 보이는 조작이다. 그 안의 그림(아이콘)만 맞으면 글자가 없어 "투명한 막" 으로 보고
|
|
125
|
+
// 그냥 눌렀다 — 아래 고정 메뉴의 Watchlist 아이콘이 눌려 관심목록으로 갔다(10/15 실험실 y57, 정보 창의 Join)
|
|
126
|
+
const ctl = h.closest('a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"]');
|
|
127
|
+
const box = h.closest('[role="dialog"],[role="alertdialog"],dialog,[aria-modal="true"]') || ctl || h;
|
|
128
|
+
const text = String(box.innerText || box.getAttribute("aria-label") || box.getAttribute("title") || "").replace(/\s+/g, " ").trim();
|
|
129
|
+
return { text: text.slice(0, 80) || (ctl ? "another button" : "") };
|
|
130
|
+
},
|
|
131
|
+
[cx, cy, OWN_SEL],
|
|
132
|
+
)
|
|
133
|
+
.catch(() => null);
|
|
134
|
+
if (!hit) return null;
|
|
135
|
+
if (hit.own) return { say: "the survey card is on top of it" };
|
|
136
|
+
if (hit.text) return { say: `something else on the screen is on top of it ("${hit.text}") — you cannot reach it until that is out of the way` };
|
|
137
|
+
return { invisible: true, x: cx, y: cy };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 그 점을 지금 누르면 그 요소에 닿나 */
|
|
141
|
+
async function hits(el, x, y) {
|
|
142
|
+
return el
|
|
143
|
+
.evaluate((node, [qx, qy]) => {
|
|
144
|
+
const hit = document.elementFromPoint(qx, qy);
|
|
145
|
+
return !!hit && (node === hit || node.contains(hit) || hit.contains(node));
|
|
146
|
+
}, [x, y])
|
|
147
|
+
.catch(() => false);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 겨눈 곳으로 가서 누른다. 누르기 직전에 그 자리에 아직 그것이 있나 본다 — 저절로 넘어가는 배너는
|
|
152
|
+
* 커서가 가는 사이 다음 카드가 와서, OKX 를 누르려다 Uniswap 이 열렸다(10/22 실험실 k5). 옮겨 갔으면 다시 겨눈다.
|
|
153
|
+
* 헛클릭(missed)은 원래 빈 곳이라 그대로 누른다. 끝내 못 맞추면 null
|
|
154
|
+
*/
|
|
155
|
+
async function pressAt(page, state, el, aim) {
|
|
156
|
+
for (let t = 0; t < 3 && aim; t++) {
|
|
157
|
+
await moveTo(page, state, aim.x, aim.y);
|
|
158
|
+
await sleep(rand(40, 140));
|
|
159
|
+
if (aim.missed || (await hits(el, aim.x, aim.y))) {
|
|
160
|
+
await page.mouse.click(aim.x, aim.y);
|
|
161
|
+
return aim;
|
|
162
|
+
}
|
|
163
|
+
aim = await aimAt(page, state, el);
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 화면 안에 보이는 같은 것(쌍둥이). 무한히 도는 배너(slick 등)는 카드마다 복사본을 앞뒤에 깔아 둔다 —
|
|
170
|
+
* 잡은 것이 화면 밖 복사본이면 사람은 화면에 보이는 그 카드를 누른다(10/22 실험실 k5: 같은 카드가 3벌)
|
|
171
|
+
*/
|
|
172
|
+
async function visibleTwin(page, el, vw) {
|
|
173
|
+
const h = await el
|
|
174
|
+
.evaluateHandle((node, w) => {
|
|
175
|
+
const key = (n) => [n.tagName, n.getAttribute("href") || "", (n.textContent || "").replace(/\s+/g, " ").trim()].join("|");
|
|
176
|
+
const k = key(node);
|
|
177
|
+
if (k.length < 8) return null;
|
|
178
|
+
for (const n of document.querySelectorAll(node.tagName)) {
|
|
179
|
+
if (n === node || key(n) !== k) continue;
|
|
180
|
+
const r = n.getBoundingClientRect();
|
|
181
|
+
const cx = r.x + r.width / 2;
|
|
182
|
+
const cy = r.y + r.height / 2;
|
|
183
|
+
if (!(r.width > 0 && cx > 1 && cx < w - 1 && cy > 1 && cy < innerHeight - 1)) continue;
|
|
184
|
+
// 화면 안이어도 잘린 틀 밖이거나 사이드바 밑이면 사람 눈에 없다 — 그 자리를 누르면 그게 맞아야 한다.
|
|
185
|
+
// 안 보면 PC 홈 배너에서 사이드바 밑 복사본을 골라 "덮였다·화면 밖" 으로 끝났다(10/23 실험실 O2)
|
|
186
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
187
|
+
if (hit && (hit === n || n.contains(hit) || hit.contains(n))) return n;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}, vw)
|
|
191
|
+
.catch(() => null);
|
|
192
|
+
const twin = h && h.asElement();
|
|
193
|
+
return twin || null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 가로로 잘린 줄(회전 배너·가로 목록)의 틀 밖으로 나가 있나 — 가운데가 틀 밖이면 사람 눈에 없다 */
|
|
197
|
+
async function outOfRow(el) {
|
|
198
|
+
return el
|
|
199
|
+
.evaluate((n) => {
|
|
200
|
+
const r = n.getBoundingClientRect();
|
|
201
|
+
const cx = r.x + r.width / 2;
|
|
202
|
+
for (let p = n.parentElement, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
|
|
203
|
+
if (getComputedStyle(p).overflowX === "visible") continue;
|
|
204
|
+
const pr = p.getBoundingClientRect();
|
|
205
|
+
return cx < pr.left || cx > pr.right;
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
})
|
|
209
|
+
.catch(() => false);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 저절로 넘어가는 줄(자동 배너)에서 잘린 것 — 사람은 밀지 않고 그 카드가 돌아와 화면 안에 들 때까지 본다.
|
|
214
|
+
* 3.5초 동안 안 움직이면 멈춰 있는 줄이다(null — 밀어야 한다). 8초 안에 화면 가운데쯤 오면 그것(또는 복사본)
|
|
215
|
+
*/
|
|
216
|
+
async function waitComeAround(page, el, vw) {
|
|
217
|
+
const first = await el.boundingBox().catch(() => null);
|
|
218
|
+
if (!first) return null;
|
|
219
|
+
let moved = false;
|
|
220
|
+
for (let i = 0; i < 32; i++) {
|
|
221
|
+
await sleep(250);
|
|
222
|
+
const b = await el.boundingBox().catch(() => null);
|
|
223
|
+
if (!b) return null;
|
|
224
|
+
if (Math.abs(b.x - first.x) > 3) moved = true;
|
|
225
|
+
// 자동 배너는 보통 3초마다 넘어간다
|
|
226
|
+
if (!moved && i >= 14) return null;
|
|
227
|
+
if (!moved) continue;
|
|
228
|
+
const c = b.x + b.width / 2;
|
|
229
|
+
if (c > vw * 0.15 && c < vw * 0.85) return el;
|
|
230
|
+
// 무한 배너는 복사본이 대신 돌아온다
|
|
231
|
+
const twin = await visibleTwin(page, el, vw);
|
|
232
|
+
if (twin) return twin;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 손가락으로 옆으로 민다(Chrome 터치 입력). 터치가 없는 화면이면 아무 일 없다 */
|
|
238
|
+
async function touchSwipe(page, x0, x1, y) {
|
|
239
|
+
const cdp = await page.context().newCDPSession(page);
|
|
240
|
+
try {
|
|
241
|
+
const pt = (x) => [{ x: Math.round(x), y: Math.round(y), id: 1 }];
|
|
242
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: pt(x0) });
|
|
243
|
+
for (let i = 1; i <= 10; i++) {
|
|
244
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: pt(x0 + ((x1 - x0) * i) / 10) });
|
|
245
|
+
await sleep(rand(12, 28));
|
|
246
|
+
}
|
|
247
|
+
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
248
|
+
} finally {
|
|
249
|
+
await cdp.detach().catch(() => {});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 가로로 넘치는 줄을 옆으로 밀어 그 요소를 화면 안으로 가져온다. 됐으면 true.
|
|
255
|
+
* 스크롤되는 줄이면 옆으로 굴리고, 아니면(transform 으로 넘기는 회전 배너) 손가락으로 끌듯 끈다
|
|
256
|
+
*/
|
|
257
|
+
async function swipeIntoView(page, state, el, vw) {
|
|
258
|
+
for (let tries = 0; tries < 4; tries++) {
|
|
259
|
+
const info = await el.evaluate((n) => {
|
|
260
|
+
const r = n.getBoundingClientRect();
|
|
261
|
+
for (let p = n.parentElement, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
|
|
262
|
+
const cs = getComputedStyle(p);
|
|
263
|
+
if (cs.overflowX === "visible") continue;
|
|
264
|
+
const pr = p.getBoundingClientRect();
|
|
265
|
+
if (pr.bottom < 0 || pr.top > innerHeight || pr.width < 40) return null;
|
|
266
|
+
const scrollable = /auto|scroll/.test(cs.overflowX) && p.scrollWidth > p.clientWidth + 2;
|
|
267
|
+
return { scrollable, cx: r.x + r.width / 2, row: { x: pr.x, y: pr.y, w: pr.width, h: pr.height } };
|
|
268
|
+
}
|
|
269
|
+
// 잘린 줄이 없는데 페이지 자체가 화면보다 넓다(모바일 배치가 넘친 사이트) — 사람은 페이지를 옆으로 끌어 본다
|
|
270
|
+
const vwNow = (window.visualViewport && visualViewport.width) || document.documentElement.clientWidth;
|
|
271
|
+
if (document.documentElement.scrollWidth > vwNow + 2) return { page: true, cx: r.x + r.width / 2 };
|
|
272
|
+
return null;
|
|
273
|
+
});
|
|
274
|
+
if (!info) return false;
|
|
275
|
+
if (info.page) {
|
|
276
|
+
await page.evaluate((dx) => window.scrollBy({ left: dx, behavior: "instant" }), info.cx - vw / 2);
|
|
277
|
+
await sleep(rand(300, 600));
|
|
278
|
+
const now = await el.boundingBox().catch(() => null);
|
|
279
|
+
return !!(now && now.x + now.width / 2 > 1 && now.x + now.width / 2 < vw - 1);
|
|
280
|
+
}
|
|
281
|
+
const target = vw / 2;
|
|
282
|
+
if (info.cx > 1 && info.cx < vw - 1 && tries > 0) return true;
|
|
283
|
+
const delta = info.cx - target;
|
|
284
|
+
if (info.scrollable) {
|
|
285
|
+
await el.evaluate((n, dx) => {
|
|
286
|
+
for (let p = n.parentElement; p && p !== document.body; p = p.parentElement) {
|
|
287
|
+
const cs = getComputedStyle(p);
|
|
288
|
+
if (/auto|scroll/.test(cs.overflowX) && p.scrollWidth > p.clientWidth + 2) return p.scrollBy({ left: dx, behavior: "instant" });
|
|
289
|
+
}
|
|
290
|
+
}, delta);
|
|
291
|
+
} else {
|
|
292
|
+
// 줄 가운데를 잡고 반대쪽으로 끈다. 한 번에 다 못 오면 다시 끈다
|
|
293
|
+
const y = Math.min(Math.max(info.row.y + info.row.h / 2, 5), (page.viewportSize() || { height: 800 }).height - 5);
|
|
294
|
+
const x0 = Math.min(Math.max(vw / 2 + Math.sign(delta) * vw * 0.3, 10), vw - 10);
|
|
295
|
+
const x1 = Math.min(Math.max(x0 - Math.sign(delta) * Math.min(Math.abs(delta) + 40, vw * 0.6), 5), vw - 5);
|
|
296
|
+
const before = await el.boundingBox().catch(() => null);
|
|
297
|
+
await moveTo(page, state, x0, y);
|
|
298
|
+
await page.mouse.down();
|
|
299
|
+
for (let i = 1; i <= 8; i++) {
|
|
300
|
+
await page.mouse.move(x0 + ((x1 - x0) * i) / 8, y + rand(-2, 2));
|
|
301
|
+
await sleep(rand(12, 30));
|
|
302
|
+
}
|
|
303
|
+
await page.mouse.up();
|
|
304
|
+
state.mouse = { x: x1, y };
|
|
305
|
+
await sleep(rand(350, 600));
|
|
306
|
+
// 휴대폰 화면에서는 배너가 손가락 밀기에만 반응한다 — 마우스로 안 움직였으면 터치로 민다
|
|
307
|
+
const after = await el.boundingBox().catch(() => null);
|
|
308
|
+
if (before && after && Math.abs(after.x - before.x) < 5) await touchSwipe(page, x0, x1, y).catch(() => {});
|
|
309
|
+
}
|
|
310
|
+
await sleep(rand(450, 800));
|
|
311
|
+
const now = await el.boundingBox().catch(() => null);
|
|
312
|
+
if (now && now.x + now.width / 2 > 1 && now.x + now.width / 2 < vw - 1) return true;
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 그 요소를 사람처럼 누른다.
|
|
319
|
+
* @returns { missed: boolean } missed 면 빈 곳을 눌러 아무 일도 안 일어난 것이다
|
|
320
|
+
*/
|
|
321
|
+
async function clickLikePerson(page, state, el) {
|
|
322
|
+
const startY = await page.evaluate(() => scrollY).catch(() => null);
|
|
323
|
+
// 위아래로만 굴린다. 사람은 휴대폰에서 화면을 옆으로 끌어 숨은 버튼을 찾지 않는다 — 옆으로 굴리면 화면 밖 것을 끌어와 눌렀다(10/15)
|
|
324
|
+
await el
|
|
325
|
+
.evaluate((n) => {
|
|
326
|
+
const r = n.getBoundingClientRect();
|
|
327
|
+
if (r.top < 0 || r.bottom > innerHeight) window.scrollBy(0, r.top - Math.max(40, innerHeight * 0.25));
|
|
328
|
+
})
|
|
329
|
+
.catch(() => {});
|
|
330
|
+
let aim = await aimAt(page, state, el);
|
|
331
|
+
if (!aim) {
|
|
332
|
+
// 화면 끝에 걸쳐 고정 막대(위 머리줄·아래 메뉴)에 가린 것은 사람이 조금 더 굴려서 누른다.
|
|
333
|
+
// 이걸 안 하고 "덮였다" 고 하면 모바일 아래 메뉴 밑 항목이 안 눌린다(10/14 bluetags p28, 0.1.12)
|
|
334
|
+
// 창 안(스크롤되는 칸)의 것이면 그 칸을 굴린다 — 페이지만 굴려서는 모바일 정보 창 맨 아래 Join 이
|
|
335
|
+
// 아래 메뉴 밑에서 안 나왔다(10/21 실험실 y2). scrollIntoView 는 감싼 칸들을 모두 굴린다
|
|
336
|
+
await el
|
|
337
|
+
.evaluate((n) => {
|
|
338
|
+
const before = n.getBoundingClientRect().top;
|
|
339
|
+
n.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" });
|
|
340
|
+
if (Math.abs(n.getBoundingClientRect().top - before) < 1) {
|
|
341
|
+
const r = n.getBoundingClientRect();
|
|
342
|
+
window.scrollBy(0, r.top + r.height / 2 - innerHeight / 2);
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
.catch(() => {});
|
|
346
|
+
await sleep(rand(150, 350));
|
|
347
|
+
aim = await aimAt(page, state, el);
|
|
348
|
+
}
|
|
349
|
+
if (!aim) {
|
|
350
|
+
// 다른 것이 위에 덮여 있으면 사람은 그게 눈에 보인다. 8초 기다려 "Timeout" 을 전하면
|
|
351
|
+
// 방문자는 화면이 멈췄다고 읽고 나간다(10/14 bluetags p4) — 무엇이 덮었는지 바로 말한다
|
|
352
|
+
const cover = await coveredBy(page, el);
|
|
353
|
+
if (cover && cover.invisible) {
|
|
354
|
+
// 투명한 막. 사람은 못 보니 그 자리를 그대로 누른다 — 클릭은 막에 닿고, 막이 닫히는 사이트가 많다.
|
|
355
|
+
// 안 누르고 "아무 일 없다" 고만 하면 막이 영영 안 닫혀 메뉴가 다 죽은 것처럼 된다(10/14 bluetags 달력 날짜 뒤, 0.1.12~13)
|
|
356
|
+
await moveTo(page, state, cover.x, cover.y);
|
|
357
|
+
await sleep(rand(40, 140));
|
|
358
|
+
await page.mouse.click(cover.x, cover.y);
|
|
359
|
+
// 막이 걷혔으면 클릭은 떠 있던 창을 닫은 것이다. 사람은 그걸 눈으로 안다 — 모르면 "링크가 한 번에 안 된다" 가 된다(10/14 실험실 p3)
|
|
360
|
+
// 닫히는 데 애니메이션이 붙은 창이 있다 — 0.4초에 한 번만 보면 놓쳤다(10/15 실험실 p15). 1.5초까지 본다
|
|
361
|
+
let gone = false;
|
|
362
|
+
for (let i = 0; i < 6 && !gone; i++) {
|
|
363
|
+
await sleep(250);
|
|
364
|
+
const still = await coveredBy(page, el).catch(() => null);
|
|
365
|
+
gone = !(still && still.invisible);
|
|
366
|
+
}
|
|
367
|
+
return { missed: false, closedOverlay: gone };
|
|
368
|
+
}
|
|
369
|
+
// 덮은 것을 못 찾아도 본다 — 틀 밖 자리를 찍으면 배너 틀 자신이 맞아 "안 덮였다" 가 되고, 끝에 Playwright 클릭이
|
|
370
|
+
// "무언가 위에 있다" 로 끝났다(10/23 실험실 P1, 20초 뒤 누르기)
|
|
371
|
+
if (!(cover && /survey card/.test(cover.say || "")) && (await outOfRow(el))) {
|
|
372
|
+
// 배너가 그 사이 넘어가 카드가 틀 밖으로 나갔다. 사람은 화면에 보이는 그 카드(복사본)를 누르거나 돌아오길 기다린다.
|
|
373
|
+
// 틀 밖 자리에 마침 사이드바가 있어 "사이드바가 덮었다" 로 끝났다 — PC 홈 배너가 처음 몇 초 안에 네 칸을 넘긴다(10/23 실험실 O2)
|
|
374
|
+
const vw = (page.viewportSize() || { width: 1200 }).width;
|
|
375
|
+
const twin = (await visibleTwin(page, el, vw)) || (await waitComeAround(page, el, vw));
|
|
376
|
+
const got = twin ? await pressAt(page, state, twin, await aimAt(page, state, twin)) : null;
|
|
377
|
+
if (got) return { missed: got.missed };
|
|
378
|
+
const say = "it slid out of sight as you went to tap it — that row of cards moved on by itself, and it is no longer showing";
|
|
379
|
+
const e = new Error(say);
|
|
380
|
+
e.human = say;
|
|
381
|
+
throw e;
|
|
382
|
+
}
|
|
383
|
+
if (cover) {
|
|
384
|
+
// 못 누른 채 화면만 굴러가 있으면 "안 됐다" 는데 화면이 바뀐다(10/21 실험실 q2) — 사람은 굴리지 않고 덮인 걸 본다
|
|
385
|
+
if (startY !== null) await page.evaluate((y) => window.scrollTo({ top: y, behavior: "instant" }), startY).catch(() => {});
|
|
386
|
+
const e = new Error(cover.say);
|
|
387
|
+
e.human = cover.say;
|
|
388
|
+
throw e;
|
|
389
|
+
}
|
|
390
|
+
// 화면 가장자리 밖으로 잘려 나간 것. 굴려도 옆으로는 안 온다 — 사람은 "잘려 있다" 를 본다.
|
|
391
|
+
// 이걸 Playwright 에 맡기면 "덮였다" 로 나왔다(10/15 실험실 p18: 모바일에서 공유 창 오른쪽 절반이 화면 밖)
|
|
392
|
+
let bb = await el.boundingBox().catch(() => null);
|
|
393
|
+
const vw = (page.viewportSize() || { width: 1200 }).width;
|
|
394
|
+
// 가로로 넘치는 줄(회전 배너·가로 목록) 안에서 잘린 것이면, 사람은 그 줄을 옆으로 밀어 가져온 뒤 누른다
|
|
395
|
+
// (10/15 실험실 p1·p3: 모바일 첫 화면 배너 끝에 걸친 카드를 끝내 못 눌렀다)
|
|
396
|
+
if (bb && (bb.x + bb.width / 2 > vw - 1 || bb.x + bb.width / 2 < 1)) {
|
|
397
|
+
const twin = await visibleTwin(page, el, vw);
|
|
398
|
+
if (twin) {
|
|
399
|
+
const got = await pressAt(page, state, twin, await aimAt(page, state, twin));
|
|
400
|
+
if (got) return { missed: got.missed };
|
|
401
|
+
}
|
|
402
|
+
// 저절로 넘어가는 줄이면 밀지 않고 돌아오길 기다린다. 넘어가는 줄을 밀면 반쯤은 엇나가 "잘렸다" 로 끝났다(10/22 실험실 k5)
|
|
403
|
+
const round = await waitComeAround(page, el, vw);
|
|
404
|
+
if (round) {
|
|
405
|
+
const came = await pressAt(page, state, round, await aimAt(page, state, round));
|
|
406
|
+
if (came) return { missed: came.missed };
|
|
407
|
+
}
|
|
408
|
+
await swipeIntoView(page, state, el, vw).catch(() => false);
|
|
409
|
+
// 무한 배너는 밀고 나면 잡은 것 대신 그 복사본이 화면에 와 있다 — 화면에 든 쪽을 누른다(10/22 실험실 k5)
|
|
410
|
+
bb = await el.boundingBox().catch(() => null);
|
|
411
|
+
const inView = bb && bb.x + bb.width / 2 > 1 && bb.x + bb.width / 2 < vw - 1;
|
|
412
|
+
const target = inView ? el : await visibleTwin(page, el, vw);
|
|
413
|
+
const done = target ? await pressAt(page, state, target, await aimAt(page, state, target)) : null;
|
|
414
|
+
if (done) return { missed: done.missed, swiped: true };
|
|
415
|
+
// 밀어도 못 가져왔다 — 저절로 넘어가는 줄이면 한 번 더 돌아오길 기다린다
|
|
416
|
+
const later = await waitComeAround(page, el, vw);
|
|
417
|
+
const came = later ? await pressAt(page, state, later, await aimAt(page, state, later)) : null;
|
|
418
|
+
if (came) return { missed: came.missed, swiped: true };
|
|
419
|
+
bb = await el.boundingBox().catch(() => null);
|
|
420
|
+
}
|
|
421
|
+
if (bb && (bb.x + bb.width / 2 > vw - 1 || bb.x + bb.width / 2 < 1)) {
|
|
422
|
+
// 보였던 카드가 배너가 넘어가며 나간 것이면 그렇게 말한다 — "화면 밖" 이면 방문자는 처음부터 못 본 줄 안다
|
|
423
|
+
const say = (await outOfRow(el))
|
|
424
|
+
? "it slid out of sight as you went to tap it — that row of cards moved on by itself, and it is no longer showing"
|
|
425
|
+
: bb.x < vw && bb.x + bb.width > 0
|
|
426
|
+
? "it is cut off at the edge of the screen — only a sliver of it shows, and you cannot reach it"
|
|
427
|
+
: "it is off the edge of the screen and you cannot reach it";
|
|
428
|
+
const e = new Error(say);
|
|
429
|
+
e.human = say;
|
|
430
|
+
throw e;
|
|
431
|
+
}
|
|
432
|
+
// 좌표를 포기한다
|
|
433
|
+
await el.click({ timeout: 8000 });
|
|
434
|
+
return { missed: false };
|
|
435
|
+
}
|
|
436
|
+
const done = await pressAt(page, state, el, aim);
|
|
437
|
+
if (done) return { missed: done.missed };
|
|
438
|
+
// 누르려는 사이 자꾸 옮겨 갔다 — 저절로 넘어가는 줄이다. 한 번 돌아오길 기다려 본다
|
|
439
|
+
const vw = (page.viewportSize() || { width: 1200 }).width;
|
|
440
|
+
const twin = await visibleTwin(page, el, vw);
|
|
441
|
+
if (twin) {
|
|
442
|
+
const got = await pressAt(page, state, twin, await aimAt(page, state, twin));
|
|
443
|
+
if (got) return { missed: got.missed };
|
|
444
|
+
}
|
|
445
|
+
const round = await waitComeAround(page, el, vw);
|
|
446
|
+
if (round) {
|
|
447
|
+
const came = await pressAt(page, state, round, await aimAt(page, state, round));
|
|
448
|
+
if (came) return { missed: came.missed };
|
|
449
|
+
}
|
|
450
|
+
const say = "it slid away as you went to tap it — that row moves by itself";
|
|
451
|
+
const e = new Error(say);
|
|
452
|
+
e.human = say;
|
|
453
|
+
throw e;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const NEAR = { a: "s", s: "d", d: "f", f: "g", g: "h", h: "j", j: "k", k: "l", l: "k", q: "w", w: "e", e: "r", r: "t", t: "y", y: "u", u: "i", i: "o", o: "p", p: "o", z: "x", x: "c", c: "v", v: "b", b: "n", n: "m", m: "n" };
|
|
457
|
+
|
|
458
|
+
/** 사람처럼 친다. 가끔 잘못 치고 알아채서 지운다. 칸에 남는 글자는 언제나 원래 치려던 것이다 */
|
|
459
|
+
async function typeLikePerson(page, state, text) {
|
|
460
|
+
if (!state.rhythm) {
|
|
461
|
+
await page.keyboard.type(text, { delay: rand(70, 160) });
|
|
462
|
+
return 0;
|
|
463
|
+
}
|
|
464
|
+
const h = state.rhythm.hand();
|
|
465
|
+
const wrongFor = (ch, i) => {
|
|
466
|
+
const low = ch.toLowerCase();
|
|
467
|
+
if (NEAR[low]) return ch === low ? NEAR[low] : NEAR[low].toUpperCase();
|
|
468
|
+
const pool = text.replace(new RegExp(ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "");
|
|
469
|
+
return pool ? pool[(i * 7 + 3) % pool.length] : "x";
|
|
470
|
+
};
|
|
471
|
+
let typos = 0;
|
|
472
|
+
for (let i = 0; i < text.length; i++) {
|
|
473
|
+
await sleep(state.rhythm.keyPause());
|
|
474
|
+
if (text.length > 2 && Math.random() < h.typoRate) {
|
|
475
|
+
typos++;
|
|
476
|
+
await page.keyboard.type(wrongFor(text[i], i));
|
|
477
|
+
let extra = 0;
|
|
478
|
+
if (Math.random() < 0.45 && i + 1 < text.length) {
|
|
479
|
+
await sleep(state.rhythm.keyPause());
|
|
480
|
+
await page.keyboard.type(text[i + 1]);
|
|
481
|
+
extra = 1;
|
|
482
|
+
}
|
|
483
|
+
await sleep(180 + Math.random() * 420); // 어? 하는 순간
|
|
484
|
+
for (let k = 0; k <= extra; k++) {
|
|
485
|
+
await page.keyboard.press("Backspace");
|
|
486
|
+
await sleep(60 + Math.random() * 120);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
await page.keyboard.type(text[i]);
|
|
490
|
+
}
|
|
491
|
+
return typos;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** 스크롤도 한 번에 정확히 가지 않는다. 지나쳤다 조금 되돌아오기도 한다 */
|
|
495
|
+
async function scrollLikePerson(page, state, dir = 1) {
|
|
496
|
+
const h = state.rhythm ? state.rhythm.hand() : { stride: 110 };
|
|
497
|
+
const total = (300 + Math.random() * 700) * dir;
|
|
498
|
+
const chunks = 2 + Math.round(Math.random() * 2);
|
|
499
|
+
const y0 = await page.evaluate(() => scrollY).catch(() => null);
|
|
500
|
+
// 휴대폰에서는 화면 가운데를 엄지로 민다. 커서가 처음 자리(0,0)에 있으면 휠이 그 자리의 것을 굴린다 —
|
|
501
|
+
// 화면을 덮은 정보 창 대신 그 뒤 페이지가 굴러, 창 맨 아래 Join 이 끝내 아래 메뉴 밑에 있었다(10/21 실험실 r1).
|
|
502
|
+
// 데스크톱도 커서가 아직 한 번도 안 움직였으면 본문 쪽으로 옮겨 굴린다
|
|
503
|
+
const touch = await page.evaluate(() => matchMedia("(pointer: coarse)").matches).catch(() => false);
|
|
504
|
+
const vp0 = page.viewportSize() || { width: 1200, height: 800 };
|
|
505
|
+
// 흐린 막 위에 창이 떠 있으면 사람은 그 창 위에서 굴린다 — 주소로 바로 열린 데스크톱 정보 창은 뒤 페이지가
|
|
506
|
+
// 안 잠겨 휠이 뒤 목록을 굴렸고, 창 맨 아래 Join 을 끝내 못 봤다(10/21 실험실 u4)
|
|
507
|
+
const panel = await page
|
|
508
|
+
.evaluate(() => {
|
|
509
|
+
const VW = document.documentElement.clientWidth || innerWidth;
|
|
510
|
+
const fixedCtx = (n) => {
|
|
511
|
+
for (let p = n; p && p !== document.body; p = p.parentElement) if (getComputedStyle(p).position === "fixed") return true;
|
|
512
|
+
return false;
|
|
513
|
+
};
|
|
514
|
+
// 화면에 붙은 큰 판 — 흐린 막(데스크톱 정보 창 뒤)이든 화면을 덮은 불투명한 창(모바일 정보 창)이든
|
|
515
|
+
let overlay = false;
|
|
516
|
+
for (const n of document.body.querySelectorAll("*")) {
|
|
517
|
+
const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
|
|
518
|
+
const a = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
|
|
519
|
+
if (!a) continue;
|
|
520
|
+
const r = n.getBoundingClientRect();
|
|
521
|
+
if ((a < 0.9 ? r.width >= VW * 0.5 : r.width >= VW * 0.9) && r.height >= innerHeight * 0.6 && fixedCtx(n)) {
|
|
522
|
+
overlay = true;
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!overlay) return null;
|
|
527
|
+
let best = null;
|
|
528
|
+
let area = 0;
|
|
529
|
+
for (const n of document.body.querySelectorAll("*")) {
|
|
530
|
+
if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100 || !/auto|scroll/.test(getComputedStyle(n).overflowY) || !fixedCtx(n)) continue;
|
|
531
|
+
const r = n.getBoundingClientRect();
|
|
532
|
+
const cx = r.left + r.width / 2;
|
|
533
|
+
const cy = Math.min(r.top + r.height / 2, innerHeight - 5);
|
|
534
|
+
const h = document.elementFromPoint(cx, cy);
|
|
535
|
+
if (!h || !(n === h || n.contains(h))) continue; // 위에 떠 있는 칸만
|
|
536
|
+
const a = r.width * r.height;
|
|
537
|
+
if (a > area) (area = a), (best = { x: cx, y: cy });
|
|
538
|
+
}
|
|
539
|
+
return best;
|
|
540
|
+
})
|
|
541
|
+
.catch(() => null);
|
|
542
|
+
if (panel) await moveTo(page, state, panel.x + rand(-30, 30), panel.y + rand(-40, 40));
|
|
543
|
+
else if (touch || !state.mouse || (state.mouse.x < 5 && state.mouse.y < 5)) {
|
|
544
|
+
await moveTo(page, state, vp0.width * rand(0.4, 0.6), vp0.height * rand(0.4, 0.6));
|
|
545
|
+
}
|
|
546
|
+
for (let i = 0; i < chunks; i++) {
|
|
547
|
+
await page.mouse.wheel(0, total / chunks);
|
|
548
|
+
await sleep(rand(80, 260));
|
|
549
|
+
// 커서가 메뉴·고정 막대 위에 있으면 휠이 페이지를 안 굴린다. 사람은 커서를 본문 쪽으로 옮기고 다시 굴린다
|
|
550
|
+
// (10/13: 약관 링크를 누른 뒤 세 번 굴려도 0px)
|
|
551
|
+
if (i === 0 && y0 !== null && (await page.evaluate(() => scrollY).catch(() => null)) === y0) {
|
|
552
|
+
// 페이지가 잠겼고 화면 한쪽 칸만 굴러가면(데스크톱 정보 창) 사람은 그 칸 위로 커서를 옮긴다(10/21 실험실 r4)
|
|
553
|
+
const pane = await page
|
|
554
|
+
.evaluate(() => {
|
|
555
|
+
// 페이지가 그냥 끝에 닿은 것이면 안쪽 칸을 찾지 않는다
|
|
556
|
+
if (document.documentElement.scrollHeight > innerHeight + 2 && getComputedStyle(document.body).overflowY !== "hidden") return null;
|
|
557
|
+
let best = null;
|
|
558
|
+
let area = 0;
|
|
559
|
+
for (const n of document.body.querySelectorAll("*")) {
|
|
560
|
+
if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100 || n.scrollTop + n.clientHeight >= n.scrollHeight - 2) continue;
|
|
561
|
+
if (!/auto|scroll/.test(getComputedStyle(n).overflowY)) continue;
|
|
562
|
+
const r = n.getBoundingClientRect();
|
|
563
|
+
const a = Math.max(0, Math.min(r.right, innerWidth) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, innerHeight) - Math.max(r.top, 0));
|
|
564
|
+
if (a > area) (area = a), (best = { x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
|
565
|
+
}
|
|
566
|
+
return best;
|
|
567
|
+
})
|
|
568
|
+
.catch(() => null);
|
|
569
|
+
const vp = page.viewportSize() || { width: 1200, height: 800 };
|
|
570
|
+
if (pane) await moveTo(page, state, pane.x + rand(-30, 30), pane.y + rand(-40, 40));
|
|
571
|
+
else await moveTo(page, state, vp.width * rand(0.45, 0.6), vp.height * rand(0.45, 0.65));
|
|
572
|
+
await page.mouse.wheel(0, total / chunks);
|
|
573
|
+
await sleep(rand(80, 260));
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// 가끔 조금 되돌린다(지나쳤다 싶어서). 실제로 움직였고 끝에 닿지 않았을 때만 —
|
|
577
|
+
// 끝에서 되돌리면 "내렸는데 덜 내려가고", 맨 위에서 위로 굴리면 오히려 내려갔다(10/14 실험실)
|
|
578
|
+
const pos = async () => page.evaluate(() => ({ y: scrollY, max: document.documentElement.scrollHeight - innerHeight })).catch(() => null);
|
|
579
|
+
const after = await pos();
|
|
580
|
+
const moved = after && y0 !== null ? after.y - y0 : 0;
|
|
581
|
+
const atEnd = after && (dir > 0 ? after.y >= after.max - 2 : after.y <= 2);
|
|
582
|
+
// 창 안을 굴렸으면 되돌리지 않는다 — 뒤 페이지가 움직인 만큼 되돌리다 창 안이 맨 위로 돌아가,
|
|
583
|
+
// 창 맨 아래 Join 이 아래 메뉴 밑에 다시 갇혔다(10/21 실험실 x4)
|
|
584
|
+
if (!panel && Math.sign(moved) === Math.sign(dir) && Math.abs(moved) > 120 && !atEnd && Math.random() < 0.25) {
|
|
585
|
+
await sleep(rand(150, 500));
|
|
586
|
+
await page.mouse.wheel(0, -moved * (0.15 + Math.random() * 0.2));
|
|
587
|
+
}
|
|
588
|
+
const end = await pos();
|
|
589
|
+
return end && y0 !== null ? Math.round(end.y - y0) : Math.round(total);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
module.exports = { moveTo, aimAt, clickLikePerson, typeLikePerson, scrollLikePerson };
|