@usercase/mcp 0.2.2-next.2 → 0.2.2-next.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/hub.js +38 -4
- package/mcp/server.js +10 -3
- package/package.json +1 -1
- package/src/browser/hand.js +87 -5
- package/src/browser/page.js +230 -15
- package/src/run/visit.js +42 -9
package/mcp/hub.js
CHANGED
|
@@ -14,6 +14,7 @@ const persona = require("../src/persona/persona.js");
|
|
|
14
14
|
const { personKey } = require("../src/persona/people.js");
|
|
15
15
|
const entry = require("../src/run/entry.js");
|
|
16
16
|
const { Visit, renderScreen, renderPerson } = require("../src/run/visit.js");
|
|
17
|
+
const { iconPicture } = require("../src/browser/page.js");
|
|
17
18
|
const { createGuard } = require("../src/browser/guard.js");
|
|
18
19
|
const policyMod = require("../src/browser/policy.js");
|
|
19
20
|
const { loadRobots } = require("../src/browser/robots.js");
|
|
@@ -69,6 +70,7 @@ class Hub {
|
|
|
69
70
|
this.headed = process.env.USERCASE_HEADED === "1";
|
|
70
71
|
this.visits = new Map();
|
|
71
72
|
this.sites = new Map(); // origin → { guard, blockWatch, links }
|
|
73
|
+
this.pictures = new Map(); // visit_id → 마지막 화면의 이름 없는 아이콘 그림(PNG) 또는 null — takePicture 로 한 번 가져간다
|
|
72
74
|
this.browserP = null;
|
|
73
75
|
this.seq = 0;
|
|
74
76
|
// 시작할 때 비밀값을 한 번 검사한다 — 잘못된 값이 방문 도중 조용히 넘어가지 않게
|
|
@@ -399,12 +401,35 @@ class Hub {
|
|
|
399
401
|
const look = await v.look();
|
|
400
402
|
if (look.ended) return "This visit has ended.";
|
|
401
403
|
if (look.card) return renderCard(look.card);
|
|
402
|
-
const lines = [
|
|
404
|
+
const lines = [];
|
|
405
|
+
// 굴리지 않았는데 맨 위로 돌아갔다(창이 닫히며 주소가 바뀐 사이트). 사람은 화면이 튀는 걸 본다(10/21 실험실 q3)
|
|
406
|
+
// 다른 페이지로 간 것은 원래 맨 위에서 시작한다 — 같은 페이지(? 뒤만 바뀐 것 포함)일 때만
|
|
407
|
+
const y = look.view.scrollY;
|
|
408
|
+
const at = look.view.path.split("?")[0];
|
|
409
|
+
// 창이 뜨거나 닫히며 위치 기준(페이지 ↔ 창)이 바뀐 것은 튄 게 아니다(10/21 실험실 r3)
|
|
410
|
+
// 굴러가는 칸 없는 작은 창(달력 날짜 창)도 — 창이 뜨면 페이지가 잠겨 위치가 0 이 된다(10/21 실험실 j4)
|
|
411
|
+
const cover = !!(look.view.cover || look.view.inPanel || look.view.panelOpen);
|
|
412
|
+
if (rec.lastY > 300 && y < 40 && at === rec.lastAt && cover === rec.lastCover && !/^scroll/.test(rec.lastAction || "")) lines.push("(The page jumped back to the top.)", "");
|
|
413
|
+
rec.lastY = y;
|
|
414
|
+
rec.lastAt = at;
|
|
415
|
+
rec.lastCover = cover;
|
|
416
|
+
lines.push(renderScreen(look));
|
|
417
|
+
// 이름 없는 아이콘은 그림으로 같이 준다 — 좌표만으로는 사진 첨부와 보내기를 못 갈랐다(10/21 bluetags 2b72)
|
|
418
|
+
const pic = await iconPicture(v.page, look.view.items, await this.browser().catch(() => null)).catch(() => null);
|
|
419
|
+
this.pictures.set(rec.id, pic);
|
|
420
|
+
if (pic) lines.push("(A picture of the unlabelled icons on this screen is attached — each is marked with its number from the list above. Look at their shapes the way a person would.)");
|
|
403
421
|
// 인내심이 다 됐다는 단정이 아니라 조건이다. "볼 만큼 봤다" 고 하면 방문자가 그 자리에서 나갔다 — 진전이 있으면 이어 간다(10/14)
|
|
404
422
|
if (v.patienceSpent) lines.push("", "(Your patience is running low. Unless what is in front of you is getting you closer to what you came for, this is about where you would give up.)");
|
|
405
423
|
return lines.join("\n");
|
|
406
424
|
}
|
|
407
425
|
|
|
426
|
+
/** 마지막 화면에 붙일 아이콘 그림. 한 번 가져가면 지운다(다음 화면은 새로 만든다) */
|
|
427
|
+
takePicture(id) {
|
|
428
|
+
const pic = this.pictures.get(id) || null;
|
|
429
|
+
this.pictures.delete(id);
|
|
430
|
+
return pic;
|
|
431
|
+
}
|
|
432
|
+
|
|
408
433
|
get(id) {
|
|
409
434
|
const rec = this.visits.get(id);
|
|
410
435
|
if (!rec) throw new Error(`No visit ${id}`);
|
|
@@ -417,12 +442,16 @@ class Hub {
|
|
|
417
442
|
async act(id, decision) {
|
|
418
443
|
const rec = this.get(id);
|
|
419
444
|
const v = rec.visit;
|
|
445
|
+
rec.lastAction = decision.action;
|
|
420
446
|
const r = await v.act(decision);
|
|
421
447
|
if (r.interrupted) return ["Before you could do that, a survey card appeared.", "", await this.screen(rec)].join("\n");
|
|
422
448
|
const said = [];
|
|
423
449
|
if (r.offsite) said.push(`That leads to another website (${r.offsite}). It did not open here — you are still on this site. If going there is what you would do, that means leaving this site.`);
|
|
424
450
|
if (r.skipped) said.push(`That did not happen: ${r.skipped}.`);
|
|
425
|
-
|
|
451
|
+
// 로그인·가입 요청(구글 로그인 버튼 등)은 "된 것처럼 이어 가라" 가 안 된다 — 다른 계정 창으로 갈 수 없으니 갈 곳을 몰랐다(10/21 실험실 i1)
|
|
452
|
+
if (r.notSent && r.notSent.every((k) => /\/(auth|signin|sign-in|login|oauth|signup|sign-up|register)\b/i.test(k)))
|
|
453
|
+
said.push("That would have started signing in or signing up. On this visit you do not sign in — you stay as you are, on this page. Carry on as this person would without an account, or leave.");
|
|
454
|
+
else if (r.notSent) said.push("That would have sent something to the site (saving, posting or signing up). In this run nothing is sent to the site, so it was not saved. Whatever the screen shows next (an error, or no change) comes from that, not from the site — carry on as you would have if it had worked.");
|
|
426
455
|
if (r.external) said.push(`Part of this page needs another server (${r.external.join(", ")}), which this test run of the site on your computer does not reach. Whatever depends on it (live chat, sending, live prices) cannot finish here — that is the test setup, not the site. Carry on as you would have if it had worked.`);
|
|
427
456
|
if (r.heldEnter) said.push("The screen changed as you typed, so you did not press Enter — what you see is what typing alone did. If you would press Enter now, type the same text into that field again.");
|
|
428
457
|
if (r.fileWindow) said.push(r.fileWindow);
|
|
@@ -433,13 +462,18 @@ class Hub {
|
|
|
433
462
|
if (r.closedOverlay) said.push("Something was open on top of the page; your click landed outside it and closed it, so it did not reach what you clicked.");
|
|
434
463
|
if (r.cappedTimes) said.push(`You did that ${r.cappedTimes} times in a row — that is the most in one go. What you see is after ${r.cappedTimes}; to go on, do it again.`);
|
|
435
464
|
if (r.missed) said.push("Your click landed just outside it and nothing happened.");
|
|
436
|
-
|
|
465
|
+
// 손가락이 잘못 누른 키를 지우고 다시 친 것 — 방문자가 일부러 쓴 철자는 그대로다. "오타를 고쳤다" 만 쓰면
|
|
466
|
+
// 제 글의 철자가 바뀐 줄 알았다(10/21 실험실 m1·m3)
|
|
467
|
+
if (r.typos) said.push(`(While typing you hit ${r.typos} wrong key(s) and corrected them as you went — the text is what you meant to write.)`);
|
|
437
468
|
if (v.ended) {
|
|
438
469
|
if (v.pending) return [...said, "As you were leaving, a survey card appeared.", "", renderCard(v.pending)].join("\n");
|
|
439
470
|
const sum = await this.finish(rec);
|
|
440
471
|
return [...said, "You left the site. This visit is over.", "", sum].join("\n");
|
|
441
472
|
}
|
|
442
|
-
|
|
473
|
+
const shown = await this.screen(rec);
|
|
474
|
+
// 하는 사이에 카드가 떴다. 손가락이 카드에 먼저 닿았을 수 있다 — 말 없이 카드만 주면 클릭이 왜 안 됐는지 모른다(10/21 실험실 t3)
|
|
475
|
+
if (v.pending && !/^(read|leave)$/.test(decision.action)) said.push("A survey card popped up just as you did that — once you have dealt with it, check whether what you did went through.");
|
|
476
|
+
return [...said, ...(said.length ? [""] : []), shown].join("\n");
|
|
443
477
|
}
|
|
444
478
|
|
|
445
479
|
/** 카드에 답하거나 닫는다 */
|
package/mcp/server.js
CHANGED
|
@@ -29,6 +29,13 @@ const hub = new Hub();
|
|
|
29
29
|
const server = new McpServer({ name: "usercase", version: require("../package.json").version });
|
|
30
30
|
|
|
31
31
|
const text = (t) => ({ content: [{ type: "text", text: String(t) }] });
|
|
32
|
+
/** 방문 화면 + 그 화면의 이름 없는 아이콘 그림(있으면). 방문자는 그림을 보고 모양으로 안다 */
|
|
33
|
+
const screen = (t, visitId) => {
|
|
34
|
+
const pic = visitId ? hub.takePicture(visitId) : null;
|
|
35
|
+
const out = text(t);
|
|
36
|
+
if (pic) out.content.push({ type: "image", data: pic.toString("base64"), mimeType: "image/png" });
|
|
37
|
+
return out;
|
|
38
|
+
};
|
|
32
39
|
const fail = (e) => ({ content: [{ type: "text", text: `Error: ${e.message}` }], isError: true });
|
|
33
40
|
const wrap = (fn) => async (args) => {
|
|
34
41
|
try {
|
|
@@ -562,7 +569,7 @@ server.registerTool(
|
|
|
562
569
|
},
|
|
563
570
|
wrap(async ({ person }) => {
|
|
564
571
|
const r = await hub.open(person);
|
|
565
|
-
return text(r.text);
|
|
572
|
+
return screen(r.text, (String(r.text).match(/visit_id: (v\d+)/) || [])[1]);
|
|
566
573
|
}),
|
|
567
574
|
);
|
|
568
575
|
|
|
@@ -581,7 +588,7 @@ server.registerTool(
|
|
|
581
588
|
times: z.number().int().min(1).max(5).optional().describe("For scroll, scroll_up or click: how many times in a row, quickly (scrolling down while skimming, clicking again because nothing happened). Default 1"),
|
|
582
589
|
},
|
|
583
590
|
},
|
|
584
|
-
wrap(async ({ visit_id, action, index, text: typed = "", why, done = false, times = 1 }) =>
|
|
591
|
+
wrap(async ({ visit_id, action, index, text: typed = "", why, done = false, times = 1 }) => screen(await hub.act(visit_id, { action, index, text: typed, why, done, times }), visit_id)),
|
|
585
592
|
);
|
|
586
593
|
|
|
587
594
|
server.registerTool(
|
|
@@ -600,7 +607,7 @@ server.registerTool(
|
|
|
600
607
|
},
|
|
601
608
|
},
|
|
602
609
|
wrap(async ({ visit_id, bothered, bothered_why, action, choice_index, other_text = "", why }) =>
|
|
603
|
-
|
|
610
|
+
screen(await hub.answer(visit_id, { bothered, botheredWhy: bothered_why, action, choiceIndex: choice_index, otherText: other_text, why }), visit_id),
|
|
604
611
|
),
|
|
605
612
|
);
|
|
606
613
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usercase/mcp",
|
|
3
|
-
"version": "0.2.2-next.
|
|
3
|
+
"version": "0.2.2-next.4",
|
|
4
4
|
"description": "Virtual users that browse your website and tell you what they thought — an MCP server for Claude Code",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://usercase.io",
|
package/src/browser/hand.js
CHANGED
|
@@ -92,7 +92,14 @@ async function aimAt(page, state, el) {
|
|
|
92
92
|
const py = Math.min(Math.max(y, box.y + 2), box.y + box.height - 2);
|
|
93
93
|
if ((await at(px, py)) === "대상") return { x: px, y: py, missed: false };
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
if ((await at(cx, cy)) === "대상") return { x: cx, y: cy, missed: false };
|
|
96
|
+
// 가운데만 가렸으면 사람은 보이는 쪽을 누른다 — 화면에 붙은 창의 Join 버튼 아래쪽이 아래 메뉴에 깔려
|
|
97
|
+
// 굴려도 안 비켜서 "Watchlist 가 덮었다" 가 됐다(10/21 실험실 q2). 가운데에서 가까운 점부터 본다
|
|
98
|
+
const pts = [];
|
|
99
|
+
for (const fy of [0.2, 0.35, 0.5, 0.65, 0.8]) for (const fx of [0.5, 0.3, 0.7, 0.15, 0.85]) pts.push([box.x + box.width * fx, box.y + box.height * fy]);
|
|
100
|
+
pts.sort((a, b) => Math.hypot(a[0] - cx, a[1] - cy) - Math.hypot(b[0] - cx, b[1] - cy));
|
|
101
|
+
for (const [px, py] of pts) if ((await at(px, py)) === "대상") return { x: px, y: py, missed: false };
|
|
102
|
+
return null;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
/**
|
|
@@ -215,6 +222,7 @@ async function swipeIntoView(page, state, el, vw) {
|
|
|
215
222
|
* @returns { missed: boolean } missed 면 빈 곳을 눌러 아무 일도 안 일어난 것이다
|
|
216
223
|
*/
|
|
217
224
|
async function clickLikePerson(page, state, el) {
|
|
225
|
+
const startY = await page.evaluate(() => scrollY).catch(() => null);
|
|
218
226
|
// 위아래로만 굴린다. 사람은 휴대폰에서 화면을 옆으로 끌어 숨은 버튼을 찾지 않는다 — 옆으로 굴리면 화면 밖 것을 끌어와 눌렀다(10/15)
|
|
219
227
|
await el
|
|
220
228
|
.evaluate((n) => {
|
|
@@ -226,10 +234,16 @@ async function clickLikePerson(page, state, el) {
|
|
|
226
234
|
if (!aim) {
|
|
227
235
|
// 화면 끝에 걸쳐 고정 막대(위 머리줄·아래 메뉴)에 가린 것은 사람이 조금 더 굴려서 누른다.
|
|
228
236
|
// 이걸 안 하고 "덮였다" 고 하면 모바일 아래 메뉴 밑 항목이 안 눌린다(10/14 bluetags p28, 0.1.12)
|
|
237
|
+
// 창 안(스크롤되는 칸)의 것이면 그 칸을 굴린다 — 페이지만 굴려서는 모바일 정보 창 맨 아래 Join 이
|
|
238
|
+
// 아래 메뉴 밑에서 안 나왔다(10/21 실험실 y2). scrollIntoView 는 감싼 칸들을 모두 굴린다
|
|
229
239
|
await el
|
|
230
240
|
.evaluate((n) => {
|
|
231
|
-
const
|
|
232
|
-
|
|
241
|
+
const before = n.getBoundingClientRect().top;
|
|
242
|
+
n.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" });
|
|
243
|
+
if (Math.abs(n.getBoundingClientRect().top - before) < 1) {
|
|
244
|
+
const r = n.getBoundingClientRect();
|
|
245
|
+
window.scrollBy(0, r.top + r.height / 2 - innerHeight / 2);
|
|
246
|
+
}
|
|
233
247
|
})
|
|
234
248
|
.catch(() => {});
|
|
235
249
|
await sleep(rand(150, 350));
|
|
@@ -256,6 +270,8 @@ async function clickLikePerson(page, state, el) {
|
|
|
256
270
|
return { missed: false, closedOverlay: gone };
|
|
257
271
|
}
|
|
258
272
|
if (cover) {
|
|
273
|
+
// 못 누른 채 화면만 굴러가 있으면 "안 됐다" 는데 화면이 바뀐다(10/21 실험실 q2) — 사람은 굴리지 않고 덮인 걸 본다
|
|
274
|
+
if (startY !== null) await page.evaluate((y) => window.scrollTo({ top: y, behavior: "instant" }), startY).catch(() => {});
|
|
259
275
|
const e = new Error(cover.say);
|
|
260
276
|
e.human = cover.say;
|
|
261
277
|
throw e;
|
|
@@ -339,14 +355,78 @@ async function scrollLikePerson(page, state, dir = 1) {
|
|
|
339
355
|
const total = (300 + Math.random() * 700) * dir;
|
|
340
356
|
const chunks = 2 + Math.round(Math.random() * 2);
|
|
341
357
|
const y0 = await page.evaluate(() => scrollY).catch(() => null);
|
|
358
|
+
// 휴대폰에서는 화면 가운데를 엄지로 민다. 커서가 처음 자리(0,0)에 있으면 휠이 그 자리의 것을 굴린다 —
|
|
359
|
+
// 화면을 덮은 정보 창 대신 그 뒤 페이지가 굴러, 창 맨 아래 Join 이 끝내 아래 메뉴 밑에 있었다(10/21 실험실 r1).
|
|
360
|
+
// 데스크톱도 커서가 아직 한 번도 안 움직였으면 본문 쪽으로 옮겨 굴린다
|
|
361
|
+
const touch = await page.evaluate(() => matchMedia("(pointer: coarse)").matches).catch(() => false);
|
|
362
|
+
const vp0 = page.viewportSize() || { width: 1200, height: 800 };
|
|
363
|
+
// 흐린 막 위에 창이 떠 있으면 사람은 그 창 위에서 굴린다 — 주소로 바로 열린 데스크톱 정보 창은 뒤 페이지가
|
|
364
|
+
// 안 잠겨 휠이 뒤 목록을 굴렸고, 창 맨 아래 Join 을 끝내 못 봤다(10/21 실험실 u4)
|
|
365
|
+
const panel = await page
|
|
366
|
+
.evaluate(() => {
|
|
367
|
+
const VW = document.documentElement.clientWidth || innerWidth;
|
|
368
|
+
const fixedCtx = (n) => {
|
|
369
|
+
for (let p = n; p && p !== document.body; p = p.parentElement) if (getComputedStyle(p).position === "fixed") return true;
|
|
370
|
+
return false;
|
|
371
|
+
};
|
|
372
|
+
// 화면에 붙은 큰 판 — 흐린 막(데스크톱 정보 창 뒤)이든 화면을 덮은 불투명한 창(모바일 정보 창)이든
|
|
373
|
+
let overlay = false;
|
|
374
|
+
for (const n of document.body.querySelectorAll("*")) {
|
|
375
|
+
const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
|
|
376
|
+
const a = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
|
|
377
|
+
if (!a) continue;
|
|
378
|
+
const r = n.getBoundingClientRect();
|
|
379
|
+
if ((a < 0.9 ? r.width >= VW * 0.5 : r.width >= VW * 0.9) && r.height >= innerHeight * 0.6 && fixedCtx(n)) {
|
|
380
|
+
overlay = true;
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (!overlay) return null;
|
|
385
|
+
let best = null;
|
|
386
|
+
let area = 0;
|
|
387
|
+
for (const n of document.body.querySelectorAll("*")) {
|
|
388
|
+
if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100 || !/auto|scroll/.test(getComputedStyle(n).overflowY) || !fixedCtx(n)) continue;
|
|
389
|
+
const r = n.getBoundingClientRect();
|
|
390
|
+
const cx = r.left + r.width / 2;
|
|
391
|
+
const cy = Math.min(r.top + r.height / 2, innerHeight - 5);
|
|
392
|
+
const h = document.elementFromPoint(cx, cy);
|
|
393
|
+
if (!h || !(n === h || n.contains(h))) continue; // 위에 떠 있는 칸만
|
|
394
|
+
const a = r.width * r.height;
|
|
395
|
+
if (a > area) (area = a), (best = { x: cx, y: cy });
|
|
396
|
+
}
|
|
397
|
+
return best;
|
|
398
|
+
})
|
|
399
|
+
.catch(() => null);
|
|
400
|
+
if (panel) await moveTo(page, state, panel.x + rand(-30, 30), panel.y + rand(-40, 40));
|
|
401
|
+
else if (touch || !state.mouse || (state.mouse.x < 5 && state.mouse.y < 5)) {
|
|
402
|
+
await moveTo(page, state, vp0.width * rand(0.4, 0.6), vp0.height * rand(0.4, 0.6));
|
|
403
|
+
}
|
|
342
404
|
for (let i = 0; i < chunks; i++) {
|
|
343
405
|
await page.mouse.wheel(0, total / chunks);
|
|
344
406
|
await sleep(rand(80, 260));
|
|
345
407
|
// 커서가 메뉴·고정 막대 위에 있으면 휠이 페이지를 안 굴린다. 사람은 커서를 본문 쪽으로 옮기고 다시 굴린다
|
|
346
408
|
// (10/13: 약관 링크를 누른 뒤 세 번 굴려도 0px)
|
|
347
409
|
if (i === 0 && y0 !== null && (await page.evaluate(() => scrollY).catch(() => null)) === y0) {
|
|
410
|
+
// 페이지가 잠겼고 화면 한쪽 칸만 굴러가면(데스크톱 정보 창) 사람은 그 칸 위로 커서를 옮긴다(10/21 실험실 r4)
|
|
411
|
+
const pane = await page
|
|
412
|
+
.evaluate(() => {
|
|
413
|
+
// 페이지가 그냥 끝에 닿은 것이면 안쪽 칸을 찾지 않는다
|
|
414
|
+
if (document.documentElement.scrollHeight > innerHeight + 2 && getComputedStyle(document.body).overflowY !== "hidden") return null;
|
|
415
|
+
let best = null;
|
|
416
|
+
let area = 0;
|
|
417
|
+
for (const n of document.body.querySelectorAll("*")) {
|
|
418
|
+
if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100 || n.scrollTop + n.clientHeight >= n.scrollHeight - 2) continue;
|
|
419
|
+
if (!/auto|scroll/.test(getComputedStyle(n).overflowY)) continue;
|
|
420
|
+
const r = n.getBoundingClientRect();
|
|
421
|
+
const a = Math.max(0, Math.min(r.right, innerWidth) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, innerHeight) - Math.max(r.top, 0));
|
|
422
|
+
if (a > area) (area = a), (best = { x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
|
423
|
+
}
|
|
424
|
+
return best;
|
|
425
|
+
})
|
|
426
|
+
.catch(() => null);
|
|
348
427
|
const vp = page.viewportSize() || { width: 1200, height: 800 };
|
|
349
|
-
await moveTo(page, state,
|
|
428
|
+
if (pane) await moveTo(page, state, pane.x + rand(-30, 30), pane.y + rand(-40, 40));
|
|
429
|
+
else await moveTo(page, state, vp.width * rand(0.45, 0.6), vp.height * rand(0.45, 0.65));
|
|
350
430
|
await page.mouse.wheel(0, total / chunks);
|
|
351
431
|
await sleep(rand(80, 260));
|
|
352
432
|
}
|
|
@@ -357,7 +437,9 @@ async function scrollLikePerson(page, state, dir = 1) {
|
|
|
357
437
|
const after = await pos();
|
|
358
438
|
const moved = after && y0 !== null ? after.y - y0 : 0;
|
|
359
439
|
const atEnd = after && (dir > 0 ? after.y >= after.max - 2 : after.y <= 2);
|
|
360
|
-
|
|
440
|
+
// 창 안을 굴렸으면 되돌리지 않는다 — 뒤 페이지가 움직인 만큼 되돌리다 창 안이 맨 위로 돌아가,
|
|
441
|
+
// 창 맨 아래 Join 이 아래 메뉴 밑에 다시 갇혔다(10/21 실험실 x4)
|
|
442
|
+
if (!panel && Math.sign(moved) === Math.sign(dir) && Math.abs(moved) > 120 && !atEnd && Math.random() < 0.25) {
|
|
361
443
|
await sleep(rand(150, 500));
|
|
362
444
|
await page.mouse.wheel(0, -moved * (0.15 + Math.random() * 0.2));
|
|
363
445
|
}
|
package/src/browser/page.js
CHANGED
|
@@ -58,6 +58,15 @@ async function listControls(page, max = 60) {
|
|
|
58
58
|
}
|
|
59
59
|
return op <= 0.45;
|
|
60
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* 화면 끝에 몇 px 만 걸친 것은 사람 눈에 아직 없다 — 창 맨 아래 Join 이 4px 걸쳐 "화면 안" 으로 나오고
|
|
63
|
+
* 화면 글에는 없어 서로 어긋났다(10/21 실험실 r2·s1). 반 넘게(긴 것은 16px 넘게) 보여야 화면 안이다
|
|
64
|
+
*/
|
|
65
|
+
const whereOf = (r) => {
|
|
66
|
+
const shown = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0);
|
|
67
|
+
if (shown >= Math.min(r.height / 2, 16) && r.right > 0 && r.left < VW) return "in view";
|
|
68
|
+
return (r.top + r.bottom) / 2 >= innerHeight / 2 ? "below" : "above";
|
|
69
|
+
};
|
|
61
70
|
const nameOf = (el) =>
|
|
62
71
|
(el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || el.getAttribute("placeholder") || "")
|
|
63
72
|
.replace(/\s+/g, " ")
|
|
@@ -78,8 +87,8 @@ async function listControls(page, max = 60) {
|
|
|
78
87
|
const key = tag + "|" + label + "|" + (href || `@${Math.round(r.left)},${Math.round(r.top + scrollY)}`);
|
|
79
88
|
if (seen.has(key)) continue;
|
|
80
89
|
seen.add(key);
|
|
81
|
-
const
|
|
82
|
-
const
|
|
90
|
+
const where = whereOf(r);
|
|
91
|
+
const inView = where === "in view";
|
|
83
92
|
cand.push({ el, tag, label, href: href ? href.split(/[?#]/)[0] : "", where, rank: inView ? 0 : where === "below" ? 1 : 2, dim: dimmed(el), edge: (r.left + r.right) / 2 > VW - 1 || (r.left + r.right) / 2 < 1 });
|
|
84
93
|
// 성능용 안전 상한. 이건 사람에게 보여줄 예산이 아니다
|
|
85
94
|
if (cand.length >= 800) break;
|
|
@@ -142,14 +151,17 @@ async function listControls(page, max = 60) {
|
|
|
142
151
|
const r = el.getBoundingClientRect();
|
|
143
152
|
// 작은 크기는 글자 없는 것에만 따진다. 날짜 "1" 은 폭이 5px 이라 빠졌다(10/14 실험실 달력)
|
|
144
153
|
if (text ? r.width < 2 || r.height < 6 : r.width < 8 || r.height < 8) continue;
|
|
154
|
+
// 글자 없이 화면 반을 덮는 것은 아이콘이 아니라 창 뒤의 막이다 — "(icon 0,0)" 으로 나와 방문자가 뭔지 몰랐다(10/21 실험실 q5).
|
|
155
|
+
// 막 뒤의 것을 누르면 손이 막을 누른다
|
|
156
|
+
if (!text && r.width * r.height > VW * innerHeight * 0.25) continue;
|
|
145
157
|
const hint = text ? null : iconHint(el);
|
|
146
158
|
const label = text || (hint ? `(icon: ${hint})` : `(icon ${Math.round(r.left)},${Math.round(r.top)})`);
|
|
147
159
|
// 같은 말인 칩이 여럿인 UI 가 흔하다(태그 목록). 자리까지 봐야 서로 다른 조작으로 남는다
|
|
148
160
|
const key = `pointer|${label}|${Math.round(r.left)},${Math.round(r.top + scrollY)}`;
|
|
149
161
|
if (seen.has(key)) continue;
|
|
150
162
|
seen.add(key);
|
|
151
|
-
const
|
|
152
|
-
const
|
|
163
|
+
const where = whereOf(r);
|
|
164
|
+
const inView = where === "in view";
|
|
153
165
|
cand.push({ el, tag: el.tagName.toLowerCase(), label, href: "", where, rank: inView ? 0 : where === "below" ? 1 : 2, size: `${Math.round(r.width)}x${Math.round(r.height)}`, dim: dimmed(el), edge: (r.left + r.right) / 2 > VW - 1 || (r.left + r.right) / 2 < 1 });
|
|
154
166
|
}
|
|
155
167
|
|
|
@@ -160,6 +172,83 @@ async function listControls(page, max = 60) {
|
|
|
160
172
|
* 지금 화면에 보이는 것부터 남기고, 같은 순위 안에서는 문서 순서를 지킨다(sort 는 안정 정렬).
|
|
161
173
|
* 2026-09-21 여기어때에서 드러났다. 블루태그스 Week/Month 토글도 같은 결의 문제였다.
|
|
162
174
|
*/
|
|
175
|
+
/**
|
|
176
|
+
* 화면을 통째로 덮은 층(모바일의 전체 화면 정보 창) 밑에 깔린 것은 사람 눈에 없다. 목록에 넣으면 방문자가
|
|
177
|
+
* 밑의 카드를 누르고 "덮였다" 만 세 번 듣고 나갔다(10/21 bluetags p1937, /community?info= 모바일).
|
|
178
|
+
* 고정 머리줄·아래 메뉴처럼 좁은 막대에 가린 것은 남긴다 — 손이 굴려서 누른다
|
|
179
|
+
*/
|
|
180
|
+
const buried = (el) => {
|
|
181
|
+
const r = el.getBoundingClientRect();
|
|
182
|
+
const x = Math.min(Math.max((r.left + r.right) / 2, 1), VW - 1);
|
|
183
|
+
const y = Math.min(Math.max((r.top + r.bottom) / 2, 1), innerHeight - 1);
|
|
184
|
+
// 위에서부터 겹친 것을 다 본다 — 맨 위가 다른 작은 것(사이트의 설문 창)이어도 그 밑에 덮은 층이 있다
|
|
185
|
+
for (const h of document.elementsFromPoint(x, y)) {
|
|
186
|
+
if (h === el || el.contains(h) || h.contains(el)) return false;
|
|
187
|
+
if (h.closest(ownSel)) continue;
|
|
188
|
+
// 맞은 것에서 올라가며 처음 칠이 있는 것. 반투명한 뒤판(데스크톱 정보 창 옆의 흐린 막)은 비쳐 보이고
|
|
189
|
+
// 누르면 창이 닫힌다 — 남긴다. 칠 없는 것은 투명하니 그 밑을 본다
|
|
190
|
+
for (let n = h; n && n !== document.body && !n.contains(el); n = n.parentElement) {
|
|
191
|
+
const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
|
|
192
|
+
const alpha = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
|
|
193
|
+
if (!alpha) continue;
|
|
194
|
+
const lr = n.getBoundingClientRect();
|
|
195
|
+
const big = Math.min(lr.right, VW) - Math.max(lr.left, 0) >= VW * 0.9 && Math.min(lr.bottom, innerHeight) - Math.max(lr.top, 0) >= innerHeight * 0.6;
|
|
196
|
+
// 반투명한 큰 막 밑 = 열린 창 뒤에 흐리게 보이는 것. 사람은 창이 떠 있는 줄 안다 — 방문자도 알아야
|
|
197
|
+
// 뒤의 카드를 눌러 창만 닫히는 걸 이해한다(10/21 실험실 q3)
|
|
198
|
+
if (big && alpha < 0.9) return "dim";
|
|
199
|
+
if (big) {
|
|
200
|
+
covers.add(n);
|
|
201
|
+
return "under";
|
|
202
|
+
}
|
|
203
|
+
break; // 작은 것(사이트의 설문 창, 고정 막대) — 그 밑을 본다
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
};
|
|
208
|
+
const covers = new Set();
|
|
209
|
+
for (let k = cand.length - 1; k >= 0; k--) {
|
|
210
|
+
if (cand[k].where !== "in view") continue;
|
|
211
|
+
const b = buried(cand[k].el);
|
|
212
|
+
if (b === "under") cand.splice(k, 1);
|
|
213
|
+
else if (b === "dim") cand[k].behind = true;
|
|
214
|
+
}
|
|
215
|
+
// 덮은 층이 화면에 붙어 있으면(fixed) 굴려도 그대로 덮여 있다 — 그 밖의 위·아래 것도 안 보인다.
|
|
216
|
+
// 남기면 "위에 있다" 고 나와 방문자가 올려 보고 누르다 덮였다를 듣는다(10/21 실험실 q2)
|
|
217
|
+
const fixedCovers = [...covers].filter((n) => {
|
|
218
|
+
for (let p = n; p && p !== document.body; p = p.parentElement) if (getComputedStyle(p).position === "fixed") return true;
|
|
219
|
+
return false;
|
|
220
|
+
});
|
|
221
|
+
if (fixedCovers.length) for (let k = cand.length - 1; k >= 0; k--) if (cand[k].where !== "in view" && !fixedCovers.some((n) => n.contains(cand[k].el))) cand.splice(k, 1);
|
|
222
|
+
// 화면을 덮은 창이 있으면 굴리는 건 그 창이다. 뒤 페이지의 위치를 말하면 "아래에 더 있다" 는데 굴려도 화면이 그대로였다(10/21 실험실 r1)
|
|
223
|
+
// 페이지가 잠겨 있으면(창이 뜨며 사이트가 막음) 화면에서 굴러가는 가장 큰 칸 — 데스크톱 정보 창 안쪽(10/21 실험실 r4)
|
|
224
|
+
// 흐린 막 위에 창이 떠 있으면 사람은 그 창을 굴린다 — 주소로 바로 열린 창은 뒤 페이지가 안 잠겨
|
|
225
|
+
// 위치·"아래에 더" 가 뒤 페이지 기준이라 창 맨 아래 Join 을 끝내 못 봤다(10/21 실험실 u4)
|
|
226
|
+
const dimOpen = cand.some((c) => c.behind);
|
|
227
|
+
const pageLocked = dimOpen || document.documentElement.scrollHeight <= innerHeight + 2 || getComputedStyle(document.body).overflowY === "hidden";
|
|
228
|
+
let roll = { y: scrollY, height: document.documentElement.scrollHeight, viewport: innerHeight };
|
|
229
|
+
// 굴리는 칸이 페이지가 아니면 "아래에 더 있다" 도 그 칸 안의 것만 센다 — 잠긴 페이지 뒤 목록을 세면 끝에 닿아도 "더 있다" 였다
|
|
230
|
+
let pane = null;
|
|
231
|
+
if (fixedCovers.length || pageLocked) {
|
|
232
|
+
roll = { y: 0, height: innerHeight, viewport: innerHeight };
|
|
233
|
+
pane = fixedCovers[0] || null;
|
|
234
|
+
let best = 0;
|
|
235
|
+
for (const n of fixedCovers.length ? [fixedCovers[0], ...fixedCovers[0].querySelectorAll("*")] : document.body.querySelectorAll("*")) {
|
|
236
|
+
if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100) continue;
|
|
237
|
+
if (!/auto|scroll/.test(getComputedStyle(n).overflowY)) continue;
|
|
238
|
+
// 창이 떠 있으면 창 쪽(흐리지 않은 것을 품은 칸)만
|
|
239
|
+
// 뒤 목록까지 품은 바깥 칸(페이지 틀)은 창이 아니다 — 그걸 굴리면 뒤 목록이 굴렀다
|
|
240
|
+
if (dimOpen && (!cand.some((c) => !c.behind && c.where === "in view" && n.contains(c.el)) || cand.some((c) => c.behind && n.contains(c.el)))) continue;
|
|
241
|
+
const r = n.getBoundingClientRect();
|
|
242
|
+
const area = Math.max(0, Math.min(r.right, VW) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, innerHeight) - Math.max(r.top, 0));
|
|
243
|
+
if (area > best) {
|
|
244
|
+
best = area;
|
|
245
|
+
pane = n;
|
|
246
|
+
roll = { y: n.scrollTop, height: n.scrollHeight, viewport: n.clientHeight };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// 창이 떠 있으면 창 밖의 위·아래 것은 굴려도 안 온다 — 뒤 목록의 필터가 "위에 있다" 로 남았다(10/21 실험실 u1)
|
|
251
|
+
if (pageLocked || fixedCovers.length) for (let k = cand.length - 1; k >= 0; k--) if (cand[k].where !== "in view" && !(pane && pane.contains(cand[k].el))) cand.splice(k, 1);
|
|
163
252
|
cand.sort((a, b) => a.rank - b.rank);
|
|
164
253
|
/**
|
|
165
254
|
* **화면 아래는 보여주지 않는다.** 사람은 내려 봐야 거기 뭐가 있는지 안다.
|
|
@@ -204,26 +293,47 @@ async function listControls(page, max = 60) {
|
|
|
204
293
|
const picked = pool.slice(0, max);
|
|
205
294
|
const groups = {};
|
|
206
295
|
for (const c of picked) (groups[c.label] = groups[c.label] || []).push(c);
|
|
207
|
-
const nearAt = (c, d) => {
|
|
296
|
+
const nearAt = (c, d, g) => {
|
|
208
297
|
let p = c.el.parentElement;
|
|
209
298
|
for (let k = 0; k < d && p && p !== document.body; k++) p = p.parentElement;
|
|
210
|
-
|
|
299
|
+
if (!p) return "";
|
|
300
|
+
// 같은 이름의 다른 항목까지 감싼 덩어리의 글자는 그 항목의 곁이 아니다 — 맨 아래 "Bluetags" 에
|
|
301
|
+
// 페이지 전체의 첫 글(맨 위 카드)이 붙었다(10/21 실험실 q4·s4)
|
|
302
|
+
if (g.some((o) => o !== c && p.contains(o.el))) return "";
|
|
303
|
+
// 제 이름은 낱말째로만 뺀다 — 이름이 "0" 인 항목에서 날짜 "20" 의 0 이 빠져 "Oct 2" 가 됐다(10/21 실험실 w5)
|
|
304
|
+
const own = new RegExp(`(^|\\s)${c.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=\\s|$)`);
|
|
305
|
+
// 곁의 입력칸 안내 문구도 본다 — 댓글칸 Post 와 답글칸 Post 는 둘레 글자가 없어 "1 of 2" 가 됐다(10/21 실험실 m1)
|
|
306
|
+
const hints = [...p.querySelectorAll("input[placeholder],textarea[placeholder]")].map((n) => n.getAttribute("placeholder")).join(" ");
|
|
307
|
+
return `${hints} ${p.innerText || ""}`.replace(/\s+/g, " ").replace(own, "$1").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
211
308
|
};
|
|
212
309
|
for (const [label, g] of Object.entries(groups)) {
|
|
213
310
|
if (g.length < 2) continue;
|
|
214
311
|
// 묶음 안에서 서로 다르게 읽히는 가장 가까운 층을 고른다. 없으면 순서로 가른다
|
|
215
312
|
let hints = null;
|
|
216
313
|
for (let d = 0; d < 6 && !hints; d++) {
|
|
217
|
-
const h = g.map((c) => nearAt(c, d));
|
|
314
|
+
const h = g.map((c) => nearAt(c, d, g));
|
|
218
315
|
if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
|
|
219
316
|
}
|
|
220
|
-
|
|
317
|
+
// 층을 하나로 맞추면 안 갈리는 것 — 댓글칸 Post 는 바로 위에, 답글칸 Post 는 한 층 더 위에 칸이 있었다(10/21 실험실 m1).
|
|
318
|
+
// 항목마다 가장 가까운 곁 글을 따로 찾는다
|
|
319
|
+
if (!hints) {
|
|
320
|
+
const h = g.map((c) => {
|
|
321
|
+
for (let d = 0; d < 6; d++) {
|
|
322
|
+
const x = nearAt(c, d, g);
|
|
323
|
+
if (x.length >= 2) return x;
|
|
324
|
+
}
|
|
325
|
+
return "";
|
|
326
|
+
});
|
|
327
|
+
if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
|
|
328
|
+
}
|
|
329
|
+
// "near:" — 괄호 속이 그 항목의 내용이 아니라 곁의 글이라는 표시. 없으면 "27 (28 29 30 1 2 3)" 이 날짜 여럿을 품은 칸처럼 읽혔다(10/21 실험실 j4·h1)
|
|
330
|
+
g.forEach((c, k) => (c.label = hints ? `${label} (near: ${hints[k]})` : `${label} (${k + 1} of ${g.length})`));
|
|
221
331
|
}
|
|
222
332
|
const els = [];
|
|
223
333
|
const out = picked.map((c, i) => {
|
|
224
334
|
els.push(c.el);
|
|
225
335
|
// 반 넘게 화면 옆으로 나간 것. 누르면 손이 "잘렸다" 고 한다 — 목록도 같은 말을 해야 한다(10/15 실험실 x87: 공유 막대가 화면을 넓혀 아래 메뉴가 밀렸다)
|
|
226
|
-
return { i, tag: c.tag, label: c.label, href: c.href, where: c.where, ...(c.dim ? { dim: true } : {}), ...(c.edge ? { edge: true } : {}) };
|
|
336
|
+
return { i, tag: c.tag, label: c.label, href: c.href, where: c.where, ...(c.dim ? { dim: true } : {}), ...(c.edge ? { edge: true } : {}), ...(c.behind ? { behind: true } : {}) };
|
|
227
337
|
});
|
|
228
338
|
Object.defineProperty(window, "__ebMcpEls", { value: els, configurable: true, enumerable: false, writable: true });
|
|
229
339
|
return {
|
|
@@ -238,12 +348,19 @@ async function listControls(page, max = 60) {
|
|
|
238
348
|
/** 그중 이름 없는 작은 아이콘 수 */
|
|
239
349
|
droppedIcons: iconsLeft,
|
|
240
350
|
/** 화면 아래에 더 있는 조작 수. 무엇인지는 말하지 않는다 */
|
|
241
|
-
below,
|
|
351
|
+
below: pane ? cand.filter((c) => c.where === "below" && pane.contains(c.el)).length : below,
|
|
242
352
|
path: location.pathname + location.search,
|
|
243
353
|
title: (document.title || "").slice(0, 120),
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
354
|
+
/** 화면을 덮은 창이 떠 있다 — 위치는 그 창 기준이다 */
|
|
355
|
+
cover: fixedCovers.length > 0,
|
|
356
|
+
/** 창이 떠 있다(굴러가는 칸이 없어도) — 위치 기준이 페이지에서 바뀐다 */
|
|
357
|
+
panelOpen: fixedCovers.length > 0 || dimOpen,
|
|
358
|
+
/** 굴리는 것이 페이지가 아니라 떠 있는 창 안쪽이다 */
|
|
359
|
+
// 본문이 안쪽 칸에서 굴러가는 앱형 사이트도 페이지가 잠겨 보인다 — 창이 실제로 떠 있을 때만
|
|
360
|
+
inPanel: !!pane && (fixedCovers.length > 0 || cand.some((c) => c.behind)),
|
|
361
|
+
scrollY: Math.round(roll.y),
|
|
362
|
+
height: roll.height,
|
|
363
|
+
viewport: roll.viewport,
|
|
247
364
|
};
|
|
248
365
|
},
|
|
249
366
|
{ ownSel: OWN_SEL, max },
|
|
@@ -272,8 +389,38 @@ async function visibleText(page, max = 2500) {
|
|
|
272
389
|
}
|
|
273
390
|
return false;
|
|
274
391
|
};
|
|
392
|
+
/**
|
|
393
|
+
* listControls 의 buried 와 같다 — 화면을 통째로 덮은 불투명한 층(모바일 전체 화면 정보 창) 밑의 글자는 안 보인다.
|
|
394
|
+
* 남기면 창 글자와 밑의 목록이 한 덩어리로 섞여 새 페이지인지 창인지 모른다(10/21 실험실 q4)
|
|
395
|
+
*/
|
|
396
|
+
// false | "under"(안 보임) | "dim"(열린 창 뒤에 흐리게 보임)
|
|
397
|
+
const buriedCache = new Map();
|
|
398
|
+
const buried = (el, r) => {
|
|
399
|
+
if (buriedCache.has(el)) return buriedCache.get(el);
|
|
400
|
+
let res = false;
|
|
401
|
+
const x = Math.min(Math.max((r.left + r.right) / 2, 1), VW - 1);
|
|
402
|
+
const y = Math.min(Math.max((r.top + r.bottom) / 2, 1), innerHeight - 1);
|
|
403
|
+
outer: for (const h of document.elementsFromPoint(x, y)) {
|
|
404
|
+
if (h === el || el.contains(h) || h.contains(el)) break;
|
|
405
|
+
if (h.closest(ownSel)) continue;
|
|
406
|
+
for (let n = h; n && n !== document.body && !n.contains(el); n = n.parentElement) {
|
|
407
|
+
const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
|
|
408
|
+
const alpha = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
|
|
409
|
+
if (!alpha) continue;
|
|
410
|
+
const lr = n.getBoundingClientRect();
|
|
411
|
+
if (Math.min(lr.right, VW) - Math.max(lr.left, 0) >= VW * 0.9 && Math.min(lr.bottom, innerHeight) - Math.max(lr.top, 0) >= innerHeight * 0.6) {
|
|
412
|
+
res = alpha >= 0.9 ? "under" : "dim";
|
|
413
|
+
break outer;
|
|
414
|
+
}
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
buriedCache.set(el, res);
|
|
419
|
+
return res;
|
|
420
|
+
};
|
|
275
421
|
const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
|
|
276
422
|
const range = document.createRange();
|
|
423
|
+
const behind = [];
|
|
277
424
|
let n = 0;
|
|
278
425
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
279
426
|
if (++n > 20000) break;
|
|
@@ -288,15 +435,30 @@ async function visibleText(page, max = 2500) {
|
|
|
288
435
|
const st = getComputedStyle(el);
|
|
289
436
|
if (st.visibility === "hidden" || st.opacity === "0") continue;
|
|
290
437
|
if (clippedX(el, r)) continue;
|
|
438
|
+
const b = buried(el, r);
|
|
439
|
+
if (b === "under") continue;
|
|
440
|
+
// 열린 창 뒤에 흐리게 비치는 글은 따로 모은다 — 섞으면 창이 어디서 끝나는지 모른다(10/21 실험실 q3·r4·s1·t1)
|
|
441
|
+
if (b === "dim") {
|
|
442
|
+
behind.push(txt);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
291
445
|
if (lastTop !== null && Math.abs(r.top - lastTop) > 4) out.push("\n");
|
|
292
446
|
else if (out.length) out.push(" ");
|
|
293
447
|
out.push(txt);
|
|
294
448
|
lastTop = r.top;
|
|
295
449
|
}
|
|
450
|
+
// 뒤의 흐린 글은 짧게 — 길면 글자 한도를 먹어 창 쪽 글이 단어 중간에 잘렸다(10/21 실험실 u1)
|
|
451
|
+
if (behind.length) {
|
|
452
|
+
const b = behind.join(" · ");
|
|
453
|
+
out.push("\n\n[Behind the open panel, dimmed:]\n", b.length > 500 ? b.slice(0, b.lastIndexOf(" ", 500)) + " …" : b);
|
|
454
|
+
}
|
|
296
455
|
return out.join("");
|
|
297
456
|
}, OWN_SEL)
|
|
298
457
|
.catch(() => "");
|
|
299
|
-
|
|
458
|
+
const s = t.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim();
|
|
459
|
+
if (s.length <= max) return s;
|
|
460
|
+
// 넘치면 낱말 사이에서 자르고 잘렸다고 적는다 — "disap" 처럼 끊기면 화면이 그렇게 생긴 줄 안다(10/21 실험실 u1)
|
|
461
|
+
return s.slice(0, Math.max(s.lastIndexOf(" ", max), s.lastIndexOf("\n", max), max - 40)) + " … (more text on screen not shown)";
|
|
300
462
|
}
|
|
301
463
|
|
|
302
464
|
/** 번호로 그 요소를 집는다. 화면이 바뀌었으면 null */
|
|
@@ -328,6 +490,8 @@ const NEVER_WORDS = /log\s?out|logout|sign\s?out|로그아웃|탈퇴|delete\s+ac
|
|
|
328
490
|
* "예약하기" 처럼 기본 목록에 없는 버튼(2026-09-21 여기어때)
|
|
329
491
|
*/
|
|
330
492
|
function clickBlocked(item, { allowAccounts = false, policy = null } = {}) {
|
|
493
|
+
// 같은 이름을 가르려고 붙인 "(near: …)"·"(2 of 3)" 은 떼고 본다 — 붙이면 40 자를 넘어 "Sign out" 같은 걸 못 막을 수 있다
|
|
494
|
+
item = { ...item, label: String(item.label || "").replace(/ \((near: .*|\d+ of \d+)\)$/, "") };
|
|
331
495
|
if (policy) {
|
|
332
496
|
if (policy.labelBlocked(item.label)) return `blocked by policy ("${item.label}")`;
|
|
333
497
|
if (item.href) {
|
|
@@ -352,4 +516,55 @@ function clickBlocked(item, { allowAccounts = false, policy = null } = {}) {
|
|
|
352
516
|
return null;
|
|
353
517
|
}
|
|
354
518
|
|
|
355
|
-
|
|
519
|
+
/**
|
|
520
|
+
* 이름 없는 아이콘의 그림. 사람은 모양(클립·종이비행기·X)으로 아는데 방문자는 "(icon 190,785)" 만 받아
|
|
521
|
+
* 채팅 보내기 대신 사진 첨부를 눌렀다(10/21 bluetags 2b72). 화면 안의 이름 없는 아이콘만 잘라 번호를 달아 한 장으로 붙인다.
|
|
522
|
+
* 없으면 null. 붙이는 판은 방문과 따로 연 빈 창이라 사이트에 아무것도 안 보낸다
|
|
523
|
+
*
|
|
524
|
+
* @param page 방문 중인 페이지(listControls 를 막 부른 뒤 — 번호가 그 목록 기준이다)
|
|
525
|
+
* @param items listControls().items
|
|
526
|
+
* @param browser 붙이는 판을 열 브라우저
|
|
527
|
+
*/
|
|
528
|
+
async function iconPicture(page, items, browser, max = 16) {
|
|
529
|
+
// 숫자뿐인 것(하트·말풍선 옆의 좋아요·답글 수)도 — 글자가 "0" 이라 이름이 있는 줄 알았는데 무엇의 수인지 몰랐다(10/21 실험실 i4)
|
|
530
|
+
const icons = items.filter((it) => it.where === "in view" && (/^\(icon[ ]/.test(it.label) || /^\d{1,5}( \(|$)/.test(it.label))).slice(0, max);
|
|
531
|
+
if (!icons.length || !browser) return null;
|
|
532
|
+
const vp = page.viewportSize() || { width: 1280, height: 860 };
|
|
533
|
+
const crops = [];
|
|
534
|
+
for (const it of icons) {
|
|
535
|
+
const el = await handleAt(page, it.i);
|
|
536
|
+
const box = el && (await el.boundingBox().catch(() => null));
|
|
537
|
+
if (!box || box.width < 2 || box.height < 2) continue;
|
|
538
|
+
// 둘레를 조금 넣어 자른다 — 아이콘만 딱 자르면 테두리·배경이 없어 모양이 안 읽힌다
|
|
539
|
+
const pad = 4;
|
|
540
|
+
const x = Math.max(0, box.x - pad);
|
|
541
|
+
const y = Math.max(0, box.y - pad);
|
|
542
|
+
const clip = { x, y, width: Math.min(vp.width - x, box.width + pad * 2), height: Math.min(vp.height - y, box.height + pad * 2) };
|
|
543
|
+
if (clip.width < 2 || clip.height < 2 || clip.width > 160 || clip.height > 160) continue;
|
|
544
|
+
const png = await page.screenshot({ clip, animations: "disabled", timeout: 3000 }).catch(() => null);
|
|
545
|
+
if (png) crops.push({ i: it.i, png, w: clip.width, h: clip.height });
|
|
546
|
+
}
|
|
547
|
+
if (!crops.length) return null;
|
|
548
|
+
const ctx = await browser.newContext({ viewport: { width: 640, height: 200 } });
|
|
549
|
+
try {
|
|
550
|
+
const sheet = await ctx.newPage();
|
|
551
|
+
// 작은 아이콘은 키운다(높이 48px 쯤) — 24px 그대로는 모양이 뭉개져 읽힌다
|
|
552
|
+
const cells = crops
|
|
553
|
+
.map((c) => {
|
|
554
|
+
const k = Math.max(1, Math.min(4, 48 / c.h));
|
|
555
|
+
return `<figure><figcaption>[${c.i}]</figcaption><img src="data:image/png;base64,${c.png.toString("base64")}" width="${Math.round(c.w * k)}" height="${Math.round(c.h * k)}"></figure>`;
|
|
556
|
+
})
|
|
557
|
+
.join("");
|
|
558
|
+
await sheet.setContent(
|
|
559
|
+
`<!doctype html><html><body style="margin:0;padding:8px;background:#fff;font:bold 14px sans-serif;display:flex;flex-wrap:wrap;gap:12px;width:620px">` +
|
|
560
|
+
`<style>figure{margin:0;display:flex;flex-direction:column;align-items:center;gap:4px;padding:4px;border:1px solid #ddd}img{display:block}</style>${cells}</body></html>`,
|
|
561
|
+
);
|
|
562
|
+
return await sheet.screenshot({ fullPage: true, timeout: 5000 });
|
|
563
|
+
} catch {
|
|
564
|
+
return null;
|
|
565
|
+
} finally {
|
|
566
|
+
await ctx.close().catch(() => {});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
module.exports = { OWN_SEL, listControls, visibleText, handleAt, clickBlocked, iconPicture };
|
package/src/run/visit.js
CHANGED
|
@@ -181,7 +181,9 @@ function renderScreen(look) {
|
|
|
181
181
|
"```",
|
|
182
182
|
"",
|
|
183
183
|
"## Things you can use (number · what it says · where)",
|
|
184
|
-
...view.items.map((it) => `[${it.i}] ${it.label || "(no text)"}${it.href ? ` → ${it.href}` : ""}${it.dim ? " · looks greyed out" : ""}${it.edge ? " · cut off at the edge of the screen" : ""} · ${it.where}`),
|
|
184
|
+
...view.items.map((it) => `[${it.i}] ${it.label || "(no text)"}${it.href ? ` → ${it.href}` : ""}${it.dim ? " · looks greyed out" : ""}${it.edge ? " · cut off at the edge of the screen" : ""}${it.behind ? " · dimmed, behind the open panel" : ""} · ${it.where}`),
|
|
185
|
+
// 떠 있는 창 뒤가 흐리게 비치는 화면. 글로 옮기면 창과 뒤 페이지가 한 덩어리로 섞인다(10/21 실험실 q3)
|
|
186
|
+
...(view.items.some((it) => it.behind) ? ["(A panel is open on top of the page; the page behind it is dimmed.)"] : []),
|
|
185
187
|
// 화면 안에 있는데 목록에 못 넣은 것. "아래에 더 있다" 고 하면 내려 봐도 못 찾는다
|
|
186
188
|
// 화면 안에서 빠진 것과 지나온 위쪽에서 빠진 것을 따로 말한다 — 한 숫자로 말하면 "화면에 너무 많다" 가 거짓이 된다
|
|
187
189
|
...(view.droppedInView
|
|
@@ -192,11 +194,18 @@ function renderScreen(look) {
|
|
|
192
194
|
? [`(${view.dropped} more things further up the page are not listed — scroll up to use them.)`]
|
|
193
195
|
: []),
|
|
194
196
|
// 아래에 더 있다는 것만. 무엇인지는 내려 봐야 안다
|
|
195
|
-
|
|
196
|
-
|
|
197
|
+
// 굴릴 데가 없는데 "아래에 더 있다" 고 하면 모순이다 — 창이 뜨며 사이트가 페이지 스크롤을 잠근 때(10/21 실험실 r2·r4)
|
|
198
|
+
...(view.height - view.viewport <= 2
|
|
199
|
+
? ["(Nothing on this screen scrolls right now.)"]
|
|
200
|
+
// 끝까지 내려왔으면 "아래에 더" 라고 하지 않는다 — 화면 밖 아래에 걸린 것이 있어도 굴려서 못 온다(10/21 실험실 t3)
|
|
201
|
+
: view.below && view.height - view.viewport - view.scrollY > 2
|
|
202
|
+
? [view.inPanel ? "(The open panel goes on below. Scroll down to see the rest of it.)" : `(The page goes on below. Scroll down to see what is there.)`]
|
|
203
|
+
: view.height - view.viewport - view.scrollY > Math.max(40, view.viewport * 0.25)
|
|
204
|
+
? [view.inPanel ? "(There is more of the open panel below.)" : "(There is more of the page below.)"]
|
|
205
|
+
// 조금 남은 것(화면 맨 아래 버튼의 나머지, 여백)을 "더 있다" 고 하면 굴려도 새것이 없었다(10/21 실험실 s2·u2·w2)
|
|
197
206
|
: view.height - view.viewport - view.scrollY > 40
|
|
198
|
-
? ["(
|
|
199
|
-
: ["(This is the bottom of the page.)"]),
|
|
207
|
+
? [view.inPanel ? "(Nearly the bottom of the open panel — only a little more below.)" : "(Nearly the bottom — only a little more below.)"]
|
|
208
|
+
: [view.inPanel ? "(This is the bottom of the open panel.)" : "(This is the bottom of the page.)"]),
|
|
200
209
|
].join("\n");
|
|
201
210
|
}
|
|
202
211
|
|
|
@@ -497,9 +506,10 @@ class Visit {
|
|
|
497
506
|
await hand.clickLikePerson(page, this.handState, el).catch((e) => (record.skipped = e.human ? `you could not get into that field: ${e.human}` : "you could not get into that field"));
|
|
498
507
|
// 칸에 전에 친 글이 남아 있으면 지우고 친다 — 사람은 새 검색어를 옛 것 뒤에 잇지 않는다.
|
|
499
508
|
// 안 지우면 "PolygonPolygon" 이 되고 그게 사이트 문제로 보고된다(10/13 bluetags 3명)
|
|
500
|
-
const typed = String(decision.text || "").slice(0, 80);
|
|
501
|
-
const value = await el.evaluate((n) => String(n.value ?? n.textContent ?? "")).catch(() => "");
|
|
502
509
|
const multiline = await el.evaluate((n) => n.tagName === "TEXTAREA" || n.isContentEditable).catch(() => false);
|
|
510
|
+
// 한 줄 칸(검색)은 짧게, 여러 줄 칸(댓글·답글·채팅)은 문장 몇 개까지 — 80 자로 자르면 답글이 낱말 중간에서 끊겨 올라갔다(10/21 실험실 m1)
|
|
511
|
+
const typed = String(decision.text || "").slice(0, multiline ? 280 : 80);
|
|
512
|
+
const value = await el.evaluate((n) => String(n.value ?? n.textContent ?? "")).catch(() => "");
|
|
503
513
|
/**
|
|
504
514
|
* Enter 는 사람이 누를 때만. 여러 줄 칸(댓글·답글)에서 Enter 는 줄바꿈이고, 사람은 Post 를 누른다.
|
|
505
515
|
* 치는 대로 결과가 걸러지는 칸에서는 사람은 Enter 없이 결과를 본다 — 그 칸이 <form> 안이면 Enter 가 폼을 보내
|
|
@@ -524,10 +534,33 @@ class Visit {
|
|
|
524
534
|
let changed = false;
|
|
525
535
|
if (!multiline) {
|
|
526
536
|
await sleep(500 + Math.random() * 400);
|
|
527
|
-
|
|
528
|
-
|
|
537
|
+
// 친 글과 이어진 변화만 친다 — 새로 나타난 줄에 친 말이 들어 있어야 추천·걸러진 목록이다.
|
|
538
|
+
// 그냥 달라진 걸로 보면 저절로 넘어가는 배너가 "치는 대로 바뀌었다" 가 되어 Enter 를 안 눌렀다(10/21 실험실 i3)
|
|
539
|
+
// 걸러지는 목록(10/15 x40 커뮤니티 검색)은 새 줄 없이 줄이 빠지고 남은 것에 친 말이 있다
|
|
540
|
+
const word = typed.trim().toLowerCase();
|
|
541
|
+
const beforeLines = shownBefore.split("\n");
|
|
542
|
+
const afterText = await visibleText(page, 2500);
|
|
543
|
+
const afterLines = afterText.split("\n");
|
|
544
|
+
const before = new Set(beforeLines);
|
|
545
|
+
const after = new Set(afterLines);
|
|
546
|
+
const added = afterLines.filter((l) => !before.has(l));
|
|
547
|
+
const removed = beforeLines.filter((l) => !after.has(l));
|
|
548
|
+
const suggested = added.some((l) => l.toLowerCase().includes(word));
|
|
549
|
+
const filtered = removed.length >= 3 && added.length <= 1 && afterText.toLowerCase().includes(word);
|
|
550
|
+
changed = !!word && (suggested || filtered);
|
|
529
551
|
}
|
|
530
552
|
if (!multiline && !changed) await page.keyboard.press("Enter").catch(() => {});
|
|
553
|
+
/**
|
|
554
|
+
* 한 줄짜리 여러 줄 칸 = 채팅 입력칸. 사람은 메신저처럼 Enter 로 보낸다 — 안 누르면 이름 없는 보내기 아이콘을
|
|
555
|
+
* 못 찾아 메시지를 못 보냈다(10/21 bluetags 2b72, 커뮤니티 채팅). Enter 가 줄만 바꿨으면 Enter 로 안 보내는 칸이니 지운다
|
|
556
|
+
*/
|
|
557
|
+
if (multiline && typed && (await el.evaluate((n) => n.getBoundingClientRect().height <= 64).catch(() => false))) {
|
|
558
|
+
await sleep(300 + Math.random() * 500);
|
|
559
|
+
await page.keyboard.press("Enter").catch(() => {});
|
|
560
|
+
await sleep(250);
|
|
561
|
+
const after = await el.evaluate((n) => String(n.value ?? n.innerText ?? "")).catch(() => "");
|
|
562
|
+
if (/\n\s*$/.test(after) && after.trim() === typed.trim()) await page.keyboard.press("Backspace").catch(() => {});
|
|
563
|
+
}
|
|
531
564
|
this.heldEnter = !multiline && changed ? typed : null;
|
|
532
565
|
if (this.heldEnter) record.heldEnter = true;
|
|
533
566
|
}
|