@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.
@@ -0,0 +1,242 @@
1
+ "use strict";
2
+ /**
3
+ * 로그인 창을 **PC 에 깔린 일반 크롬**으로 띄운다.
4
+ *
5
+ * Playwright 가 띄운 브라우저는 자동화 표시가 있어서 구글 로그인이 막힌다 — 이메일을 넣어도 다음이 안 눌린다
6
+ * (10/14 bluetags Google 로그인). 그래서 크롬을 자동화 없이 실행하고, 디버깅 포트로 **쿠키만** 읽어 온다.
7
+ *
8
+ * - 빈 프로필(--user-data-dir 임시 폴더)로 연다. 사용자가 평소 쓰는 크롬은 건드리지도 보지도 않는다
9
+ * - 로그인하는 동안 크롬에는 아무것도 붙지 않는다 — 로그인 화면이 눈치챌 게 없다
10
+ * - 저장하는 건 그 사이트 쿠키뿐이다. 사용자의 구글 쿠키 같은 건 버린다
11
+ *
12
+ * 크롬(또는 엣지)이 없으면 null — 부르는 쪽이 Playwright 창으로 돌아간다.
13
+ * 의존성 없음. Node 22 의 WebSocket 을 쓴다.
14
+ */
15
+ const fs = require("fs");
16
+ const os = require("os");
17
+ const path = require("path");
18
+ const { spawn } = require("child_process");
19
+
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+
22
+ function findChrome() {
23
+ const env = process.env;
24
+ const win = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
25
+ const candidates =
26
+ process.platform === "win32"
27
+ ? [
28
+ ...win.map((d) => path.join(d, "Google", "Chrome", "Application", "chrome.exe")),
29
+ ...win.map((d) => path.join(d, "Microsoft", "Edge", "Application", "msedge.exe")),
30
+ ]
31
+ : process.platform === "darwin"
32
+ ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"]
33
+ : ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/microsoft-edge"];
34
+ return candidates.find((p) => {
35
+ try {
36
+ return fs.statSync(p).isFile();
37
+ } catch {
38
+ return false;
39
+ }
40
+ }) || null;
41
+ }
42
+
43
+ /** 브라우저 단위 CDP 연결. 페이지에는 붙지 않는다 */
44
+ async function connect(wsUrl) {
45
+ const ws = new WebSocket(wsUrl);
46
+ await new Promise((ok, bad) => {
47
+ ws.onopen = ok;
48
+ ws.onerror = () => bad(new Error("could not connect to Chrome"));
49
+ });
50
+ let seq = 0;
51
+ const waiting = new Map();
52
+ ws.onmessage = (ev) => {
53
+ const m = JSON.parse(typeof ev.data === "string" ? ev.data : ev.data.toString());
54
+ if (m.id && waiting.has(m.id)) {
55
+ const { ok, bad } = waiting.get(m.id);
56
+ waiting.delete(m.id);
57
+ m.error ? bad(new Error(m.error.message)) : ok(m.result);
58
+ }
59
+ };
60
+ const send = (method, params = {}, sessionId) =>
61
+ new Promise((ok, bad) => {
62
+ const id = ++seq;
63
+ waiting.set(id, { ok, bad });
64
+ ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
65
+ setTimeout(() => waiting.has(id) && (waiting.delete(id), bad(new Error(`${method} timed out`))), 5000);
66
+ });
67
+ return { send, close: () => ws.close() };
68
+ }
69
+
70
+ /** 사이트 쿠키인가 — 같은 호스트, 그 상위 도메인(.example.com), 그 하위 도메인(auth.example.com) */
71
+ function siteCookie(c, host) {
72
+ const d = String(c.domain || "").replace(/^\./, "");
73
+ const bare = host.replace(/^www\./, "");
74
+ return d === host || host.endsWith("." + d) || d === bare || d.endsWith("." + bare);
75
+ }
76
+
77
+ /** CDP 쿠키 → Playwright storageState 쿠키 */
78
+ function toState(c) {
79
+ return {
80
+ name: c.name,
81
+ value: c.value,
82
+ domain: c.domain,
83
+ path: c.path || "/",
84
+ expires: c.session || !(c.expires > 0) ? -1 : c.expires,
85
+ httpOnly: !!c.httpOnly,
86
+ secure: !!c.secure,
87
+ sameSite: ["Strict", "Lax", "None"].includes(c.sameSite) ? c.sameSite : "Lax",
88
+ };
89
+ }
90
+
91
+ const PROFILE_PREFIX = "earningbirds-login-";
92
+
93
+ /** 사용자가 X 를 누른 것처럼 끈다. 윈도는 taskkill(/F 없이)이 창에 닫기를 보낸다, 나머지는 SIGTERM */
94
+ function closeGently(child) {
95
+ try {
96
+ if (process.platform === "win32") spawn("taskkill", ["/PID", String(child.pid)], { stdio: "ignore" });
97
+ else child.kill("SIGTERM");
98
+ } catch {}
99
+ }
100
+
101
+ /** 임시 프로필을 지운다. 크롬이 놓을 때까지 30초 동안 다시 해 본다 */
102
+ async function removeProfile(dir) {
103
+ for (let i = 0; i < 30; i++) {
104
+ try {
105
+ fs.rmSync(dir, { recursive: true, force: true });
106
+ if (!fs.existsSync(dir)) return true;
107
+ } catch {}
108
+ await sleep(1000);
109
+ }
110
+ return false;
111
+ }
112
+
113
+ /** 전에 못 지운 임시 프로필(중간에 꺼졌을 때)을 치운다. 15분 넘은 것만 — 지금 열린 창의 것은 두고 */
114
+ function sweepProfiles() {
115
+ try {
116
+ for (const name of fs.readdirSync(os.tmpdir())) {
117
+ if (!name.startsWith(PROFILE_PREFIX)) continue;
118
+ const p = path.join(os.tmpdir(), name);
119
+ try {
120
+ if (Date.now() - fs.statSync(p).mtimeMs > 15 * 60 * 1000) fs.rmSync(p, { recursive: true, force: true });
121
+ } catch {}
122
+ }
123
+ } catch {}
124
+ }
125
+
126
+ /** 프로필 폴더의 DevToolsActivePort 에서 크롬이 고른 포트를 읽는다 */
127
+ async function waitPort(dir, isGone) {
128
+ for (let i = 0; i < 75 && !isGone(); i++) {
129
+ await sleep(200);
130
+ try {
131
+ const port = Number(fs.readFileSync(path.join(dir, "DevToolsActivePort"), "utf8").split("\n")[0]);
132
+ if (port) return port;
133
+ } catch {}
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * 로그인이 끝난 프로필을 화면 없이 다시 켜서 그 사이트 쿠키와 localStorage 를 읽는다.
140
+ * 쿠키는 크롬이 직접 풀어 준다 — 디스크의 쿠키 파일은 암호화돼 있어 밖에서 못 읽는다
141
+ */
142
+ async function readProfile(exe, dir, origin) {
143
+ const host = new URL(origin).hostname;
144
+ const child = spawn(exe, ["--headless=new", "--remote-debugging-port=0", "--no-first-run", "--no-default-browser-check", `--user-data-dir=${dir}`], { stdio: "ignore" });
145
+ let gone = false;
146
+ child.on("exit", () => (gone = true));
147
+ child.on("error", () => (gone = true));
148
+ try {
149
+ try {
150
+ fs.unlinkSync(path.join(dir, "DevToolsActivePort"));
151
+ } catch {}
152
+ const port = await waitPort(dir, () => gone);
153
+ if (!port) return null;
154
+ const ver = await fetch(`http://127.0.0.1:${port}/json/version`).then((r) => r.json());
155
+ const cdp = await connect(ver.webSocketDebuggerUrl);
156
+ try {
157
+ const all = (await cdp.send("Storage.getCookies")).cookies || [];
158
+ const cookies = all.filter((c) => siteCookie(c, host)).map(toState);
159
+ // localStorage 는 그 사이트 화면을 하나 열어 읽는다
160
+ let storage = null;
161
+ try {
162
+ const { targetId } = await cdp.send("Target.createTarget", { url: origin + "/" });
163
+ const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
164
+ // 개발 서버는 첫 화면이 느리다. 그 사이트에 닿을 때까지 본다(최대 20초)
165
+ const expression = `document.readyState !== "loading" && location.origin === ${JSON.stringify(origin)} ? JSON.stringify(Object.entries(localStorage)) : ""`;
166
+ for (let i = 0; i < 40 && !storage; i++) {
167
+ await sleep(500);
168
+ const r = await cdp.send("Runtime.evaluate", { expression, returnByValue: true }, sessionId).catch(() => null);
169
+ const v = r && r.result && r.result.value;
170
+ if (v) storage = JSON.parse(v).map(([name, value]) => ({ name, value }));
171
+ }
172
+ } catch {}
173
+ return { cookies, origins: storage ? [{ origin, localStorage: storage }] : [] };
174
+ } finally {
175
+ cdp.close();
176
+ }
177
+ } finally {
178
+ try {
179
+ child.kill();
180
+ } catch {}
181
+ for (let i = 0; i < 20 && !gone; i++) await sleep(100);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * 일반 크롬 로그인 창을 연다.
187
+ *
188
+ * **로그인하는 동안에는 디버깅 포트를 열지 않는다.** 포트를 연 크롬은 구글이 "안전하지 않은 브라우저" 로 막았다(10/14).
189
+ * 창이 닫히면 같은 프로필을 화면 없이 다시 켜서 그때 읽는다.
190
+ * "이어서 열기" 를 켜 둔다 — 끄면 창을 닫을 때 세션 쿠키(만료 없는 로그인 쿠키)가 지워진다
191
+ *
192
+ * @param url 처음 보여 줄 주소(사이트 첫 화면)
193
+ * @param origin 사이트 origin — 이 사이트 쿠키·localStorage 만 저장한다
194
+ * @param onSave (storageState) => void — 창이 닫힌 뒤 읽은 것. 못 읽으면 안 부른다
195
+ * @param onDone () => void — 다 끝나면(창이 닫히고 읽은 뒤, 또는 시간 초과)
196
+ * @returns { close, exe } 또는 null(크롬 없음·실행 실패)
197
+ */
198
+ async function openRealChrome({ url, origin, onSave, onDone, timeoutMs = 10 * 60 * 1000 }) {
199
+ const exe = findChrome();
200
+ if (!exe) return null;
201
+ sweepProfiles();
202
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), PROFILE_PREFIX));
203
+ fs.mkdirSync(path.join(dir, "Default"), { recursive: true });
204
+ fs.writeFileSync(path.join(dir, "Default", "Preferences"), JSON.stringify({ session: { restore_on_startup: 1 } }));
205
+ const child = spawn(exe, [`--user-data-dir=${dir}`, "--no-first-run", "--no-default-browser-check", "--new-window", url], { stdio: "ignore" });
206
+ let exited = false;
207
+ let failed = false;
208
+ child.on("exit", () => (exited = true));
209
+ child.on("error", () => ((exited = true), (failed = true)));
210
+ // 실행 자체가 안 됐으면 Playwright 창으로 돌아가게 null
211
+ await sleep(1500);
212
+ if (failed) return null;
213
+
214
+ let finished = false;
215
+ const finish = async () => {
216
+ if (finished) return;
217
+ finished = true;
218
+ clearTimeout(limit);
219
+ if (!exited) {
220
+ // 창을 닫듯이 끈다. 강제로 끄면 크롬이 아직 디스크에 안 쓴 쿠키·저장값을 잃는다(10/14 시험에서 비어 나왔다)
221
+ closeGently(child);
222
+ for (let i = 0; i < 100 && !exited; i++) await sleep(100);
223
+ if (!exited) {
224
+ try {
225
+ child.kill();
226
+ } catch {}
227
+ for (let i = 0; i < 30 && !exited; i++) await sleep(100);
228
+ }
229
+ }
230
+ const state = await readProfile(exe, dir, origin).catch(() => null);
231
+ if (state) onSave(state);
232
+ onDone();
233
+ // 이 프로필에는 사용자가 로그인한 구글 세션 같은 것이 남아 있다. 꼭 지운다 — 크롬이 파일을 몇 초 더 붙잡고 있다(10/14)
234
+ removeProfile(dir);
235
+ };
236
+ const limit = setTimeout(finish, timeoutMs);
237
+ child.on("exit", finish);
238
+ // closed: 창이 닫혔나(닫힌 뒤에도 쿠키를 읽고 확인하느라 수십 초 더 걸린다)
239
+ return { close: finish, exe, closed: () => exited || finished };
240
+ }
241
+
242
+ module.exports = { findChrome, openRealChrome, readProfile, siteCookie };
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ /**
3
+ * 사이트가 "여기는 들어오지 마세요" 라고 적어 둔 것을 읽고 지킨다.
4
+ *
5
+ * 가상 사용자는 사람인 척하지 않는다(헤더·UA 로 자기를 밝힌다). 밝히는 쪽을 택한 이상
6
+ * **robots.txt 도 지켜야 앞뒤가 맞는다.** 고객 사이트라도 마찬가지다 — 고객이 들어가지 말라고 적어 둔 곳이다.
7
+ *
8
+ * `Allow` 를 안 읽으면 `Disallow: /` + `Allow: /$` 처럼 예외를 둔 사이트가 통째로 막힌다.
9
+ * 규칙 고르기는 표준대로 **가장 긴 패턴이 이기고, 길이가 같으면 Allow 가 이긴다.**
10
+ * `*` 는 아무 글자, `$` 는 경로 끝.
11
+ *
12
+ * prototype/explore/driver.js 에 있던 것을 옮겼다(2026-10-07).
13
+ */
14
+
15
+ /** 한 줄의 규칙 */
16
+ function toRule(kind, value) {
17
+ const rx = new RegExp("^" + value.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\\\$$/, "$").replace(/\*/g, ".*"));
18
+ return { allow: kind === "allow", path: value, rx, len: value.length };
19
+ }
20
+
21
+ /** robots.txt 글을 규칙 목록으로. `User-agent: *` 묶음만 본다 */
22
+ function parse(text) {
23
+ const rules = [];
24
+ let star = false;
25
+ for (const raw of String(text || "").split(/\r?\n/)) {
26
+ const line = raw.replace(/#.*/, "").trim();
27
+ const m = line.match(/^([A-Za-z-]+)\s*:\s*(.*)$/);
28
+ if (!m) continue;
29
+ const k = m[1].toLowerCase();
30
+ const v = m[2].trim();
31
+ if (k === "user-agent") star = v === "*";
32
+ else if (star && (k === "disallow" || k === "allow") && v) rules.push(toRule(k, v));
33
+ }
34
+ return rules;
35
+ }
36
+
37
+ /**
38
+ * 사이트의 robots.txt 를 한 번 읽는다. 못 읽으면(404·오류) 규칙 없음으로 둔다 —
39
+ * 없는 것과 못 읽은 것을 구별해 막아버리면 멀쩡한 사이트를 못 돈다.
40
+ *
41
+ * **이게 우리가 그 사이트에 보내는 첫 요청이다.** 여기서부터 자기를 밝힌다 —
42
+ * 브라우저 밖이라 컨텍스트에 붙인 헤더·UA 가 안 따라온다.
43
+ */
44
+ async function loadRobots(site, { headers = {}, timeoutMs = 10000 } = {}) {
45
+ const origin = new URL(site).origin;
46
+ let rules = [];
47
+ try {
48
+ const res = await fetch(origin + "/robots.txt", { headers, signal: AbortSignal.timeout(timeoutMs) });
49
+ if (res.ok) rules = parse(await res.text());
50
+ } catch (e) {
51
+ rules = [];
52
+ }
53
+ return {
54
+ origin,
55
+ rules,
56
+ /** 이 경로가 막혔나. 막혔으면 그 규칙을 돌려준다 */
57
+ blocked(pathname) {
58
+ let best = null;
59
+ for (const r of rules) {
60
+ if (!r.rx.test(pathname)) continue;
61
+ if (!best || r.len > best.len || (r.len === best.len && r.allow)) best = r;
62
+ }
63
+ return best && !best.allow ? best.path : null;
64
+ },
65
+ describe() {
66
+ if (!rules.length) return "robots.txt: no rules";
67
+ const no = rules.filter((r) => !r.allow).length;
68
+ return `robots.txt: ${rules.length} rules (${no} disallow)`;
69
+ },
70
+ };
71
+ }
72
+
73
+ module.exports = { loadRobots, parse };
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ /**
3
+ * 로그인이 됐나 — 세션을 넣은 화면과 안 넣은 화면을 비교한다.
4
+ *
5
+ * 세션 파일이 있다고 로그인된 게 아니다. 10/14 bluetags 에서 로그인 전에 창을 닫았는데 쿠키 6개가 저장돼
6
+ * "Sign-in session found" 로 보였다. 회원 방문자가 비회원 화면을 돌아다니고 그게 회원 기록이 된다.
7
+ *
8
+ * 사이트마다 로그인 표시가 다르니 하나로 맞히지 않는다. **안 넣은 화면에 있던 로그인·가입 링크가
9
+ * 넣은 화면에서 사라졌나**를 본다. 안 넣은 화면에 그런 링크가 처음부터 없으면 알 수 없다고 한다.
10
+ *
11
+ * 의존성 없음. Playwright page 를 받는다.
12
+ */
13
+
14
+ const SIGNIN_TEXT = /^(sign ?in|log ?in|sign ?up|register|create (an )?account|로그인|회원가입|가입하기)$/i;
15
+ const SIGNIN_HREF = /\/(signin|sign-in|login|log-in|signup|sign-up|register|auth\/signin)(\/|\?|#|$)/i;
16
+
17
+ /** 화면에 보이는 로그인·가입 링크(최대 5개). 글자가 정확히 그것이거나 주소가 로그인 화면인 것 */
18
+ async function signInMarks(page) {
19
+ return page
20
+ .evaluate(
21
+ ([textSrc, hrefSrc]) => {
22
+ const text = new RegExp(textSrc, "i");
23
+ const href = new RegExp(hrefSrc, "i");
24
+ const out = [];
25
+ for (const el of document.querySelectorAll('a,button,[role="button"],[role="link"]')) {
26
+ if (!el.getClientRects().length) continue;
27
+ const st = getComputedStyle(el);
28
+ if (st.visibility === "hidden" || st.display === "none" || Number(st.opacity) === 0) continue;
29
+ const t = (el.innerText || el.getAttribute("aria-label") || "").trim().replace(/\s+/g, " ");
30
+ const h = el.getAttribute("href") || "";
31
+ if ((t && t.length <= 30 && text.test(t)) || (h && href.test(h))) out.push(t || h);
32
+ if (out.length >= 5) break;
33
+ }
34
+ return out;
35
+ },
36
+ [SIGNIN_TEXT.source, SIGNIN_HREF.source],
37
+ )
38
+ .catch(() => []);
39
+ }
40
+
41
+ /** 한 화면을 열어 자리 잡은 뒤 로그인 링크를 센다 */
42
+ async function marksAt(ctx, url) {
43
+ const page = await ctx.newPage();
44
+ try {
45
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 }).catch(() => {});
46
+ await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
47
+ // 로그인 상태는 화면이 뜬 뒤 스크립트가 바꾸는 사이트가 많다(next-auth useSession 등)
48
+ await page.waitForTimeout(1500);
49
+ return await signInMarks(page);
50
+ } finally {
51
+ await page.close().catch(() => {});
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @param anon 세션 없이 연 화면의 로그인 링크
57
+ * @param signed 세션으로 연 화면의 로그인 링크
58
+ * @returns { status: "signed_in" | "signed_out" | "unknown", note }
59
+ */
60
+ function judge(anon, signed) {
61
+ if (!anon.length) return { status: "unknown", note: "the site shows no sign-in link to compare against" };
62
+ if (!signed.length) return { status: "signed_in", note: `sign-in links (${anon.slice(0, 2).join(", ")}) are gone when signed in` };
63
+ return { status: "signed_out", note: `the screen still shows ${signed.slice(0, 2).join(", ")}` };
64
+ }
65
+
66
+ module.exports = { signInMarks, marksAt, judge };
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ /**
3
+ * 가상 사용자 만들기 — AI 에 보낼 글과 받을 모양.
4
+ *
5
+ * 질문 문구는 주지 않는다. 사람을 질문에 맞춰 만들면 답이 정해진 각본이 된다.
6
+ * 누구인지·왜 왔는지·그 순간 어떤 상태인지만 정하고, 방문이 어땠는지는 정하지 않는다.
7
+ *
8
+ * MCP 로 팔 때 **고객이 사람을 직접 줄 수도 있다.** 그때 들어오는 칸이 아래 PERSON_FIELDS 다.
9
+ *
10
+ * 이름은 없다 — 실제 방문자도 이름이 없다. 이름이 있으면 진짜 사람처럼 보여 헷갈리고, AI 가 그 인물을 연기한다.
11
+ * 사람을 가리킬 때는 우리가 붙이는 번호(id: p1, p2…)를 쓴다.
12
+ * 의존성 없음. 프롬프트 글은 ../prompts/people.md.
13
+ */
14
+ const crypto = require("crypto");
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+
18
+ const PROMPT_FILE = path.join(__dirname, "..", "prompts", "people.md");
19
+
20
+ /** 사람 한 명의 칸. 고객이 주는 자리이기도 하다 */
21
+ const PERSON_FIELDS = {
22
+ type: "object",
23
+ properties: {
24
+ device: { type: "string", enum: ["desktop", "mobile"] },
25
+ background: { type: "string" },
26
+ goal: { type: "string" },
27
+ /** 그 방문 순간의 상태. 답할지 말지를 가르는 것은 성격보다 이쪽이다 */
28
+ moment: { type: "string" },
29
+ attention: { type: "string", enum: ["skims", "reads", "studies"] },
30
+ /** 뜬 설문 카드를 평소 어떻게 하는가. 대부분은 무시한다 */
31
+ surveyHabit: { type: "string", enum: ["ignores", "sometimes", "willing"] },
32
+ writing: { type: "string", enum: ["careless", "careful"] },
33
+ /** 이번에 어떻게 들어왔나. 유입이 다르면 떨어지는 자리도 다니는 길도 다르다 */
34
+ arrival: { type: "string", enum: ["search", "social", "shared", "direct"] },
35
+ /** 들어오는 화면(경로). 없으면 direct 는 홈, 나머지는 홈에 걸린 상세 하나 */
36
+ landing: { type: "string" },
37
+ /** 이 방문이 사이트의 어느 기능에 관한 것인가 — 고르게 퍼뜨리려는 캐스팅용. 방문자에게는 안 보인다 */
38
+ feature: { type: "string" },
39
+ /** 어떻게 움직이나(persona.js 규격) */
40
+ traits: {
41
+ type: "object",
42
+ properties: {
43
+ readCps: { type: "number" },
44
+ patience: { type: "number" },
45
+ decisiveness: { type: "number" },
46
+ distraction: { type: "number" },
47
+ thoroughness: { type: "number" },
48
+ mobileEase: { type: "number" },
49
+ },
50
+ required: ["readCps", "patience", "decisiveness", "distraction", "thoroughness", "mobileEase"],
51
+ },
52
+ },
53
+ required: ["device", "background", "goal", "moment", "attention", "surveyHabit", "writing", "arrival", "traits"],
54
+ };
55
+
56
+ const PEOPLE_SCHEMA = {
57
+ type: "object",
58
+ properties: { people: { type: "array", items: PERSON_FIELDS } },
59
+ required: ["people"],
60
+ };
61
+
62
+ function systemPrompt() {
63
+ return fs.readFileSync(PROMPT_FILE, "utf8");
64
+ }
65
+
66
+ /**
67
+ * @param siteAbout 사이트가 어떤 곳인지 한두 줄
68
+ * @param path 그 사람들이 이번 방문에서 걸은 길(영어). 자유 탐방이면 "무엇을 하러 왔는지" 만
69
+ * @param n 몇 명
70
+ * @param existing 이미 만든 사람들 [{device, goal}] — 겹치지 않게
71
+ */
72
+ function buildMessage(siteAbout, pathText, n, existing = []) {
73
+ const lines = ["## Website", siteAbout, "", "## Path every person took", pathText, "", "## How many", `Create ${n} people.`];
74
+ if (existing.length) {
75
+ lines.push("", "## Existing people (do not repeat them)");
76
+ for (const p of existing) lines.push(`- (${p.device}) ${p.goal}`);
77
+ }
78
+ return lines.join("\n");
79
+ }
80
+
81
+ /**
82
+ * 새로 들어온 사람들에게 번호를 붙인다. 이미 있는 번호 다음부터. 이름이 실려 와도 버린다.
83
+ * 옛 파일(이름만 있고 번호가 없는 사람)도 여기서 번호를 받는다.
84
+ */
85
+ function withIds(existing, incoming) {
86
+ let n = existing.reduce((m, p) => Math.max(m, Number(String(p.id ?? "").replace(/^p/, "")) || 0), 0);
87
+ const fix = (p) => {
88
+ const { name: _drop, id, uid, ...rest } = p;
89
+ return { id: id || `p${++n}`, uid: uid || newUid(), ...rest };
90
+ };
91
+ const old = existing.map(fix);
92
+ return { existing: old, incoming: incoming.map((p) => fix({ ...p, id: null, uid: null })) };
93
+ }
94
+
95
+ /**
96
+ * 사람의 고유 값. 번호(p3)는 이 저장소의 people.json 안에서만 겹치지 않는다 —
97
+ * 파일을 지우거나 팀원이 각자 돌리면 다른 사람이 같은 p1 이 된다. 서버에는 이것도 같이 보낸다
98
+ */
99
+ const newUid = () => `p_${crypto.randomBytes(6).toString("hex")}`;
100
+
101
+ /** 움직이는 성향의 씨앗 — 사람마다 다르고, 같은 사람이면 늘 같게 */
102
+ const personKey = (p) => `${p.id ?? ""}|${p.createdAt ?? ""}|${p.goal ?? ""}`;
103
+
104
+ module.exports = { PERSON_FIELDS, PEOPLE_SCHEMA, PROMPT_FILE, systemPrompt, buildMessage, withIds, personKey };