@usercase/mcp 0.0.0-stage → 0.2.1-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -2
- package/agents/install.md +26 -0
- package/agents/path.md +49 -0
- package/agents/question.md +62 -0
- package/agents/research.md +113 -0
- package/agents/usercase-visitor.md +112 -0
- package/mcp/hub.js +760 -0
- package/mcp/path.js +394 -0
- package/mcp/report.js +207 -0
- package/mcp/server.js +679 -0
- package/mcp/workspace.js +182 -0
- package/package.json +41 -4
- package/src/browser/blocked.js +180 -0
- package/src/browser/card.js +269 -0
- package/src/browser/guard.js +211 -0
- package/src/browser/hand.js +368 -0
- package/src/browser/open.js +59 -0
- package/src/browser/page.js +353 -0
- package/src/browser/policy.js +154 -0
- package/src/browser/real-chrome.js +242 -0
- package/src/browser/robots.js +73 -0
- package/src/browser/signin.js +66 -0
- package/src/persona/people.js +104 -0
- package/src/persona/persona.js +469 -0
- package/src/prompts/answer.md +50 -0
- package/src/prompts/people.md +38 -0
- package/src/prompts/step.md +36 -0
- package/src/run/answer.js +94 -0
- package/src/run/diag.js +64 -0
- package/src/run/entry.js +76 -0
- package/src/run/runner.js +205 -0
- package/src/run/visit.js +693 -0
package/mcp/hub.js
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* MCP 서버 안에서 브라우저와 진행 중인 방문들을 쥔다.
|
|
4
|
+
*
|
|
5
|
+
* 판단은 고객의 AI 도구가 한다 — 가상 사용자 한 명 = 서브에이전트 하나가 이 도구들을 한 걸음씩 부른다.
|
|
6
|
+
* 여기는 **몸**만 한다: 브라우저 열기(open.js), 방어선(guard.js), 사람 손(Visit), 차단 알아보기(blocked.js).
|
|
7
|
+
* 서브에이전트가 무엇을 시키든 이 선은 코드가 지킨다.
|
|
8
|
+
*
|
|
9
|
+
* 서브에이전트 여럿이 한 서버를 같이 쓴다 — 브라우저 하나에 방문마다 컨텍스트 하나.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const playwright = require("playwright");
|
|
13
|
+
const persona = require("../src/persona/persona.js");
|
|
14
|
+
const { personKey } = require("../src/persona/people.js");
|
|
15
|
+
const entry = require("../src/run/entry.js");
|
|
16
|
+
const { Visit, renderScreen, renderPerson } = require("../src/run/visit.js");
|
|
17
|
+
const { createGuard } = require("../src/browser/guard.js");
|
|
18
|
+
const policyMod = require("../src/browser/policy.js");
|
|
19
|
+
const { loadRobots } = require("../src/browser/robots.js");
|
|
20
|
+
const { launchBrowser, openVisit } = require("../src/browser/open.js");
|
|
21
|
+
const { createBlockWatch, describeBlock } = require("../src/browser/blocked.js");
|
|
22
|
+
const { waitForSdk, agentKey, KEY_ENV, KEY_HEADER, AGENT_HEADER, uaWith } = require("../src/browser/card.js");
|
|
23
|
+
const ws = require("./workspace.js");
|
|
24
|
+
const signin = require("../src/browser/signin.js");
|
|
25
|
+
const { openRealChrome } = require("../src/browser/real-chrome.js");
|
|
26
|
+
const report = require("./report.js");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 가상 사용자 표시 이름. 서버가 이 이름으로 가상 사용자 답을 진짜 답과 갈라 센다
|
|
30
|
+
* (earningbirds-server insightFindingService.SYNTHETIC_AGENTS). 자유 탐방이라 qa-explore 와 같은 부류다.
|
|
31
|
+
*/
|
|
32
|
+
const AGENT = "qa-explore";
|
|
33
|
+
/** 동시에 열어 둘 방문 수. 같은 사이트를 한꺼번에 두드리면 쓰로틀링에 걸린다(9/21 사람인) */
|
|
34
|
+
const MAX_OPEN = 5;
|
|
35
|
+
/** 서브에이전트가 죽으면 방문이 열린 채 남는다. 이만큼 조용하면 닫는다 */
|
|
36
|
+
const IDLE_MS = 10 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 쓰기를 허용할 경로(정규식, 쉼표로). 로그인 뒤 화면은 조회도 POST 로 하는 사이트가 많다(GraphQL 등).
|
|
40
|
+
* 기본은 다 막는다 — 고객이 경로를 지정해 연다(PLAN.md "로그인")
|
|
41
|
+
*/
|
|
42
|
+
const ALLOW_WRITE_PATHS = (process.env.USERCASE_ALLOW_WRITE_PATHS || "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 고객이 정한 금지 구역. `USERCASE_POLICY` 에 JSON 파일 경로(browser/policy.js 설명 참고).
|
|
46
|
+
* 없으면 기본값 — 가입·로그인·결제·글쓰기처럼 사이트에 흔적을 남기는 조작을 막는다.
|
|
47
|
+
* 쓰기를 보낼지는 사용자가 고른다(set_writes → .usercase/settings.json). 고르면 그때부터 여는 방문에 걸린다
|
|
48
|
+
*/
|
|
49
|
+
const POLICY_FILE = process.env.USERCASE_POLICY || null;
|
|
50
|
+
const policyNow = () => policyMod.load(POLICY_FILE, { writes: ws.loadSettings().writes });
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 로컬 SDK 를 심어 돌리나(우리 내부 검증 — 운영 SDK 에 아직 가상 사용자 표시가 없다, PLAN.md "전제").
|
|
54
|
+
* 고객은 안 쓴다. 사이트에 박힌 SDK 로 돈다.
|
|
55
|
+
*/
|
|
56
|
+
function injectFromEnv() {
|
|
57
|
+
const project = process.env.USERCASE_INJECT_PROJECT;
|
|
58
|
+
if (!project) return null;
|
|
59
|
+
return {
|
|
60
|
+
project,
|
|
61
|
+
sdkSrc: process.env.USERCASE_SDK_SRC || "http://localhost:4000/dist/eb-plugin-web.js",
|
|
62
|
+
serverOrigin: process.env.USERCASE_SERVER_ORIGIN || "http://localhost:5000",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class Hub {
|
|
67
|
+
constructor() {
|
|
68
|
+
this.inject = injectFromEnv();
|
|
69
|
+
this.headed = process.env.USERCASE_HEADED === "1";
|
|
70
|
+
this.visits = new Map();
|
|
71
|
+
this.sites = new Map(); // origin → { guard, blockWatch, links }
|
|
72
|
+
this.browserP = null;
|
|
73
|
+
this.seq = 0;
|
|
74
|
+
// 시작할 때 비밀값을 한 번 검사한다 — 잘못된 값이 방문 도중 조용히 넘어가지 않게
|
|
75
|
+
this.keySet = !!agentKey();
|
|
76
|
+
setInterval(() => this.sweep(), 60 * 1000).unref();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
browser() {
|
|
80
|
+
if (!this.browserP) {
|
|
81
|
+
const localSdk = !!this.inject && /localhost|127\.0\.0\.1/.test(this.inject.sdkSrc);
|
|
82
|
+
this.browserP = launchBrowser(playwright, { headed: this.headed, localSdk }).catch((e) => {
|
|
83
|
+
this.browserP = null;
|
|
84
|
+
throw e;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return this.browserP;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
site(url) {
|
|
91
|
+
const origin = new URL(url).origin;
|
|
92
|
+
if (!this.sites.has(origin)) {
|
|
93
|
+
// 사이트마다 하나 — **반복되는지**가 "막혔나" 판단의 핵심이라 방문을 넘어 센다
|
|
94
|
+
const s = { origin, blockWatch: createBlockWatch(), links: null, robots: null };
|
|
95
|
+
this.arm(s);
|
|
96
|
+
this.sites.set(origin, s);
|
|
97
|
+
}
|
|
98
|
+
return this.sites.get(origin);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 지금 고른 쓰기 설정으로 방어선을 건다. 고르기를 바꾸면 다시 건다 — 이미 연 방문은 그대로다 */
|
|
102
|
+
arm(s) {
|
|
103
|
+
s.policy = policyNow();
|
|
104
|
+
// robots 는 나중에 읽는다(네트워크). 함수로 넘겨 읽히는 대로 반영되게
|
|
105
|
+
s.guard = createGuard({ site: s.origin, inject: this.inject, allowWritePaths: ALLOW_WRITE_PATHS, policy: s.policy, robots: () => s.robots });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 사이트에 쓰는 것을 보낼지. 사용자가 고른다 */
|
|
109
|
+
setWrites(mode) {
|
|
110
|
+
const next = ws.saveSettings({ writes: mode });
|
|
111
|
+
for (const s of this.sites.values()) this.arm(s);
|
|
112
|
+
return next.writes === "send"
|
|
113
|
+
? "Writes are now SENT: visitors can sign up, post, comment, save and like, and those really reach the site. Signed-in visitors act as the demo account — they may change its profile and settings, and later member visits on that account see the changes. Payments, deleting, wallet connections, sign-out and password changes stay blocked, and personal fields (email, phone) are still not typed into. Applies to visits opened from now on."
|
|
114
|
+
: "Writes are now NOT SENT: visitors may still press Post, Save or Follow, but anything a page tries to send is stopped, and the visitor is told it was not sent. Applies to visits opened from now on.";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 사이트가 적어 둔 robots.txt 를 한 번 읽는다. **방문 전에 불러야** 금지 경로로 안 간다.
|
|
119
|
+
* 우리가 그 사이트에 보내는 첫 요청이라 여기서부터 자기를 밝힌다(헤더·비밀값).
|
|
120
|
+
*/
|
|
121
|
+
async ensureRobots(s) {
|
|
122
|
+
if (s.robots) return s.robots;
|
|
123
|
+
const key = agentKey();
|
|
124
|
+
s.robots = await loadRobots(s.origin, {
|
|
125
|
+
headers: { [AGENT_HEADER]: AGENT, "User-Agent": uaWith(AGENT, "Mozilla/5.0 (compatible)"), ...(key ? { [KEY_HEADER]: key } : {}) },
|
|
126
|
+
});
|
|
127
|
+
process.stderr.write(`${s.origin} ${s.robots.describe()}\n`);
|
|
128
|
+
return s.robots;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async context(s, opts = {}) {
|
|
132
|
+
return openVisit(await this.browser(), playwright.devices, { agent: AGENT, site: s.origin, guard: s.guard, ...opts });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 돌리기 전 점검. 한 번 열어 보고 SDK·차단·비밀값을 말한다 */
|
|
136
|
+
async checkSite(url) {
|
|
137
|
+
const s = this.site(url);
|
|
138
|
+
await this.ensureRobots(s);
|
|
139
|
+
const watch = createBlockWatch({ minNetErrors: 1 });
|
|
140
|
+
const ctx = await this.context(s, { debugCards: false });
|
|
141
|
+
const out = [];
|
|
142
|
+
try {
|
|
143
|
+
const page = await ctx.newPage();
|
|
144
|
+
watch.watch(page, s.origin);
|
|
145
|
+
let status = null;
|
|
146
|
+
try {
|
|
147
|
+
const res = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
148
|
+
status = res ? res.status() : null;
|
|
149
|
+
} catch (e) {
|
|
150
|
+
const msg = String(e.message).split("\n")[0];
|
|
151
|
+
// 로컬 사이트가 안 켜져 있는 것이다. 차단으로 읽으면 방화벽 안내가 나간다(10/14 bluetags 3001 이 꺼져 있었다)
|
|
152
|
+
if (s.guard.local && /ERR_CONNECTION_REFUSED/.test(msg)) {
|
|
153
|
+
return `Nothing is running at ${s.origin}. Start the site's dev server (or check the port), then run check_site again.`;
|
|
154
|
+
}
|
|
155
|
+
// 본문 없는 5xx 는 goto 가 이 오류로 끝난다
|
|
156
|
+
if (/ERR_HTTP_RESPONSE_CODE_FAILURE/.test(msg)) status = "error";
|
|
157
|
+
else out.push(`Could not open the page: ${msg}`);
|
|
158
|
+
}
|
|
159
|
+
const title = await page.title().catch(() => "");
|
|
160
|
+
watch.notePage({ title, text: await page.evaluate(() => (document.body || {}).innerText || "").catch(() => ""), path: new URL(url).pathname });
|
|
161
|
+
const v = watch.verdict();
|
|
162
|
+
if (v) {
|
|
163
|
+
out.push(describeBlock(v, { keySet: this.keySet }));
|
|
164
|
+
out.push("", "→ Do not run visits while the site is blocking them. Make the change above first.");
|
|
165
|
+
return out.join("\n");
|
|
166
|
+
}
|
|
167
|
+
// 사이트가 오류를 낸다(10/15 bluetags 개발 서버가 방문 도중 500). 차단이 아니라 잠깐 죽은 것일 수 있다
|
|
168
|
+
if (status === "error" || status >= 500) {
|
|
169
|
+
return `The site is answering with an error (${status === "error" ? "an HTTP error with an empty page" : `HTTP ${status}`}) — it looks down right now. If it is a dev server, it may come back by itself in a minute or two; otherwise restart it. Then run check_site again.`;
|
|
170
|
+
}
|
|
171
|
+
const sdk = await waitForSdk(page, { timeoutMs: 15000 });
|
|
172
|
+
const info = await page.evaluate(() => ({ project: (window.Usercase || window.Earningbirds || {}).project || null })).catch(() => ({}));
|
|
173
|
+
out.push(`✓ Opened (HTTP ${status ?? "?"}) — "${title.slice(0, 80)}"`);
|
|
174
|
+
// 심어서 돌릴 때는 우리가 준 id 를 말한다. 페이지의 값은 SDK 가 객체를 갈아 끼운 뒤 사이트 스니펫이
|
|
175
|
+
// 운영 id 로 덮어써서 틀린다(SDK 는 시작할 때 한 번 읽어 기록은 제대로 간다 — guard.js injectSdk 참고)
|
|
176
|
+
if (sdk.ready) out.push(`✓ Usercase SDK is running — project ${this.inject ? `${this.inject.project} (local SDK injected)` : info.project ?? "?"}`);
|
|
177
|
+
else
|
|
178
|
+
out.push(
|
|
179
|
+
"⚠ The Usercase SDK is not running on this site. Visitors can still browse (and with an API key their visits still reach the dashboard), but no survey cards will appear.",
|
|
180
|
+
" If you are in this site's repository, install it: call install_snippet and follow it.",
|
|
181
|
+
);
|
|
182
|
+
// 우리 서버 쪽 키 — 방문 기록을 대시보드로 올린다(방화벽 비밀값과 다른 것)
|
|
183
|
+
out.push(await report.describeKey(sdk.ready ? (this.inject ? this.inject.project : info.project) : null));
|
|
184
|
+
out.push(
|
|
185
|
+
this.keySet
|
|
186
|
+
? `✓ Firewall secret is set — sent only to this site, in the X-Usercase-Key header`
|
|
187
|
+
: `· No firewall secret (${KEY_ENV}). Fine if the site has no firewall; if visits get blocked, see "Running on a production site" in the @usercase/mcp README`,
|
|
188
|
+
);
|
|
189
|
+
// 사용자가 창을 닫자마자 부르는 일이 많다. 닫힌 뒤에도 쿠키를 읽고 확인하는 데 수십 초가 걸린다 —
|
|
190
|
+
// 그 사이에 "아직 열려 있다" 고 하면 사용자는 닫았는데 왜 또 묻냐고 한다(10/14). 끝날 때까지 기다린다
|
|
191
|
+
await this.waitLogin();
|
|
192
|
+
const accs = this.loginOpen ? ws.accounts() : await this.verifyAccounts(s);
|
|
193
|
+
if (accs.length || this.loginOpen) out.push(describeAccounts(accs, this.loginOpen && (this.loginClosed() ? "saving" : "open")));
|
|
194
|
+
const g = s.guard.summary();
|
|
195
|
+
if (g) out.push(`· Safety guard on — analytics tools (GA, Hotjar, etc.) are blocked`);
|
|
196
|
+
out.push(
|
|
197
|
+
s.policy.writes === "send"
|
|
198
|
+
? "· Writes: SENT — visitors really sign up, post, comment and save on this site (payments, deleting and sign-out stay blocked). Change with set_writes"
|
|
199
|
+
: "· Writes: not sent — visitors may press Post or Save, but nothing is written to the site. Ask the user before the first round whether to send them (set_writes)",
|
|
200
|
+
);
|
|
201
|
+
// 도구가 하지 못한 것(진단) — 사이트 글자·주소 없이 종류와 요소 생김새만 우리 서버로 간다
|
|
202
|
+
out.push(
|
|
203
|
+
require("../src/run/diag.js").enabled()
|
|
204
|
+
? "· Diagnostics on — when a visitor cannot do something (a button cut off, covered, unreachable), the kind of failure and the element's shape (tag, icon, in a carousel …) go to Usercase to improve the tool. No text, addresses or input from the site. Turn off with USERCASE_DIAGNOSTICS=off"
|
|
205
|
+
: "· Diagnostics off (USERCASE_DIAGNOSTICS=off)",
|
|
206
|
+
);
|
|
207
|
+
// 지키는 선을 고객이 볼 수 있게. 막혔는데 왜인지 모르면 우리 도구 탓으로 읽힌다
|
|
208
|
+
out.push(`· ${s.robots.describe()} — disallowed paths are not visited`);
|
|
209
|
+
out.push(`· ${s.policy.describe().split("\n")[0]} (to change it, set ${"USERCASE_POLICY"} to a JSON file path)`);
|
|
210
|
+
// 방문자가 처음 보는 것. 메인 에이전트가 사람을 만들 때 코드보다 이걸 먼저 봐야 사이트를 제대로 안다
|
|
211
|
+
// (9/21 스모크: 코드에서 description 만 grep 하고 Web3 정보 사이트를 "카드 공유 서비스" 로 알았다)
|
|
212
|
+
const first = await page
|
|
213
|
+
.evaluate(() => {
|
|
214
|
+
const nav = [...document.querySelectorAll("nav a, header a, [role=navigation] a")]
|
|
215
|
+
.map((a) => (a.innerText || "").trim())
|
|
216
|
+
.filter(Boolean);
|
|
217
|
+
return { nav: [...new Set(nav)].slice(0, 20), text: (document.body.innerText || "").replace(/\n{2,}/g, "\n").slice(0, 1500) };
|
|
218
|
+
})
|
|
219
|
+
.catch(() => null);
|
|
220
|
+
if (first) {
|
|
221
|
+
out.push("", "## First screen (what a visitor sees on arrival)");
|
|
222
|
+
if (first.nav.length) out.push(`Menu: ${first.nav.join(" · ")}`);
|
|
223
|
+
out.push("```", first.text, "```");
|
|
224
|
+
}
|
|
225
|
+
// 착지 화면을 고를 실제 주소. 검색·링크로 온 사람은 이 중 목적에 맞는 곳으로 들어온다(save_people landing)
|
|
226
|
+
const links = await this.links(s).catch(() => []);
|
|
227
|
+
if (links.length) {
|
|
228
|
+
const groups = new Map();
|
|
229
|
+
for (const l of links) {
|
|
230
|
+
const shape = l.split(/[?#]/)[0].replace(/\/[0-9a-f]{12,}|\/\d+(?=\/|$)/gi, "/:id");
|
|
231
|
+
if (!groups.has(shape)) groups.set(shape, []);
|
|
232
|
+
groups.get(shape).push(l);
|
|
233
|
+
}
|
|
234
|
+
out.push("", "Pages linked from the front page (real paths for `landing`; the code's routes have more):");
|
|
235
|
+
const named = (l) => (links.text && links.text[l] ? `${l} "${links.text[l]}"` : l);
|
|
236
|
+
for (const [shape, ls] of groups) {
|
|
237
|
+
if (ls.length === 1 && ls[0] === shape) out.push(`- ${named(shape)}`);
|
|
238
|
+
else out.push(`- ${shape}`, ...ls.slice(0, 6).map((l) => ` ${named(l)}`), ...(ls.length > 6 ? [` (+${ls.length - 6} more)`] : []));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return out.join("\n");
|
|
242
|
+
} finally {
|
|
243
|
+
await ctx.close().catch(() => {});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 사이트 안 링크. 검색·공유로 들어오는 사람이 떨어질 자리다. 사이트마다 한 번만 모은다 */
|
|
248
|
+
async links(s) {
|
|
249
|
+
if (s.links) return s.links;
|
|
250
|
+
const ctx = await this.context(s, { debugCards: false });
|
|
251
|
+
try {
|
|
252
|
+
const page = await ctx.newPage();
|
|
253
|
+
s.blockWatch.watch(page, s.origin);
|
|
254
|
+
s.links = await entry.collectLinks(page, s.origin).catch(() => []);
|
|
255
|
+
} finally {
|
|
256
|
+
await ctx.close().catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
return s.links;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 가상 사용자 한 명의 방문을 연다. 그 사람이 누구인지와 첫 화면을 돌려준다 */
|
|
262
|
+
async open(who) {
|
|
263
|
+
const doc = ws.loadPeople();
|
|
264
|
+
if (!doc.site) throw new Error("people.json has no site. Create people with save_people first.");
|
|
265
|
+
const p = doc.people.find((x) => x.id === who);
|
|
266
|
+
if (!p) throw new Error(`"${who}" is not in people.json. People: ${doc.people.map((x) => x.id).join(", ")}`);
|
|
267
|
+
const open = [...this.visits.values()].filter((v) => !v.closed).length;
|
|
268
|
+
if (open >= MAX_OPEN) throw new Error(`At most ${MAX_OPEN} visits can run at once. Wait for one to finish before opening another.`);
|
|
269
|
+
const s = this.site(doc.site);
|
|
270
|
+
const blockedAlready = s.blockWatch.verdict();
|
|
271
|
+
if (blockedAlready) return { stop: true, text: describeBlock(blockedAlready, { keySet: this.keySet }) + "\n\n→ The site is blocking visitors, so the visit was not opened. Stop running visits and tell the user what is above." };
|
|
272
|
+
|
|
273
|
+
await this.ensureRobots(s);
|
|
274
|
+
const links = await this.links(s);
|
|
275
|
+
const traits = persona.coerceTraits(p.traits, personKey(p)).traits;
|
|
276
|
+
const rhythm = new persona.Rhythm({ traits, visits: 1, device: p.device, source: p.traits ? "given" : "seed", seed: persona.seedOf(personKey(p)) });
|
|
277
|
+
const shape = persona.drawShape(Math.random, traits, persona.familiarityOf(1));
|
|
278
|
+
// 회원이면 고객이 login 으로 저장한 세션으로 들어간다. 비번은 우리가 모른다
|
|
279
|
+
let storageState;
|
|
280
|
+
let account = null;
|
|
281
|
+
if (p.member) {
|
|
282
|
+
account = pickAccount(p);
|
|
283
|
+
storageState = account.path;
|
|
284
|
+
}
|
|
285
|
+
// 사이트 밖으로 가려던 주소. 방어선이 끊고 여기 적는다 — 방문자에게 "다른 사이트로 가는 링크" 로 알린다
|
|
286
|
+
const offsite = [];
|
|
287
|
+
// 사이트에 쓰려다 끊긴 요청. 방문자에게 "보내지 않았다" 로 알린다 — 모르면 "저장이 안 된다" 가 사이트 문제로 기록된다
|
|
288
|
+
const notSent = [];
|
|
289
|
+
// 로컬 사이트가 바깥 서버에 닿으려다 끊긴 것(host). 방문자에게 "이 시험 실행에서는 거기 안 닿는다" 로 알린다
|
|
290
|
+
const external = [];
|
|
291
|
+
const ctx = await this.context(s, {
|
|
292
|
+
device: p.device,
|
|
293
|
+
storageState,
|
|
294
|
+
onOffsite: (u) => offsite.push(u),
|
|
295
|
+
onWrite: (k) => notSent.push(k),
|
|
296
|
+
onExternal: (h) => external.push(h),
|
|
297
|
+
persona: { id: p.id, device: p.device, background: p.background, goal: p.goal, moment: p.moment, attention: p.attention, surveyHabit: p.surveyHabit, writing: p.writing, traits: p.traits, path: "free exploration" },
|
|
298
|
+
});
|
|
299
|
+
const page = await ctx.newPage();
|
|
300
|
+
s.blockWatch.watch(page, s.origin);
|
|
301
|
+
// 새 탭으로 여는 링크. 방문은 한 탭에서 하므로 닫는다 — 사이트 밖이면 방어선이 이미 적어 두었다
|
|
302
|
+
// 버튼이 스크립트로 여는 탭은 링크 주소가 없다 — 탭이 가려던 주소를 보고 적는다
|
|
303
|
+
ctx.on("page", (pop) => {
|
|
304
|
+
if (pop === page) return;
|
|
305
|
+
let noted = false;
|
|
306
|
+
const note = (u) => {
|
|
307
|
+
// http(s) 만 본다. 방어선이 끊은 탭은 크롬 오류 화면(chrome-error://chromewebdata)이 되는데,
|
|
308
|
+
// 그걸 갈 곳으로 적으면 "chromewebdata 라는 다른 사이트" 로 안내됐다(10/14 실험실 p22)
|
|
309
|
+
if (noted || !/^https?:/i.test(u || "")) return;
|
|
310
|
+
noted = true;
|
|
311
|
+
let same = false;
|
|
312
|
+
try {
|
|
313
|
+
same = new URL(u).origin === s.origin;
|
|
314
|
+
} catch {}
|
|
315
|
+
if (!same) return void offsite.push(u);
|
|
316
|
+
// 같은 사이트를 새 탭으로 여는 링크. 사람은 열린 탭에서 이어 본다 — 닫기만 하면 "눌러도 안 열린다" 가 된다
|
|
317
|
+
// (10/14 0.1.9 bluetags 프로젝트 카드 "See more" 가 target=_blank). 탭을 하나로 두려고 보던 탭을 그 주소로 옮긴다
|
|
318
|
+
pop.close().catch(() => {});
|
|
319
|
+
// 조작 뒤 기다림(settle)이 이 이동을 기다리게 걸어 둔다. 안 그러면 느린 화면에서 기다림이 먼저 끝나
|
|
320
|
+
// 방문자는 "안 열렸다" 를 보고, 다시 누르면 그새 넘어가 "없어졌다" 가 된다(10/14 실험실 p3)
|
|
321
|
+
const going = page
|
|
322
|
+
.goto(u, { waitUntil: "domcontentloaded", referer: page.url(), timeout: 30000 })
|
|
323
|
+
.catch(() => {})
|
|
324
|
+
.finally(() => {
|
|
325
|
+
if (page.__ebPendingNav === going) page.__ebPendingNav = null;
|
|
326
|
+
});
|
|
327
|
+
page.__ebPendingNav = going;
|
|
328
|
+
};
|
|
329
|
+
pop.on("request", (req) => {
|
|
330
|
+
if (req.isNavigationRequest()) note(req.url());
|
|
331
|
+
});
|
|
332
|
+
note(pop.url());
|
|
333
|
+
// 새 탭 소식은 늦게, 주소가 빈 채로 올 때가 있다 — 주소가 생길 때까지 3초 본다. 그래도 없으면 닫는다
|
|
334
|
+
(async () => {
|
|
335
|
+
for (let i = 0; i < 30 && !noted && !pop.isClosed(); i++) {
|
|
336
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
337
|
+
note(pop.url());
|
|
338
|
+
}
|
|
339
|
+
pop.close().catch(() => {});
|
|
340
|
+
})();
|
|
341
|
+
});
|
|
342
|
+
const id = `v${++this.seq}`;
|
|
343
|
+
// 정해 둔 착지 화면이 금지 경로(robots.txt·정책)면 쓰지 않는다 — 무작위 상세나 홈으로 들어온다
|
|
344
|
+
let landingOff = null;
|
|
345
|
+
if (p.landing) {
|
|
346
|
+
const pathOnly = p.landing.split(/[?#]/)[0];
|
|
347
|
+
landingOff = (s.robots && s.robots.blocked(pathOnly) && "robots.txt") || s.policy.pathReason(pathOnly);
|
|
348
|
+
}
|
|
349
|
+
const how = entry.chooseEntry(landingOff ? { ...p, landing: undefined } : p, links);
|
|
350
|
+
if (landingOff) process.stderr.write(`${p.id}: landing ${p.landing} is off limits (${landingOff}) — entering elsewhere\n`);
|
|
351
|
+
const rec = { id, person: p, site: s, ctx, page, how, startedAt: Date.now(), touched: Date.now(), closed: false, visit: null };
|
|
352
|
+
this.visits.set(id, rec);
|
|
353
|
+
|
|
354
|
+
await page.goto(s.origin + how.path, { waitUntil: "domcontentloaded", referer: how.referer || undefined, timeout: 60000 }).catch(() => {});
|
|
355
|
+
// 화면이 보여도 사이트 코드가 아직 안 붙었으면 버튼이 안 먹는다. 바로 상세·달력으로 들어오면 첫 클릭이 그렇게 사라졌다
|
|
356
|
+
// (10/15 실험실 p14, 달력 Month). 사람도 다 뜰 때까지 잠깐 본다 — 조용해질 때까지 최대 10초
|
|
357
|
+
await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {});
|
|
358
|
+
// 차단 페이지는 정상 응답으로 와서 goto 가 성공한다. 화면을 보고 가린다 — 막혔으면 걷지 않는다
|
|
359
|
+
const blocked = s.blockWatch.notePage({
|
|
360
|
+
title: await page.title().catch(() => ""),
|
|
361
|
+
text: await page.evaluate(() => (document.body || {}).innerText || "").catch(() => ""),
|
|
362
|
+
path: how.path,
|
|
363
|
+
});
|
|
364
|
+
if (blocked) {
|
|
365
|
+
await this.finish(rec, { blocked: true });
|
|
366
|
+
const v = s.blockWatch.verdict();
|
|
367
|
+
return { stop: true, text: (v ? describeBlock(v, { keySet: this.keySet }) : "The site showed a block page.") + "\n\n→ Blocked. Stop running visits and tell the user what is above." };
|
|
368
|
+
}
|
|
369
|
+
// 회원인데 화면에 로그인 링크가 있다 — 세션이 풀렸다. 이 계정으로는 더 걷지 않는다(비회원 화면이 회원 기록이 된다)
|
|
370
|
+
// 확인 때 "로그인하면 링크가 사라진다" 를 본 사이트만 이걸로 판단한다
|
|
371
|
+
if (account && account.status === "signed_in") {
|
|
372
|
+
const marks = await signin.signInMarks(page);
|
|
373
|
+
if (marks.length) {
|
|
374
|
+
ws.setAccountStatus(account.account, "expired", `a member visit still saw ${marks.slice(0, 2).join(", ")}`);
|
|
375
|
+
this.visits.delete(id);
|
|
376
|
+
rec.closed = true;
|
|
377
|
+
await ctx.close().catch(() => {});
|
|
378
|
+
throw new Error(
|
|
379
|
+
`Account ${account.account}'s sign-in has expired, so ${p.id} was not visited (it would have been a signed-out visit recorded as a member). Ask the user to sign in again with login (account: "${account.account}") — never ask for a password. Other members use the remaining accounts.`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
rec.account = account ? account.account : null;
|
|
384
|
+
rec.visit = new Visit(page, p, { rhythm, shape, policy: s.policy, offsite, notSent, external });
|
|
385
|
+
const sdk = await rec.visit.begin();
|
|
386
|
+
const arrived =
|
|
387
|
+
how.how === "direct" ? `You typed the address and landed on ${how.path}.` : `You came from ${how.how} (${how.referer || "a link"}) and landed on ${how.path}.`;
|
|
388
|
+
// **누구인지 먼저.** 이게 없으면 서브에이전트가 이름만 보고 짐작해서 걷는다(9/21 그랬다)
|
|
389
|
+
const lines = [`visit_id: ${id}`, "", "## You", renderPerson(p, { card: true }), "", arrived];
|
|
390
|
+
if (p.member) lines.push("You already have an account here and are signed in.");
|
|
391
|
+
if (!sdk.ready) lines.push("(note for whoever runs this: the Usercase SDK did not start on this page — this visit may not be recorded)");
|
|
392
|
+
lines.push("", await this.screen(rec));
|
|
393
|
+
return { id, text: lines.join("\n") };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 지금 화면(또는 떠 있는 카드)을 에이전트가 읽을 글로 */
|
|
397
|
+
async screen(rec) {
|
|
398
|
+
const v = rec.visit;
|
|
399
|
+
const look = await v.look();
|
|
400
|
+
if (look.ended) return "This visit has ended.";
|
|
401
|
+
if (look.card) return renderCard(look.card);
|
|
402
|
+
const lines = [renderScreen(look)];
|
|
403
|
+
// 인내심이 다 됐다는 단정이 아니라 조건이다. "볼 만큼 봤다" 고 하면 방문자가 그 자리에서 나갔다 — 진전이 있으면 이어 간다(10/14)
|
|
404
|
+
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
|
+
return lines.join("\n");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
get(id) {
|
|
409
|
+
const rec = this.visits.get(id);
|
|
410
|
+
if (!rec) throw new Error(`No visit ${id}`);
|
|
411
|
+
if (rec.closed) throw new Error(`Visit ${id} has already ended`);
|
|
412
|
+
rec.touched = Date.now();
|
|
413
|
+
return rec;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 한 걸음. 끝나면 기록하고 닫는다 */
|
|
417
|
+
async act(id, decision) {
|
|
418
|
+
const rec = this.get(id);
|
|
419
|
+
const v = rec.visit;
|
|
420
|
+
const r = await v.act(decision);
|
|
421
|
+
if (r.interrupted) return ["Before you could do that, a survey card appeared.", "", await this.screen(rec)].join("\n");
|
|
422
|
+
const said = [];
|
|
423
|
+
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
|
+
if (r.skipped) said.push(`That did not happen: ${r.skipped}.`);
|
|
425
|
+
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
|
+
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
|
+
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
|
+
if (r.fileWindow) said.push(r.fileWindow);
|
|
429
|
+
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.");
|
|
430
|
+
if (r.swiped) said.push("It was cut off at the edge, so you swiped that row sideways to bring it into view, then tapped it.");
|
|
431
|
+
// 개인정보 칸. 사이트가 막은 게 아니라 이 사람이 안 친 것이다 — 그렇게 알린다
|
|
432
|
+
if (r.personalField !== undefined) said.push(`You don't type personal details such as your email, phone number or real name into this site on this visit, so you left that field empty. Carry on as you would without filling it in — skip it, or go elsewhere.`);
|
|
433
|
+
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
|
+
if (r.missed) said.push("Your click landed just outside it and nothing happened.");
|
|
435
|
+
if (r.typos) said.push(`(You made ${r.typos} typo(s) and fixed them.)`);
|
|
436
|
+
if (v.ended) {
|
|
437
|
+
if (v.pending) return [...said, "As you were leaving, a survey card appeared.", "", renderCard(v.pending)].join("\n");
|
|
438
|
+
const sum = await this.finish(rec);
|
|
439
|
+
return [...said, "You left the site. This visit is over.", "", sum].join("\n");
|
|
440
|
+
}
|
|
441
|
+
return [...said, ...(said.length ? [""] : []), await this.screen(rec)].join("\n");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** 카드에 답하거나 닫는다 */
|
|
445
|
+
async answer(id, decision) {
|
|
446
|
+
const rec = this.get(id);
|
|
447
|
+
const v = rec.visit;
|
|
448
|
+
const c = await v.answer(decision);
|
|
449
|
+
const done = c.result === "close" ? "You closed the card." : `You answered: ${c.answer}`;
|
|
450
|
+
if (v.ended) {
|
|
451
|
+
const sum = await this.finish(rec);
|
|
452
|
+
return [done, "This visit is over.", "", sum].join("\n");
|
|
453
|
+
}
|
|
454
|
+
return [done, "", await this.screen(rec)].join("\n");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** 방문을 닫고 기록을 남긴다. 사람 파일에 쓴 날을 적는다 */
|
|
458
|
+
async finish(rec, extra = {}) {
|
|
459
|
+
if (rec.closed) return "";
|
|
460
|
+
rec.closed = true;
|
|
461
|
+
const res = rec.visit ? rec.visit.result() : { steps: [], card: null, shape: null };
|
|
462
|
+
const row = {
|
|
463
|
+
at: new Date().toISOString(),
|
|
464
|
+
site: rec.site.origin,
|
|
465
|
+
person: rec.person.id,
|
|
466
|
+
personUid: rec.person.uid,
|
|
467
|
+
device: rec.person.device,
|
|
468
|
+
goal: rec.person.goal,
|
|
469
|
+
entry: rec.how,
|
|
470
|
+
steps: res.steps,
|
|
471
|
+
pages: [...new Set(res.steps.map((s) => s.path))],
|
|
472
|
+
card: res.card,
|
|
473
|
+
ms: Date.now() - rec.startedAt,
|
|
474
|
+
sdkReady: rec.visit ? !!rec.visit.sdk?.ready : null,
|
|
475
|
+
...(rec.account ? { account: rec.account } : {}),
|
|
476
|
+
// 도구가 하지 못한 것 — 고객도 자기 기록에서 볼 수 있게 같이 남긴다(서버로 가는 것과 같은 내용)
|
|
477
|
+
...(rec.visit && rec.visit.diag && rec.visit.diag.length ? { diag: rec.visit.diag } : {}),
|
|
478
|
+
...extra,
|
|
479
|
+
};
|
|
480
|
+
ws.appendRun(row);
|
|
481
|
+
// 대시보드로 — 키가 없으면 건너뛴다. 실패해도 방문은 끝낸다
|
|
482
|
+
const uploaded = await report.sendVisit(row, rec.person, rec.startedAt).catch((e) => `⚠ Could not upload to the dashboard (${e.message})`);
|
|
483
|
+
// 도구가 하지 못한 것들 — 우리 쪽 진단으로(사이트 글자 없음). 기본으로 켜져 있다, USERCASE_DIAGNOSTICS=off 로 끈다
|
|
484
|
+
if (rec.visit && rec.visit.diag && rec.visit.diag.length) await report.sendDiagnostics(rec.visit.diag, rec.person.device).catch(() => 0);
|
|
485
|
+
const doc = ws.loadPeople();
|
|
486
|
+
const p = doc.people.find((x) => x.id === rec.person.id);
|
|
487
|
+
if (p) {
|
|
488
|
+
p.usedAt = row.at;
|
|
489
|
+
ws.savePeople(doc);
|
|
490
|
+
}
|
|
491
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
492
|
+
await rec.ctx.close().catch(() => {});
|
|
493
|
+
return [summarize(row), uploaded].filter(Boolean).join("\n");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 고객이 직접 로그인하게 브라우저를 띄운다. **비밀번호는 우리를 지나가지 않는다** — 로그인된 세션(쿠키)만 저장한다.
|
|
498
|
+
* 도구는 바로 돌아온다. 창이 열려 있는 동안 몇 초마다 세션을 저장하고, 창을 닫으면 끝난다.
|
|
499
|
+
*
|
|
500
|
+
* 방어선(guard)은 걸지 않는다 — 로그인 자체가 쓰기(POST)이고, 누르는 사람은 고객이다.
|
|
501
|
+
* 가상 사용자 표시(헤더·비밀값)는 붙인다 — 방화벽을 지나야 하고, 이 방문이 진짜 방문자로 세어지면 안 된다.
|
|
502
|
+
* 구글 등 일부 소셜 로그인은 자동화된 브라우저를 거절할 수 있다. 그때는 데모 계정의 아이디·비번 로그인을 쓰게 안내한다.
|
|
503
|
+
*/
|
|
504
|
+
/**
|
|
505
|
+
* @param window "chrome"(일반 크롬 — 구글·소셜 로그인이 된다) | "playwright"(닫기 전에 읽는다 — 브라우저를 닫으면 지워지는 쿠키도 남는다). 없으면 chrome 부터
|
|
506
|
+
* @param lifetimeH 코드로 본 세션 수명(시간). 알면 언제 끝나는지 보여 주고, 지나면 그 계정을 쓰지 않는다
|
|
507
|
+
*/
|
|
508
|
+
/** 로그인 창이 닫혔나(닫혔어도 저장·확인 중이면 loginOpen 은 아직 true) */
|
|
509
|
+
loginClosed() {
|
|
510
|
+
if (!this.loginOpen) return true;
|
|
511
|
+
if (this.loginReal) return this.loginReal.closed();
|
|
512
|
+
return !!this.loginFinishing;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** 로그인 창 마무리를 기다린다. 창이 열려 있으면 조금(방금 닫는 중일 수 있다), 닫혔으면 확인이 끝날 때까지 */
|
|
516
|
+
async waitLogin() {
|
|
517
|
+
const t0 = Date.now();
|
|
518
|
+
while (this.loginOpen && Date.now() - t0 < (this.loginClosed() ? 90000 : 15000)) await new Promise((r) => setTimeout(r, 500));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async login(url, account, { window: win = null, lifetimeH = null } = {}) {
|
|
522
|
+
if (this.loginOpen) return "A sign-in window is already open. Sign in, then close that window.";
|
|
523
|
+
ws.ensure();
|
|
524
|
+
account = account || ws.nextAccount();
|
|
525
|
+
if (!ws.ACCOUNT_NAME.test(account)) throw new Error(`Account name "${account}" — use letters, numbers, - or _ (e.g. a1, a2)`);
|
|
526
|
+
const file = ws.sessionPath(account);
|
|
527
|
+
const s = this.site(url);
|
|
528
|
+
|
|
529
|
+
// 먼저 PC 의 일반 크롬으로. 자동화 브라우저는 구글·소셜 로그인이 막힌다(10/14 bluetags Google)
|
|
530
|
+
// 일반 크롬은 창을 닫을 때 "브라우저를 닫으면 지워지는" 쿠키를 버린다. 그런 쿠키로 로그인하는 사이트는
|
|
531
|
+
// 크롬으로 해도 로그인 안 됨으로 나온다 — 그 계정은 다음부터 닫기 전에 읽는 Playwright 창으로 연다(10/14)
|
|
532
|
+
this.chromeDropped = this.chromeDropped || new Set();
|
|
533
|
+
const viaChrome = win !== "playwright" && process.env.USERCASE_LOGIN_BROWSER !== "playwright" && !this.chromeDropped.has(`${s.origin} ${account}`);
|
|
534
|
+
const extra = { ...(typeof lifetimeH === "number" && lifetimeH > 0 ? { lifetimeH } : {}) };
|
|
535
|
+
if (viaChrome) {
|
|
536
|
+
const real = await openRealChrome({
|
|
537
|
+
url: s.origin + "/",
|
|
538
|
+
origin: s.origin,
|
|
539
|
+
onSave: (state) => {
|
|
540
|
+
try {
|
|
541
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2));
|
|
542
|
+
} catch {}
|
|
543
|
+
},
|
|
544
|
+
onDone: async () => {
|
|
545
|
+
await this.verifyAccounts(s, [account]).catch(() => {});
|
|
546
|
+
const a = ws.accounts().find((x) => x.account === account);
|
|
547
|
+
ws.setAccountStatus(account, a ? a.status : "unchecked", a ? a.note : null, { ...extra, via: "chrome" });
|
|
548
|
+
if (a && a.status === "signed_out") {
|
|
549
|
+
this.chromeDropped.add(`${s.origin} ${account}`);
|
|
550
|
+
ws.setAccountStatus(
|
|
551
|
+
account,
|
|
552
|
+
"signed_out",
|
|
553
|
+
`${a.note}. This site may sign people in with a cookie that Chrome deletes when its window closes — the next login for this account opens a different window that keeps it (Google sign-in may not work there; use email and password)`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
this.loginReal = null;
|
|
557
|
+
this.loginOpen = false;
|
|
558
|
+
},
|
|
559
|
+
}).catch(() => null);
|
|
560
|
+
if (real) {
|
|
561
|
+
this.loginOpen = true;
|
|
562
|
+
this.loginReal = real;
|
|
563
|
+
return [
|
|
564
|
+
`A Chrome window for account "${account}" is open on the user's screen, on the site's first screen.`,
|
|
565
|
+
"1. Sign in there yourself with a **demo account**, the way you normally would — Google and other sign-in buttons work in this window. With a real account, the visitors' activity would mix into that account's history.",
|
|
566
|
+
`2. When you are signed in, close the window. Only this site's cookies are saved, as account "${account}" in .usercase/sessions/ — the password is never seen or stored, and the window uses a fresh, empty Chrome profile that is deleted afterwards.`,
|
|
567
|
+
"3. Then run check_site. It checks whether the sign-in really worked; if it did not, open login again for the same account. Never ask for the password.",
|
|
568
|
+
].join("\n");
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const browser = await launchBrowser(playwright, { headed: true, localSdk: !!this.inject && /localhost|127\.0\.0\.1/.test(this.inject.sdkSrc) });
|
|
573
|
+
const noGuard = { apply: async () => {}, local: false };
|
|
574
|
+
const ctx = await openVisit(browser, playwright.devices, { agent: AGENT, site: s.origin, guard: noGuard, debugCards: false });
|
|
575
|
+
const page = await ctx.newPage();
|
|
576
|
+
this.loginOpen = true;
|
|
577
|
+
this.loginBrowser = browser;
|
|
578
|
+
let saved = 0;
|
|
579
|
+
// ctx.storageState() 는 부를 때마다 빈 탭을 열었다 닫는다 — 3초마다 부르니 로그인 창이 깜빡였다(10/14).
|
|
580
|
+
// 쿠키는 탭 없이 읽고, localStorage 는 열려 있는 사이트 페이지에서 읽는다. 사이트 것만 있으면 된다
|
|
581
|
+
let siteStorage = null;
|
|
582
|
+
const save = async () => {
|
|
583
|
+
try {
|
|
584
|
+
const cookies = await ctx.cookies();
|
|
585
|
+
if (page.url().startsWith(s.origin)) {
|
|
586
|
+
siteStorage = await page.evaluate(() => Object.entries(localStorage).map(([name, value]) => ({ name, value }))).catch(() => siteStorage);
|
|
587
|
+
}
|
|
588
|
+
const state = { cookies, origins: siteStorage ? [{ origin: s.origin, localStorage: siteStorage }] : [] };
|
|
589
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2));
|
|
590
|
+
saved++;
|
|
591
|
+
} catch {}
|
|
592
|
+
};
|
|
593
|
+
// 창을 통째로 닫으면 컨텍스트가 먼저 사라져 마지막 저장을 못 할 수 있다. 열려 있는 동안 계속 저장한다
|
|
594
|
+
const timer = setInterval(save, 3000);
|
|
595
|
+
let finishing = false;
|
|
596
|
+
const done = async () => {
|
|
597
|
+
if (finishing) return;
|
|
598
|
+
finishing = true;
|
|
599
|
+
this.loginFinishing = true;
|
|
600
|
+
clearInterval(timer);
|
|
601
|
+
await save();
|
|
602
|
+
await browser.close().catch(() => {});
|
|
603
|
+
// 창을 닫았다고 로그인된 게 아니다. 세션을 넣은 화면과 안 넣은 화면을 비교해 둔다 — check_site 가 보여 준다
|
|
604
|
+
await this.verifyAccounts(s, [account]).catch(() => {});
|
|
605
|
+
const a = ws.accounts().find((x) => x.account === account);
|
|
606
|
+
if (a) {
|
|
607
|
+
// 두 창 다 해 봤는데 안 됐다 — 이 사이트는 이 도구로 로그인한 방문을 못 할 수 있다. 사용자가 알아야 한다
|
|
608
|
+
const both = this.chromeDropped.has(`${s.origin} ${account}`);
|
|
609
|
+
const note =
|
|
610
|
+
a.status === "signed_out" && both
|
|
611
|
+
? `${a.note}. Both sign-in windows were tried. If the user did sign in, signed-in visits are not possible on this site with this tool — tell the user plainly, and run newcomers only`
|
|
612
|
+
: a.note;
|
|
613
|
+
ws.setAccountStatus(account, a.status, note, { ...extra, via: "playwright" });
|
|
614
|
+
}
|
|
615
|
+
this.loginOpen = false;
|
|
616
|
+
this.loginFinishing = false;
|
|
617
|
+
};
|
|
618
|
+
page.on("close", done);
|
|
619
|
+
// 창을 통째로 닫아도 같은 마무리 — 마지막 저장은 못 해도 열려 있는 동안 저장한 것으로 확인한다
|
|
620
|
+
browser.on("disconnected", done);
|
|
621
|
+
// 10분 지나면 닫는다
|
|
622
|
+
setTimeout(done, 10 * 60 * 1000).unref();
|
|
623
|
+
// 사이트 첫 화면을 먼저 연다. 로그인 뒤 "뒤로" 돌려보내는 사이트가 있다 — 빈 탭에서 바로 열면 about:blank 로 간다(10/14 bluetags router.back)
|
|
624
|
+
(async () => {
|
|
625
|
+
if (new URL(url).pathname !== "/") await page.goto(s.origin + "/", { waitUntil: "domcontentloaded" }).catch(() => {});
|
|
626
|
+
await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => {});
|
|
627
|
+
})();
|
|
628
|
+
return [
|
|
629
|
+
`A sign-in window for account "${account}" is open on the user's screen.`,
|
|
630
|
+
"1. Sign in there yourself with a **demo account**. With a real account, the visitors' activity would mix into that account's history.",
|
|
631
|
+
`2. When you are signed in, close the window. Only the session (cookies) is saved, as account "${account}" in .usercase/sessions/ — the password is never seen or stored.`,
|
|
632
|
+
"3. Then run check_site. It checks whether the sign-in really worked; if it did not, open login again for the same account. Never ask for the password.",
|
|
633
|
+
"If Google sign-in or similar refuses with 'this browser may not be secure', use an email and password sign-in instead.",
|
|
634
|
+
].join("\n");
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* 계정마다 로그인이 됐나 본다. 세션 없는 화면 한 번 + 계정마다 한 번.
|
|
638
|
+
* @param only 이 계정들만(없으면 전부)
|
|
639
|
+
*/
|
|
640
|
+
async verifyAccounts(s, only = null) {
|
|
641
|
+
const list = ws.accounts().filter((a) => !only || only.includes(a.account));
|
|
642
|
+
if (!list.length) return ws.accounts();
|
|
643
|
+
const url = s.origin + "/";
|
|
644
|
+
const anonCtx = await this.context(s, { debugCards: false });
|
|
645
|
+
const anon = await signin.marksAt(anonCtx, url).finally(() => anonCtx.close().catch(() => {}));
|
|
646
|
+
for (const a of list) {
|
|
647
|
+
const ctx = await this.context(s, { debugCards: false, storageState: a.path }).catch(() => null);
|
|
648
|
+
if (!ctx) {
|
|
649
|
+
ws.setAccountStatus(a.account, "signed_out", "the saved session could not be read");
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
const marks = await signin.marksAt(ctx, url).finally(() => ctx.close().catch(() => {}));
|
|
653
|
+
const j = signin.judge(anon, marks);
|
|
654
|
+
ws.setAccountStatus(a.account, j.status, j.note);
|
|
655
|
+
}
|
|
656
|
+
return ws.accounts();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
/** 버려진 방문 닫기 */
|
|
661
|
+
sweep() {
|
|
662
|
+
for (const rec of this.visits.values()) {
|
|
663
|
+
if (!rec.closed && Date.now() - rec.touched > IDLE_MS) this.finish(rec, { abandoned: true }).catch(() => {});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** 이 서버가 돌린 방문과 막은 것 */
|
|
668
|
+
status() {
|
|
669
|
+
const lines = [];
|
|
670
|
+
for (const s of this.sites.values()) {
|
|
671
|
+
const g = s.guard.summary();
|
|
672
|
+
const v = s.blockWatch.verdict();
|
|
673
|
+
lines.push(`${s.origin}${v ? " — ⚠ blocked" : ""}`);
|
|
674
|
+
if (g) lines.push(` Blocked requests: writes to the site ${g.writes.join(", ") || "none"} · analytics ${g.trackers} · production SDK ${g.production}`);
|
|
675
|
+
}
|
|
676
|
+
const open = [...this.visits.values()].filter((r) => !r.closed).map((r) => `${r.id} ${r.person.id} (${r.visit?.steps.length ?? 0} steps)`);
|
|
677
|
+
if (open.length) lines.push(`In progress: ${open.join(", ")}`);
|
|
678
|
+
return lines.join("\n");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async close() {
|
|
682
|
+
for (const rec of this.visits.values()) if (!rec.closed) await this.finish(rec, { abandoned: true }).catch(() => {});
|
|
683
|
+
if (this.browserP) await (await this.browserP).close().catch(() => {});
|
|
684
|
+
if (this.loginBrowser) await this.loginBrowser.close().catch(() => {});
|
|
685
|
+
if (this.loginReal) await this.loginReal.close().catch(() => {});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* 회원을 어느 계정에 앉히나. 사람마다 늘 같은 계정 — 다시 와도 같은 사람이다.
|
|
691
|
+
* people.json 에 account 를 적으면 그 계정. 확인해서 로그인 안 된 계정에는 앉히지 않는다
|
|
692
|
+
*/
|
|
693
|
+
function pickAccount(p) {
|
|
694
|
+
const all = ws.accounts();
|
|
695
|
+
if (!all.length) throw new Error(`${p.id} is a member but no demo account is signed in. Run login first so the user can sign in.`);
|
|
696
|
+
const usable = all.filter((a) => a.status === "signed_in" || a.status === "unknown" || a.status === "unchecked");
|
|
697
|
+
if (p.account) {
|
|
698
|
+
const a = all.find((x) => x.account === p.account);
|
|
699
|
+
if (!a) throw new Error(`${p.id} uses account "${p.account}", which has no sign-in. Run login with account "${p.account}".`);
|
|
700
|
+
if (!usable.includes(a)) throw new Error(`${p.id} uses account "${p.account}", which is not signed in (${a.status}). Ask the user to sign in again with login (account: "${p.account}").`);
|
|
701
|
+
return a;
|
|
702
|
+
}
|
|
703
|
+
if (!usable.length) {
|
|
704
|
+
throw new Error(
|
|
705
|
+
`${p.id} is a member but none of the accounts is signed in (${all.map((a) => `${a.account}: ${a.status}`).join(", ")}). Ask the user to sign in again with login. If signing in cannot work on this site, tell the user that signed-in visits are not possible here and make the remaining people newcomers.`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
return usable[persona.seedOf(personKey(p)) % usable.length];
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const STATUS_TEXT = {
|
|
712
|
+
signed_in: "signed in",
|
|
713
|
+
signed_out: "NOT signed in",
|
|
714
|
+
expired: "sign-in EXPIRED",
|
|
715
|
+
unknown: "saved, but could not confirm it is signed in",
|
|
716
|
+
unchecked: "saved, not checked yet",
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
/** check_site 에 보일 계정 목록. 안 된 계정은 무엇을 하라고 같이 적는다 */
|
|
720
|
+
function describeAccounts(accs, loginOpen) {
|
|
721
|
+
const L = [accs.length ? `· Demo accounts (${accs.length}) — members are spread across the signed-in ones:` : "· Demo accounts: none saved yet"];
|
|
722
|
+
for (const a of accs) {
|
|
723
|
+
const left = a.expiresAt && a.status === "signed_in" ? Math.max(0, (Date.parse(a.expiresAt) - Date.now()) / 36e5) : null;
|
|
724
|
+
const when = left === null ? `saved ${a.ageH}h ago` : left < 1 ? `about ${Math.max(1, Math.round(left * 60))} min left` : `about ${Math.round(left)}h left`;
|
|
725
|
+
let line = ` - ${a.account}: ${STATUS_TEXT[a.status] || a.status}${a.note ? ` — ${a.note}` : ""} (${when})`;
|
|
726
|
+
if (a.status === "signed_out" || a.status === "expired") line += ` → ask the user to sign in again with login (account: "${a.account}")`;
|
|
727
|
+
if (a.status === "unknown") line += " → ask the user whether they were signed in when they closed the window";
|
|
728
|
+
L.push(line);
|
|
729
|
+
}
|
|
730
|
+
if (loginOpen === "saving") L.push(" (the sign-in window is closed; the sign-in is still being saved and checked — run check_site again in a moment, without asking the user anything)");
|
|
731
|
+
else if (loginOpen) L.push(" (the sign-in window is still open on the user's screen — run check_site again after the user closes it)");
|
|
732
|
+
return L.join("\n");
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function renderCard(card) {
|
|
736
|
+
return [
|
|
737
|
+
"## A survey card appeared on the screen",
|
|
738
|
+
`Question: ${card.question}`,
|
|
739
|
+
...(card.choices.length
|
|
740
|
+
? ["Options:", ...card.choices.map((c, i) => `[${i}] ${c}`), "There is also a box to type your own answer, and a close button."]
|
|
741
|
+
: ["No options — only a box to type your own words, and a close button."]),
|
|
742
|
+
"",
|
|
743
|
+
"Decide as yourself with answer_card: first whether you would bother at all, then what.",
|
|
744
|
+
].join("\n");
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** 방문 한 줄 요약 — 메인 에이전트가 모아서 사용자에게 보여준다 */
|
|
748
|
+
function summarize(row) {
|
|
749
|
+
const last = row.steps[row.steps.length - 1];
|
|
750
|
+
const card = row.card ? `Card "${row.card.question}" → ${row.card.result === "close" ? "closed" : row.card.answer}` : "No card";
|
|
751
|
+
return [
|
|
752
|
+
`${row.person} (${row.device}) · ${row.entry?.how ?? "?"} → ${row.entry?.path ?? "/"} · ${row.steps.length} steps · ${Math.round(row.ms / 1000)}s`,
|
|
753
|
+
card,
|
|
754
|
+
last ? `Last: ${last.action} — ${last.why}` : row.blocked ? "Stopped at a block page" : "",
|
|
755
|
+
]
|
|
756
|
+
.filter(Boolean)
|
|
757
|
+
.join("\n");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
module.exports = { Hub, AGENT, MAX_OPEN, summarize };
|