@usercase/mcp 0.2.2-next.3 → 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 +22 -3
- package/mcp/server.js +10 -3
- package/package.json +1 -1
- package/src/browser/page.js +73 -3
- package/src/run/visit.js +29 -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}`);
|
|
@@ -434,7 +448,10 @@ class Hub {
|
|
|
434
448
|
const said = [];
|
|
435
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.`);
|
|
436
450
|
if (r.skipped) said.push(`That did not happen: ${r.skipped}.`);
|
|
437
|
-
|
|
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.");
|
|
438
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.`);
|
|
439
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.");
|
|
440
457
|
if (r.fileWindow) said.push(r.fileWindow);
|
|
@@ -445,7 +462,9 @@ class Hub {
|
|
|
445
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.");
|
|
446
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.`);
|
|
447
464
|
if (r.missed) said.push("Your click landed just outside it and nothing happened.");
|
|
448
|
-
|
|
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.)`);
|
|
449
468
|
if (v.ended) {
|
|
450
469
|
if (v.pending) return [...said, "As you were leaving, a survey card appeared.", "", renderCard(v.pending)].join("\n");
|
|
451
470
|
const sum = await this.finish(rec);
|
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/page.js
CHANGED
|
@@ -302,7 +302,9 @@ async function listControls(page, max = 60) {
|
|
|
302
302
|
if (g.some((o) => o !== c && p.contains(o.el))) return "";
|
|
303
303
|
// 제 이름은 낱말째로만 뺀다 — 이름이 "0" 인 항목에서 날짜 "20" 의 0 이 빠져 "Oct 2" 가 됐다(10/21 실험실 w5)
|
|
304
304
|
const own = new RegExp(`(^|\\s)${c.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=\\s|$)`);
|
|
305
|
-
|
|
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);
|
|
306
308
|
};
|
|
307
309
|
for (const [label, g] of Object.entries(groups)) {
|
|
308
310
|
if (g.length < 2) continue;
|
|
@@ -312,7 +314,20 @@ async function listControls(page, max = 60) {
|
|
|
312
314
|
const h = g.map((c) => nearAt(c, d, g));
|
|
313
315
|
if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
|
|
314
316
|
}
|
|
315
|
-
|
|
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})`));
|
|
316
331
|
}
|
|
317
332
|
const els = [];
|
|
318
333
|
const out = picked.map((c, i) => {
|
|
@@ -338,6 +353,8 @@ async function listControls(page, max = 60) {
|
|
|
338
353
|
title: (document.title || "").slice(0, 120),
|
|
339
354
|
/** 화면을 덮은 창이 떠 있다 — 위치는 그 창 기준이다 */
|
|
340
355
|
cover: fixedCovers.length > 0,
|
|
356
|
+
/** 창이 떠 있다(굴러가는 칸이 없어도) — 위치 기준이 페이지에서 바뀐다 */
|
|
357
|
+
panelOpen: fixedCovers.length > 0 || dimOpen,
|
|
341
358
|
/** 굴리는 것이 페이지가 아니라 떠 있는 창 안쪽이다 */
|
|
342
359
|
// 본문이 안쪽 칸에서 굴러가는 앱형 사이트도 페이지가 잠겨 보인다 — 창이 실제로 떠 있을 때만
|
|
343
360
|
inPanel: !!pane && (fixedCovers.length > 0 || cand.some((c) => c.behind)),
|
|
@@ -473,6 +490,8 @@ const NEVER_WORDS = /log\s?out|logout|sign\s?out|로그아웃|탈퇴|delete\s+ac
|
|
|
473
490
|
* "예약하기" 처럼 기본 목록에 없는 버튼(2026-09-21 여기어때)
|
|
474
491
|
*/
|
|
475
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+)\)$/, "") };
|
|
476
495
|
if (policy) {
|
|
477
496
|
if (policy.labelBlocked(item.label)) return `blocked by policy ("${item.label}")`;
|
|
478
497
|
if (item.href) {
|
|
@@ -497,4 +516,55 @@ function clickBlocked(item, { allowAccounts = false, policy = null } = {}) {
|
|
|
497
516
|
return null;
|
|
498
517
|
}
|
|
499
518
|
|
|
500
|
-
|
|
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
|
@@ -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 가 폼을 보내
|
|
@@ -533,10 +534,33 @@ class Visit {
|
|
|
533
534
|
let changed = false;
|
|
534
535
|
if (!multiline) {
|
|
535
536
|
await sleep(500 + Math.random() * 400);
|
|
536
|
-
|
|
537
|
-
|
|
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);
|
|
538
551
|
}
|
|
539
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
|
+
}
|
|
540
564
|
this.heldEnter = !multiline && changed ? typed : null;
|
|
541
565
|
if (this.heldEnter) record.heldEnter = true;
|
|
542
566
|
}
|