@usercase/mcp 0.2.2-next.3 → 0.2.2-next.5
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 +31 -5
- package/mcp/server.js +10 -3
- package/package.json +1 -1
- package/src/browser/page.js +124 -4
- package/src/run/visit.js +33 -5
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
|
// 시작할 때 비밀값을 한 번 검사한다 — 잘못된 값이 방문 도중 조용히 넘어가지 않게
|
|
@@ -405,17 +407,29 @@ class Hub {
|
|
|
405
407
|
const y = look.view.scrollY;
|
|
406
408
|
const at = look.view.path.split("?")[0];
|
|
407
409
|
// 창이 뜨거나 닫히며 위치 기준(페이지 ↔ 창)이 바뀐 것은 튄 게 아니다(10/21 실험실 r3)
|
|
408
|
-
|
|
410
|
+
// 굴러가는 칸 없는 작은 창(달력 날짜 창)도 — 창이 뜨면 페이지가 잠겨 위치가 0 이 된다(10/21 실험실 j4)
|
|
411
|
+
const cover = !!(look.view.cover || look.view.inPanel || look.view.panelOpen);
|
|
409
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.)", "");
|
|
410
413
|
rec.lastY = y;
|
|
411
414
|
rec.lastAt = at;
|
|
412
415
|
rec.lastCover = cover;
|
|
413
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.)");
|
|
414
421
|
// 인내심이 다 됐다는 단정이 아니라 조건이다. "볼 만큼 봤다" 고 하면 방문자가 그 자리에서 나갔다 — 진전이 있으면 이어 간다(10/14)
|
|
415
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.)");
|
|
416
423
|
return lines.join("\n");
|
|
417
424
|
}
|
|
418
425
|
|
|
426
|
+
/** 마지막 화면에 붙일 아이콘 그림. 한 번 가져가면 지운다(다음 화면은 새로 만든다) */
|
|
427
|
+
takePicture(id) {
|
|
428
|
+
const pic = this.pictures.get(id) || null;
|
|
429
|
+
this.pictures.delete(id);
|
|
430
|
+
return pic;
|
|
431
|
+
}
|
|
432
|
+
|
|
419
433
|
get(id) {
|
|
420
434
|
const rec = this.visits.get(id);
|
|
421
435
|
if (!rec) throw new Error(`No visit ${id}`);
|
|
@@ -430,12 +444,19 @@ class Hub {
|
|
|
430
444
|
const v = rec.visit;
|
|
431
445
|
rec.lastAction = decision.action;
|
|
432
446
|
const r = await v.act(decision);
|
|
433
|
-
if (r.interrupted)
|
|
447
|
+
if (r.interrupted) {
|
|
448
|
+
rec.cutOff = true;
|
|
449
|
+
return ["Before you could do that, a survey card appeared.", "", await this.screen(rec)].join("\n");
|
|
450
|
+
}
|
|
434
451
|
const said = [];
|
|
435
452
|
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.`);
|
|
436
453
|
if (r.skipped) said.push(`That did not happen: ${r.skipped}.`);
|
|
437
|
-
|
|
454
|
+
// 로그인·가입 요청(구글 로그인 버튼 등)은 "된 것처럼 이어 가라" 가 안 된다 — 다른 계정 창으로 갈 수 없으니 갈 곳을 몰랐다(10/21 실험실 i1)
|
|
455
|
+
if (r.notSent && r.notSent.every((k) => /\/(auth|signin|sign-in|login|oauth|signup|sign-up|register)\b/i.test(k)))
|
|
456
|
+
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.");
|
|
457
|
+
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.");
|
|
438
458
|
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.`);
|
|
459
|
+
if (r.sentWithEnter) said.push("You pressed Enter to send it, as you would in a chat box.");
|
|
439
460
|
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.");
|
|
440
461
|
if (r.fileWindow) said.push(r.fileWindow);
|
|
441
462
|
if (r.openedApp) said.push("That tried to open another app outside the browser (a share or app link). Nothing opened, and you are back on the page you were on.");
|
|
@@ -445,7 +466,9 @@ class Hub {
|
|
|
445
466
|
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.");
|
|
446
467
|
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.`);
|
|
447
468
|
if (r.missed) said.push("Your click landed just outside it and nothing happened.");
|
|
448
|
-
|
|
469
|
+
// 손가락이 잘못 누른 키를 지우고 다시 친 것 — 방문자가 일부러 쓴 철자는 그대로다. "오타를 고쳤다" 만 쓰면
|
|
470
|
+
// 제 글의 철자가 바뀐 줄 알았다(10/21 실험실 m1·m3)
|
|
471
|
+
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.)`);
|
|
449
472
|
if (v.ended) {
|
|
450
473
|
if (v.pending) return [...said, "As you were leaving, a survey card appeared.", "", renderCard(v.pending)].join("\n");
|
|
451
474
|
const sum = await this.finish(rec);
|
|
@@ -462,7 +485,10 @@ class Hub {
|
|
|
462
485
|
const rec = this.get(id);
|
|
463
486
|
const v = rec.visit;
|
|
464
487
|
const c = await v.answer(decision);
|
|
465
|
-
|
|
488
|
+
let done = c.result === "close" ? "You closed the card." : `You answered: ${c.answer}`;
|
|
489
|
+
// 카드가 가로챈 동작은 일어나지 않았다 — 카드를 닫고 나서도 말해야 다시 한다. 안 하면 "클릭이 됐나?" 를 몰랐다(10/21 실험실 e4·H2)
|
|
490
|
+
if (rec.cutOff) done += " What you were about to do before the card appeared did not happen — do it again if you still want to.";
|
|
491
|
+
rec.cutOff = false;
|
|
466
492
|
if (v.ended) {
|
|
467
493
|
const sum = await this.finish(rec);
|
|
468
494
|
return [done, "This visit is over.", "", sum].join("\n");
|
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.5",
|
|
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/page.js
CHANGED
|
@@ -264,6 +264,14 @@ async function listControls(page, max = 60) {
|
|
|
264
264
|
// 넘치면 이름 없는 아이콘부터 뺀다. 달력 칸마다 아이콘이 서너 개라 60개를 다 먹고 날짜·버튼이 잘렸다(10/14 실험실 p29)
|
|
265
265
|
let pool = shown;
|
|
266
266
|
let iconsLeft = 0;
|
|
267
|
+
// 화면 안 입력칸(글 쓰는 곳)과, 그 둘레 48px 안에 있는 것
|
|
268
|
+
for (const c of shown) c.field = c.where === "in view" && ["input", "textarea"].includes(c.tag);
|
|
269
|
+
const fieldBoxes = shown.filter((c) => c.field).map((c) => c.el.getBoundingClientRect());
|
|
270
|
+
const nearField = (c) => {
|
|
271
|
+
if (!fieldBoxes.length) return false;
|
|
272
|
+
const r = c.el.getBoundingClientRect();
|
|
273
|
+
return fieldBoxes.some((f) => r.right >= f.left - 48 && r.left <= f.right + 48 && r.bottom >= f.top - 48 && r.top <= f.bottom + 48);
|
|
274
|
+
};
|
|
267
275
|
// 화면 안의 것만으로 넘칠 때만 — 지나온 위쪽 때문에 넘친 거면 위쪽을 자르면 된다. 아이콘까지 빼면 보이는 것이 빠진다(10/15)
|
|
268
276
|
if (pool.filter((c) => c.where === "in view").length > max) {
|
|
269
277
|
// 같은 이름으로 되풀이되는 그림도 아이콘처럼 먼저 뺀다 — 카드마다의 "Project logo image" 17개가 자리를 먹어
|
|
@@ -287,9 +295,17 @@ async function listControls(page, max = 60) {
|
|
|
287
295
|
...ranked.filter((x) => x.rep <= 2).slice(0, 4).map((x) => x.c),
|
|
288
296
|
...ranked.slice(0, Math.max(0, max - named)).map((x) => x.c),
|
|
289
297
|
]);
|
|
298
|
+
// 입력칸 바로 옆 아이콘(보내기·첨부·검색·지우기)은 남긴다 — 채팅방은 왼쪽 방 목록 아이콘과 크기가 같아
|
|
299
|
+
// 보내기·첨부가 같이 빠졌고, 방문자는 "보내기 버튼이 없다" 며 못 보냈다(10/21 bluetags 82fd)
|
|
300
|
+
for (const c of inView) if (isIcon(c) && nearField(c)) keep.add(c);
|
|
290
301
|
pool = pool.filter((c) => c.where !== "in view" || !isIcon(c) || keep.has(c));
|
|
291
302
|
iconsLeft = shown.length - pool.length;
|
|
292
303
|
}
|
|
304
|
+
// 입력칸과 그 곁 아이콘은 앞으로 — 60 개로 자를 때 페이지 아래쪽 채팅칸이 잘리지 않게
|
|
305
|
+
if (pool.length > max) {
|
|
306
|
+
const front = pool.filter((c) => c.where === "in view" && (c.field || nearField(c)));
|
|
307
|
+
pool = [...front, ...pool.filter((c) => !front.includes(c))];
|
|
308
|
+
}
|
|
293
309
|
const picked = pool.slice(0, max);
|
|
294
310
|
const groups = {};
|
|
295
311
|
for (const c of picked) (groups[c.label] = groups[c.label] || []).push(c);
|
|
@@ -302,7 +318,9 @@ async function listControls(page, max = 60) {
|
|
|
302
318
|
if (g.some((o) => o !== c && p.contains(o.el))) return "";
|
|
303
319
|
// 제 이름은 낱말째로만 뺀다 — 이름이 "0" 인 항목에서 날짜 "20" 의 0 이 빠져 "Oct 2" 가 됐다(10/21 실험실 w5)
|
|
304
320
|
const own = new RegExp(`(^|\\s)${c.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=\\s|$)`);
|
|
305
|
-
|
|
321
|
+
// 곁의 입력칸 안내 문구도 본다 — 댓글칸 Post 와 답글칸 Post 는 둘레 글자가 없어 "1 of 2" 가 됐다(10/21 실험실 m1)
|
|
322
|
+
const hints = [...p.querySelectorAll("input[placeholder],textarea[placeholder]")].map((n) => n.getAttribute("placeholder")).join(" ");
|
|
323
|
+
return `${hints} ${p.innerText || ""}`.replace(/\s+/g, " ").replace(own, "$1").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
306
324
|
};
|
|
307
325
|
for (const [label, g] of Object.entries(groups)) {
|
|
308
326
|
if (g.length < 2) continue;
|
|
@@ -312,13 +330,41 @@ async function listControls(page, max = 60) {
|
|
|
312
330
|
const h = g.map((c) => nearAt(c, d, g));
|
|
313
331
|
if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
|
|
314
332
|
}
|
|
315
|
-
|
|
333
|
+
// 층을 하나로 맞추면 안 갈리는 것 — 댓글칸 Post 는 바로 위에, 답글칸 Post 는 한 층 더 위에 칸이 있었다(10/21 실험실 m1).
|
|
334
|
+
// 항목마다 가장 가까운 곁 글을 따로 찾는다
|
|
335
|
+
if (!hints) {
|
|
336
|
+
// 둘레 글이 없으면 가장 가까운 입력칸의 안내 문구 — 댓글칸 Post 는 곁 덩어리에 답글칸 Post 까지 들어 있어 끝내 못 갈랐다(10/21 실험실 m1·f1)
|
|
337
|
+
const fieldHint = (c) => {
|
|
338
|
+
const r = c.el.getBoundingClientRect();
|
|
339
|
+
let best = null;
|
|
340
|
+
let dist = 160;
|
|
341
|
+
for (const f of document.querySelectorAll("input[placeholder],textarea[placeholder]")) {
|
|
342
|
+
const b = f.getBoundingClientRect();
|
|
343
|
+
if (!b.width || !b.height) continue;
|
|
344
|
+
const dx = Math.max(b.left - r.right, r.left - b.right, 0);
|
|
345
|
+
const dy = Math.max(b.top - r.bottom, r.top - b.bottom, 0);
|
|
346
|
+
const dd = Math.hypot(dx, dy);
|
|
347
|
+
if (dd < dist) (dist = dd), (best = f.getAttribute("placeholder"));
|
|
348
|
+
}
|
|
349
|
+
return best ? `the "${best}" box` : "";
|
|
350
|
+
};
|
|
351
|
+
const h = g.map((c) => {
|
|
352
|
+
for (let d = 0; d < 6; d++) {
|
|
353
|
+
const x = nearAt(c, d, g);
|
|
354
|
+
if (x.length >= 2) return x;
|
|
355
|
+
}
|
|
356
|
+
return fieldHint(c);
|
|
357
|
+
});
|
|
358
|
+
if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
|
|
359
|
+
}
|
|
360
|
+
// "near:" — 괄호 속이 그 항목의 내용이 아니라 곁의 글이라는 표시. 없으면 "27 (28 29 30 1 2 3)" 이 날짜 여럿을 품은 칸처럼 읽혔다(10/21 실험실 j4·h1)
|
|
361
|
+
g.forEach((c, k) => (c.label = hints ? `${label} (near: ${hints[k]})` : `${label} (${k + 1} of ${g.length})`));
|
|
316
362
|
}
|
|
317
363
|
const els = [];
|
|
318
364
|
const out = picked.map((c, i) => {
|
|
319
365
|
els.push(c.el);
|
|
320
366
|
// 반 넘게 화면 옆으로 나간 것. 누르면 손이 "잘렸다" 고 한다 — 목록도 같은 말을 해야 한다(10/15 실험실 x87: 공유 막대가 화면을 넓혀 아래 메뉴가 밀렸다)
|
|
321
|
-
return { i, tag: c.tag, label: c.label, href: c.href, where: c.where, ...(c.dim ? { dim: true } : {}), ...(c.edge ? { edge: true } : {}), ...(c.behind ? { behind: true } : {}) };
|
|
367
|
+
return { i, tag: c.tag, label: c.label, href: c.href, where: c.where, ...(c.dim ? { dim: true } : {}), ...(c.edge ? { edge: true } : {}), ...(c.behind ? { behind: true } : {}), ...(c.where === "in view" && !c.field && nearField(c) ? { nearField: true } : {}) };
|
|
322
368
|
});
|
|
323
369
|
Object.defineProperty(window, "__ebMcpEls", { value: els, configurable: true, enumerable: false, writable: true });
|
|
324
370
|
return {
|
|
@@ -338,6 +384,8 @@ async function listControls(page, max = 60) {
|
|
|
338
384
|
title: (document.title || "").slice(0, 120),
|
|
339
385
|
/** 화면을 덮은 창이 떠 있다 — 위치는 그 창 기준이다 */
|
|
340
386
|
cover: fixedCovers.length > 0,
|
|
387
|
+
/** 창이 떠 있다(굴러가는 칸이 없어도) — 위치 기준이 페이지에서 바뀐다 */
|
|
388
|
+
panelOpen: fixedCovers.length > 0 || dimOpen,
|
|
341
389
|
/** 굴리는 것이 페이지가 아니라 떠 있는 창 안쪽이다 */
|
|
342
390
|
// 본문이 안쪽 칸에서 굴러가는 앱형 사이트도 페이지가 잠겨 보인다 — 창이 실제로 떠 있을 때만
|
|
343
391
|
inPanel: !!pane && (fixedCovers.length > 0 || cand.some((c) => c.behind)),
|
|
@@ -430,6 +478,21 @@ async function visibleText(page, max = 2500) {
|
|
|
430
478
|
out.push(txt);
|
|
431
479
|
lastTop = r.top;
|
|
432
480
|
}
|
|
481
|
+
// 빈 입력칸의 흐린 안내 글(placeholder)도 사람 눈에 보인다 — 없으면 "Your Reply" 칸이 목록에만 있고 화면에는 없었다(10/21 실험실 G1).
|
|
482
|
+
// 글 사이 자리를 찾기 어려워 끝에 모은다
|
|
483
|
+
const boxes = [];
|
|
484
|
+
for (const f of document.querySelectorAll("input[placeholder],textarea[placeholder]")) {
|
|
485
|
+
if (f.closest(ownSel) || f.value) continue;
|
|
486
|
+
const r = f.getBoundingClientRect();
|
|
487
|
+
if (r.width < 2 || r.height < 2 || r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= VW) continue;
|
|
488
|
+
if (getComputedStyle(f).visibility === "hidden") continue;
|
|
489
|
+
if (buried(f, r) === "under") continue;
|
|
490
|
+
// 끝에 모으니 어디 있는 칸인지 몰랐다(10/21 실험실 H3) — 대략 자리를 붙인다
|
|
491
|
+
const mid = (r.top + r.bottom) / 2;
|
|
492
|
+
const where = mid < innerHeight / 3 ? "near the top" : mid > (innerHeight * 2) / 3 ? "near the bottom" : "in the middle";
|
|
493
|
+
boxes.push(`[empty box ${where}: "${f.getAttribute("placeholder").trim().slice(0, 40)}"]`);
|
|
494
|
+
}
|
|
495
|
+
if (boxes.length) out.push("\n", boxes.join(" "));
|
|
433
496
|
// 뒤의 흐린 글은 짧게 — 길면 글자 한도를 먹어 창 쪽 글이 단어 중간에 잘렸다(10/21 실험실 u1)
|
|
434
497
|
if (behind.length) {
|
|
435
498
|
const b = behind.join(" · ");
|
|
@@ -473,6 +536,8 @@ const NEVER_WORDS = /log\s?out|logout|sign\s?out|로그아웃|탈퇴|delete\s+ac
|
|
|
473
536
|
* "예약하기" 처럼 기본 목록에 없는 버튼(2026-09-21 여기어때)
|
|
474
537
|
*/
|
|
475
538
|
function clickBlocked(item, { allowAccounts = false, policy = null } = {}) {
|
|
539
|
+
// 같은 이름을 가르려고 붙인 "(near: …)"·"(2 of 3)" 은 떼고 본다 — 붙이면 40 자를 넘어 "Sign out" 같은 걸 못 막을 수 있다
|
|
540
|
+
item = { ...item, label: String(item.label || "").replace(/ \((near: .*|\d+ of \d+)\)$/, "") };
|
|
476
541
|
if (policy) {
|
|
477
542
|
if (policy.labelBlocked(item.label)) return `blocked by policy ("${item.label}")`;
|
|
478
543
|
if (item.href) {
|
|
@@ -497,4 +562,59 @@ function clickBlocked(item, { allowAccounts = false, policy = null } = {}) {
|
|
|
497
562
|
return null;
|
|
498
563
|
}
|
|
499
564
|
|
|
500
|
-
|
|
565
|
+
/**
|
|
566
|
+
* 이름 없는 아이콘의 그림. 사람은 모양(클립·종이비행기·X)으로 아는데 방문자는 "(icon 190,785)" 만 받아
|
|
567
|
+
* 채팅 보내기 대신 사진 첨부를 눌렀다(10/21 bluetags 2b72). 화면 안의 이름 없는 아이콘만 잘라 번호를 달아 한 장으로 붙인다.
|
|
568
|
+
* 없으면 null. 붙이는 판은 방문과 따로 연 빈 창이라 사이트에 아무것도 안 보낸다
|
|
569
|
+
*
|
|
570
|
+
* @param page 방문 중인 페이지(listControls 를 막 부른 뒤 — 번호가 그 목록 기준이다)
|
|
571
|
+
* @param items listControls().items
|
|
572
|
+
* @param browser 붙이는 판을 열 브라우저
|
|
573
|
+
*/
|
|
574
|
+
async function iconPicture(page, items, browser, max = 16) {
|
|
575
|
+
// 숫자뿐인 것(하트·말풍선 옆의 좋아요·답글 수)도 — 글자가 "0" 이라 이름이 있는 줄 알았는데 무엇의 수인지 몰랐다(10/21 실험실 i4)
|
|
576
|
+
const icons = items
|
|
577
|
+
.filter((it) => it.where === "in view" && (/^\(icon[ ]/.test(it.label) || /^\d{1,5}( \(|$)/.test(it.label)))
|
|
578
|
+
// 입력칸 곁(보내기·첨부)을 먼저 — 아이콘이 많은 화면에서 16 개 안에 들게
|
|
579
|
+
.sort((a, b) => (b.nearField ? 1 : 0) - (a.nearField ? 1 : 0))
|
|
580
|
+
.slice(0, max);
|
|
581
|
+
if (!icons.length || !browser) return null;
|
|
582
|
+
const vp = page.viewportSize() || { width: 1280, height: 860 };
|
|
583
|
+
const crops = [];
|
|
584
|
+
for (const it of icons) {
|
|
585
|
+
const el = await handleAt(page, it.i);
|
|
586
|
+
const box = el && (await el.boundingBox().catch(() => null));
|
|
587
|
+
if (!box || box.width < 2 || box.height < 2) continue;
|
|
588
|
+
// 둘레를 조금 넣어 자른다 — 아이콘만 딱 자르면 테두리·배경이 없어 모양이 안 읽힌다
|
|
589
|
+
const pad = 4;
|
|
590
|
+
const x = Math.max(0, box.x - pad);
|
|
591
|
+
const y = Math.max(0, box.y - pad);
|
|
592
|
+
const clip = { x, y, width: Math.min(vp.width - x, box.width + pad * 2), height: Math.min(vp.height - y, box.height + pad * 2) };
|
|
593
|
+
if (clip.width < 2 || clip.height < 2 || clip.width > 160 || clip.height > 160) continue;
|
|
594
|
+
const png = await page.screenshot({ clip, animations: "disabled", timeout: 3000 }).catch(() => null);
|
|
595
|
+
if (png) crops.push({ i: it.i, png, w: clip.width, h: clip.height });
|
|
596
|
+
}
|
|
597
|
+
if (!crops.length) return null;
|
|
598
|
+
const ctx = await browser.newContext({ viewport: { width: 640, height: 200 } });
|
|
599
|
+
try {
|
|
600
|
+
const sheet = await ctx.newPage();
|
|
601
|
+
// 작은 아이콘은 키운다(높이 48px 쯤) — 24px 그대로는 모양이 뭉개져 읽힌다
|
|
602
|
+
const cells = crops
|
|
603
|
+
.map((c) => {
|
|
604
|
+
const k = Math.max(1, Math.min(4, 48 / c.h));
|
|
605
|
+
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>`;
|
|
606
|
+
})
|
|
607
|
+
.join("");
|
|
608
|
+
await sheet.setContent(
|
|
609
|
+
`<!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">` +
|
|
610
|
+
`<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>`,
|
|
611
|
+
);
|
|
612
|
+
return await sheet.screenshot({ fullPage: true, timeout: 5000 });
|
|
613
|
+
} catch {
|
|
614
|
+
return null;
|
|
615
|
+
} finally {
|
|
616
|
+
await ctx.close().catch(() => {});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
module.exports = { OWN_SEL, listControls, visibleText, handleAt, clickBlocked, iconPicture };
|
package/src/run/visit.js
CHANGED
|
@@ -204,7 +204,7 @@ function renderScreen(look) {
|
|
|
204
204
|
? [view.inPanel ? "(There is more of the open panel below.)" : "(There is more of the page below.)"]
|
|
205
205
|
// 조금 남은 것(화면 맨 아래 버튼의 나머지, 여백)을 "더 있다" 고 하면 굴려도 새것이 없었다(10/21 실험실 s2·u2·w2)
|
|
206
206
|
: view.height - view.viewport - view.scrollY > 40
|
|
207
|
-
? ["(Nearly the bottom — only a little more below.)"]
|
|
207
|
+
? [view.inPanel ? "(Nearly the bottom of the open panel — only a little more below.)" : "(Nearly the bottom — only a little more below.)"]
|
|
208
208
|
: [view.inPanel ? "(This is the bottom of the open panel.)" : "(This is the bottom of the page.)"]),
|
|
209
209
|
].join("\n");
|
|
210
210
|
}
|
|
@@ -506,9 +506,10 @@ class Visit {
|
|
|
506
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"));
|
|
507
507
|
// 칸에 전에 친 글이 남아 있으면 지우고 친다 — 사람은 새 검색어를 옛 것 뒤에 잇지 않는다.
|
|
508
508
|
// 안 지우면 "PolygonPolygon" 이 되고 그게 사이트 문제로 보고된다(10/13 bluetags 3명)
|
|
509
|
-
const typed = String(decision.text || "").slice(0, 80);
|
|
510
|
-
const value = await el.evaluate((n) => String(n.value ?? n.textContent ?? "")).catch(() => "");
|
|
511
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(() => "");
|
|
512
513
|
/**
|
|
513
514
|
* Enter 는 사람이 누를 때만. 여러 줄 칸(댓글·답글)에서 Enter 는 줄바꿈이고, 사람은 Post 를 누른다.
|
|
514
515
|
* 치는 대로 결과가 걸러지는 칸에서는 사람은 Enter 없이 결과를 본다 — 그 칸이 <form> 안이면 Enter 가 폼을 보내
|
|
@@ -529,14 +530,41 @@ class Visit {
|
|
|
529
530
|
}
|
|
530
531
|
// 치기 전 화면. 치는 대로 걸러 주는 칸이면 친 뒤 화면이 이미 바뀌어 있다
|
|
531
532
|
const shownBefore = multiline ? "" : await visibleText(page, 2500);
|
|
533
|
+
// 채팅칸인지는 치기 **전** 높이로 본다 — 글 길이에 따라 커지는 칸은 긴 질문을 치면 여러 줄이 되어 채팅칸으로 안 보였다(10/21 bluetags 82fd)
|
|
534
|
+
const oneLine = multiline && (await el.evaluate((n) => n.getBoundingClientRect().height <= 64).catch(() => false));
|
|
532
535
|
record.typos = await hand.typeLikePerson(page, this.handState, typed).catch(() => 0);
|
|
533
536
|
let changed = false;
|
|
534
537
|
if (!multiline) {
|
|
535
538
|
await sleep(500 + Math.random() * 400);
|
|
536
|
-
|
|
537
|
-
|
|
539
|
+
// 친 글과 이어진 변화만 친다 — 새로 나타난 줄에 친 말이 들어 있어야 추천·걸러진 목록이다.
|
|
540
|
+
// 그냥 달라진 걸로 보면 저절로 넘어가는 배너가 "치는 대로 바뀌었다" 가 되어 Enter 를 안 눌렀다(10/21 실험실 i3)
|
|
541
|
+
// 걸러지는 목록(10/15 x40 커뮤니티 검색)은 새 줄 없이 줄이 빠지고 남은 것에 친 말이 있다
|
|
542
|
+
const word = typed.trim().toLowerCase();
|
|
543
|
+
const beforeLines = shownBefore.split("\n");
|
|
544
|
+
const afterText = await visibleText(page, 2500);
|
|
545
|
+
const afterLines = afterText.split("\n");
|
|
546
|
+
const before = new Set(beforeLines);
|
|
547
|
+
const after = new Set(afterLines);
|
|
548
|
+
const added = afterLines.filter((l) => !before.has(l));
|
|
549
|
+
const removed = beforeLines.filter((l) => !after.has(l));
|
|
550
|
+
const suggested = added.some((l) => l.toLowerCase().includes(word));
|
|
551
|
+
const filtered = removed.length >= 3 && added.length <= 1 && afterText.toLowerCase().includes(word);
|
|
552
|
+
changed = !!word && (suggested || filtered);
|
|
538
553
|
}
|
|
539
554
|
if (!multiline && !changed) await page.keyboard.press("Enter").catch(() => {});
|
|
555
|
+
/**
|
|
556
|
+
* 한 줄짜리 여러 줄 칸 = 채팅 입력칸. 사람은 메신저처럼 Enter 로 보낸다 — 안 누르면 이름 없는 보내기 아이콘을
|
|
557
|
+
* 못 찾아 메시지를 못 보냈다(10/21 bluetags 2b72, 커뮤니티 채팅). Enter 가 줄만 바꿨으면 Enter 로 안 보내는 칸이니 지운다
|
|
558
|
+
*/
|
|
559
|
+
if (oneLine && typed) {
|
|
560
|
+
await sleep(300 + Math.random() * 500);
|
|
561
|
+
await page.keyboard.press("Enter").catch(() => {});
|
|
562
|
+
await sleep(250);
|
|
563
|
+
const after = await el.evaluate((n) => String(n.value ?? n.innerText ?? "")).catch(() => "");
|
|
564
|
+
if (/\n\s*$/.test(after) && after.trim() === typed.trim()) await page.keyboard.press("Backspace").catch(() => {});
|
|
565
|
+
// 칸이 비었으면 Enter 로 보낸 것 — 방문자에게 말한다. 안 하면 "보내지도 않았는데 올라갔다" 가 됐다(10/21 실험실 g2)
|
|
566
|
+
else if (!after.trim()) record.sentWithEnter = true;
|
|
567
|
+
}
|
|
540
568
|
this.heldEnter = !multiline && changed ? typed : null;
|
|
541
569
|
if (this.heldEnter) record.heldEnter = true;
|
|
542
570
|
}
|