@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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 카드가 떴을 때 그 사람이 어떻게 하는가 — AI 에 보낼 글과 받을 모양.
|
|
4
|
+
*
|
|
5
|
+
* **선택지를 보기 전에 "이 사람이 굳이 답할 사람인가"(bothered) 부터 정한다.**
|
|
6
|
+
* 이 단계가 없으면 전원이 성실하게 답한다(2026-09-17: 방문 150번에 닫기 9번).
|
|
7
|
+
* 답할 비율은 지시하지 않는다 — 사람과 그 순간에서 나오게 두고 결과 비율은 보기만 한다.
|
|
8
|
+
*
|
|
9
|
+
* 의존성 없음. 프롬프트 글은 ../prompts/answer.md.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
|
|
14
|
+
const PROMPT_FILE = path.join(__dirname, "..", "prompts", "answer.md");
|
|
15
|
+
|
|
16
|
+
const DECISION_SCHEMA = {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
bothered: { type: "boolean" },
|
|
20
|
+
botheredWhy: { type: "string" },
|
|
21
|
+
action: { type: "string", enum: ["choose", "other", "close"] },
|
|
22
|
+
choiceIndex: { type: "integer" },
|
|
23
|
+
otherText: { type: "string" },
|
|
24
|
+
why: { type: "string" },
|
|
25
|
+
},
|
|
26
|
+
// 순서가 곧 판단 순서다. bothered 를 먼저 쓰게 한다
|
|
27
|
+
required: ["bothered", "botheredWhy", "action", "choiceIndex", "otherText", "why"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function systemPrompt() {
|
|
31
|
+
return fs.readFileSync(PROMPT_FILE, "utf8");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param person 가상 사용자 (people.js PERSON_FIELDS)
|
|
36
|
+
* @param siteAbout 사이트가 어떤 곳인지
|
|
37
|
+
* @param pathText 이번 방문에서 한 일(영어)
|
|
38
|
+
* @param screen 카드가 뜬 순간 페이지 글자
|
|
39
|
+
* @param card { question, choices }
|
|
40
|
+
* @param seen 도중에 거쳐 간 페이지들 [{url, text}] — 카드가 뜬 페이지만으로 모자랄 때
|
|
41
|
+
*/
|
|
42
|
+
function buildMessage(person, siteAbout, pathText, screen, card, seen = []) {
|
|
43
|
+
const earlier = seen.flatMap((x) => [`## Page they opened earlier in this visit (${x.url})`, "```", x.text, "```", ""]);
|
|
44
|
+
return [
|
|
45
|
+
"## Website",
|
|
46
|
+
siteAbout,
|
|
47
|
+
"",
|
|
48
|
+
"## The person",
|
|
49
|
+
`- device: ${person.device}`,
|
|
50
|
+
`- background: ${person.background}`,
|
|
51
|
+
`- goal for this visit: ${person.goal}`,
|
|
52
|
+
`- state right now: ${person.moment ?? "(not given)"}`,
|
|
53
|
+
`- takes in a page by: ${person.attention ?? "reads"}`,
|
|
54
|
+
`- survey cards that pop up: ${{ ignores: "usually ignores them", willing: "is usually happy to answer them" }[person.surveyHabit] || "sometimes answers them"}`,
|
|
55
|
+
`- types: ${person.writing ?? "careful"}`,
|
|
56
|
+
"",
|
|
57
|
+
"## What they did in this visit",
|
|
58
|
+
pathText,
|
|
59
|
+
"",
|
|
60
|
+
...earlier,
|
|
61
|
+
"## Page text when the card appeared",
|
|
62
|
+
"```",
|
|
63
|
+
screen,
|
|
64
|
+
"```",
|
|
65
|
+
"",
|
|
66
|
+
"## The card",
|
|
67
|
+
`Question: ${card.question}`,
|
|
68
|
+
// 자유 입력 질문은 선택지가 없다. 자기 말로 쓰거나 닫거나뿐이다
|
|
69
|
+
...(card.choices.length
|
|
70
|
+
? [
|
|
71
|
+
"Options:",
|
|
72
|
+
...card.choices.map((c, i) => `[${i}] ${c}`),
|
|
73
|
+
"The card also has a box to type an answer of their own, and a close button.",
|
|
74
|
+
]
|
|
75
|
+
: [
|
|
76
|
+
"This card has no options — only a box to type an answer in their own words, and a close button.",
|
|
77
|
+
'When they answer, `action` is "other" and the words go in `otherText`.',
|
|
78
|
+
]),
|
|
79
|
+
].join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** AI 가 준 결정을 실제 조작으로. 범위 밖 번호·빈 글은 닫기로 본다 */
|
|
83
|
+
function toAction(decision, card) {
|
|
84
|
+
if (!decision.bothered) return { kind: "close", reason: "not_bothered" };
|
|
85
|
+
if (decision.action === "choose" && decision.choiceIndex >= 0 && decision.choiceIndex < card.choices.length) {
|
|
86
|
+
return { kind: "choose", index: decision.choiceIndex };
|
|
87
|
+
}
|
|
88
|
+
if (decision.action === "other" && decision.otherText.trim()) {
|
|
89
|
+
return { kind: "other", text: decision.otherText.trim().slice(0, 300) };
|
|
90
|
+
}
|
|
91
|
+
return { kind: "close", reason: decision.action === "close" ? "closed" : `invalid_${decision.action}` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { DECISION_SCHEMA, PROMPT_FILE, systemPrompt, buildMessage, toAction };
|
package/src/run/diag.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 진단 — 가상 사용자가 사이트에서 **하지 못한 것**을 모아 어닝버드 서버로 보낸다. 도구를 고치려고 받는다.
|
|
4
|
+
*
|
|
5
|
+
* 사이트마다 다른 이유로 막힌다(회전 배너를 밀 수 없어서, 화면 밖으로 잘려서, 덮개 때문에 …).
|
|
6
|
+
* bluetags 하나로는 다 못 찾는다(10/15 — 스와이프가 없어서 배너 끝 카드를 못 눌렀다).
|
|
7
|
+
*
|
|
8
|
+
* **사이트 글자·주소·입력값은 보내지 않는다.** 실패의 종류와 요소의 생김새(태그·역할·아이콘인지·회전 배너 안인지·
|
|
9
|
+
* 고정 위치인지·대화상자 안인지·크기)만. 고객 방문 기록(/agent/visits)과 따로 간다.
|
|
10
|
+
* 기본으로 켜져 있고 USERCASE_DIAGNOSTICS=off 로 끈다. API 키가 있을 때만 간다(키가 프로젝트를 정한다).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const OFF = /^(0|off|false|no)$/i;
|
|
14
|
+
const enabled = () => !OFF.test(String(process.env.USERCASE_DIAGNOSTICS || "").trim());
|
|
15
|
+
|
|
16
|
+
/** 방문자에게 간 말 → 실패 종류. 도구가 만든 말이라 사이트 글자는 섞이지 않는다 */
|
|
17
|
+
function kindOf(skipped) {
|
|
18
|
+
const s = String(skipped || "");
|
|
19
|
+
if (/cut off at the edge|off the edge of the screen/.test(s)) return "cut_off";
|
|
20
|
+
if (/survey card is on top/.test(s)) return "covered_by_card";
|
|
21
|
+
if (/on top of it/.test(s)) return "covered";
|
|
22
|
+
if (/greyed out/.test(s)) return "disabled";
|
|
23
|
+
if (/not visible on the screen/.test(s)) return "not_visible";
|
|
24
|
+
if (/no longer there/.test(s)) return "stale";
|
|
25
|
+
if (/get into that field/.test(s)) return "field_unreachable";
|
|
26
|
+
if (/nothing happened when you clicked/.test(s)) return "no_effect";
|
|
27
|
+
if (/not on the screen/.test(s)) return "bad_index";
|
|
28
|
+
return null; // 정책으로 막은 것(결제·로그아웃 등)은 도구 실패가 아니다
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 요소의 생김새 — 글자 없이. 페이지 안에서 돈다 */
|
|
32
|
+
async function shapeOf(el) {
|
|
33
|
+
if (!el) return null;
|
|
34
|
+
return el
|
|
35
|
+
.evaluate((n) => {
|
|
36
|
+
const r = n.getBoundingClientRect();
|
|
37
|
+
let carousel = false;
|
|
38
|
+
let fixed = false;
|
|
39
|
+
let dialog = false;
|
|
40
|
+
for (let p = n, d = 0; p && p !== document.body && d < 25; p = p.parentElement, d++) {
|
|
41
|
+
const cs = getComputedStyle(p);
|
|
42
|
+
if (cs.overflowX !== "visible" && p.scrollWidth > p.clientWidth + 2) carousel = true;
|
|
43
|
+
if (cs.position === "fixed" || cs.position === "sticky") fixed = true;
|
|
44
|
+
if (p.matches('[role="dialog"],[role="alertdialog"],dialog,[aria-modal="true"]')) dialog = true;
|
|
45
|
+
}
|
|
46
|
+
let react = false;
|
|
47
|
+
for (const k in n) if (k.startsWith("__reactProps") && n[k] && typeof n[k].onClick === "function") react = true;
|
|
48
|
+
return {
|
|
49
|
+
tag: n.tagName.toLowerCase(),
|
|
50
|
+
role: n.getAttribute("role") || undefined,
|
|
51
|
+
icon: !(n.innerText || "").trim(),
|
|
52
|
+
svg: n.tagName.toLowerCase() === "svg" || !!n.querySelector("svg"),
|
|
53
|
+
carousel,
|
|
54
|
+
fixed,
|
|
55
|
+
dialog,
|
|
56
|
+
react,
|
|
57
|
+
w: Math.round(r.width),
|
|
58
|
+
h: Math.round(r.height),
|
|
59
|
+
};
|
|
60
|
+
})
|
|
61
|
+
.catch(() => null);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { enabled, kindOf, shapeOf };
|
package/src/run/entry.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 어디로 들어오나 — 유입.
|
|
4
|
+
*
|
|
5
|
+
* 전원이 홈에서 시작하면 사람 같지 않다. 실제로는 검색 결과나 공유된 링크를 타고
|
|
6
|
+
* 상세 페이지에 바로 떨어지는 방문이 많고, 그런 방문은 한 쪽만 보고 나간다.
|
|
7
|
+
* 들어오는 자리가 다르면 다니는 길도, 뜨는 질문도 달라진다.
|
|
8
|
+
*
|
|
9
|
+
* 어떻게 들어왔는지는 **그 사람 설정**(arrival)에서 나온다. 우리가 비율을 정하지 않는다 —
|
|
10
|
+
* MCP 로 팔 때는 고객이 "우리 유입은 대개 검색" 처럼 준다.
|
|
11
|
+
*
|
|
12
|
+
* 의존성 없음.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 사람이 말한 유입 → 어디로 떨어지고 어디서 왔다고 할 것인가 */
|
|
16
|
+
const REFERRERS = {
|
|
17
|
+
search: "https://www.google.com/",
|
|
18
|
+
social: "https://t.co/",
|
|
19
|
+
shared: "",
|
|
20
|
+
direct: "",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** 사이트 안 링크를 모은다. 홈에 걸린 것만 봐도 그 사이트가 뭘 밀고 있는지 나온다 */
|
|
24
|
+
async function collectLinks(page, site) {
|
|
25
|
+
await page.goto(site + "/", { waitUntil: "domcontentloaded" });
|
|
26
|
+
await page.waitForTimeout(1500);
|
|
27
|
+
const links = await page
|
|
28
|
+
.evaluate(() =>
|
|
29
|
+
[
|
|
30
|
+
...new Set(
|
|
31
|
+
Array.from(document.querySelectorAll('a[href^="/"]'))
|
|
32
|
+
.map((a) => a.getAttribute("href"))
|
|
33
|
+
.filter((h) => h && h !== "/" && !h.startsWith("//")),
|
|
34
|
+
),
|
|
35
|
+
].slice(0, 200),
|
|
36
|
+
)
|
|
37
|
+
.catch(() => []);
|
|
38
|
+
// 링크 글자도 같이 — 착지 화면을 고를 때 주소만 보면 무슨 글인지 모른다(10/15 실험실: Azuki 인 줄 알았던 카드가 다른 프로젝트였다)
|
|
39
|
+
const text = await page
|
|
40
|
+
.evaluate(() => {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const a of document.querySelectorAll('a[href^="/"]')) {
|
|
43
|
+
const h = a.getAttribute("href");
|
|
44
|
+
const t = (a.innerText || a.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim();
|
|
45
|
+
if (h && t && !out[h]) out[h] = t.slice(0, 60);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
})
|
|
49
|
+
.catch(() => ({}));
|
|
50
|
+
Object.defineProperty(links, "text", { value: text, enumerable: false });
|
|
51
|
+
return links;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 이 사람이 이번에 어디로 들어오나.
|
|
56
|
+
* @param person arrival: "search" | "social" | "shared" | "direct" (없으면 direct)
|
|
57
|
+
* @param links collectLinks 로 모은 사이트 안 경로들
|
|
58
|
+
*/
|
|
59
|
+
function chooseEntry(person, links, rnd = Math.random) {
|
|
60
|
+
const arrival = person.arrival ?? "direct";
|
|
61
|
+
// 캐스팅 때 정한 착지 화면. 검색한 사람은 그 글로, 즐겨찾기한 사람은 그 화면으로 — 무작위 상세로 떨어뜨리면
|
|
62
|
+
// "Uniswap 지갑" 을 찾던 사람이 엉뚱한 프로젝트 화면에서 시작해 방문 앞부분을 수습에 썼다(10/14 실험실)
|
|
63
|
+
if (typeof person.landing === "string" && person.landing.startsWith("/")) {
|
|
64
|
+
return { path: person.landing, referer: REFERRERS[arrival] ?? "", how: arrival };
|
|
65
|
+
}
|
|
66
|
+
const deep = links.filter((h) => (h.match(/\//g) || []).length >= 2);
|
|
67
|
+
const pick = (list) => (list.length ? list[Math.floor(rnd() * list.length)] : "/");
|
|
68
|
+
|
|
69
|
+
// 검색·공유로 온 사람은 그 글 하나를 보러 온 것이다 — 목록이 아니라 상세로 떨어진다
|
|
70
|
+
if (arrival === "search" || arrival === "social" || arrival === "shared") {
|
|
71
|
+
return { path: pick(deep.length ? deep : links), referer: REFERRERS[arrival], how: arrival };
|
|
72
|
+
}
|
|
73
|
+
return { path: "/", referer: "", how: "direct" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { REFERRERS, collectLinks, chooseEntry };
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 가상 사용자 여러 명을 돌린다 — MCP 의 `run_visits` 가 이걸 부른다.
|
|
4
|
+
*
|
|
5
|
+
* 한 명의 한 방문은 `visit.js`, 여기는 그 바깥: 브라우저 하나, 동시 몇 명, 누가 어디로 들어오나, 막히면 멈추기.
|
|
6
|
+
*
|
|
7
|
+
* - **막히면 멈춘다.** 차단 판정이 나면 남은 사람을 돌리지 않는다. 같은 벽에 계속 부딪히면 사이트에 더 수상해 보이고 비용만 든다
|
|
8
|
+
* - **차단 화면에서는 AI 를 부르지 않는다.** 전에는 막힌 화면을 AI 에 보여주고 "떠남" 을 고르게 했다(9/21, 한 번에 8천 토큰)
|
|
9
|
+
* - 동시 실행은 최대 5. 같은 사이트를 짧은 시간에 되풀이하면 쓰로틀링에 걸린다(9/21 사람인)
|
|
10
|
+
*
|
|
11
|
+
* Playwright 와 AI(`ask`)는 받아 쓴다.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const persona = require("../persona/persona.js");
|
|
15
|
+
const { personKey } = require("../persona/people.js");
|
|
16
|
+
const { runVisit } = require("./visit.js");
|
|
17
|
+
const entry = require("./entry.js");
|
|
18
|
+
const { createGuard } = require("../browser/guard.js");
|
|
19
|
+
const policyMod = require("../browser/policy.js");
|
|
20
|
+
const { loadRobots } = require("../browser/robots.js");
|
|
21
|
+
const { launchBrowser, openVisit } = require("../browser/open.js");
|
|
22
|
+
const { createBlockWatch, describeBlock } = require("../browser/blocked.js");
|
|
23
|
+
const { sleep, agentKey, AGENT_HEADER, KEY_HEADER, uaWith } = require("../browser/card.js");
|
|
24
|
+
|
|
25
|
+
const MAX_CONCURRENCY = 5;
|
|
26
|
+
|
|
27
|
+
/** SDK 가 응답에 실어 보낼 그 사람. 닫고 나가도 남아야 해서 방문 내내 붙어 있다 */
|
|
28
|
+
const personaCard = (p) => ({
|
|
29
|
+
id: p.id,
|
|
30
|
+
device: p.device,
|
|
31
|
+
background: p.background,
|
|
32
|
+
goal: p.goal,
|
|
33
|
+
moment: p.moment,
|
|
34
|
+
attention: p.attention,
|
|
35
|
+
surveyHabit: p.surveyHabit,
|
|
36
|
+
writing: p.writing,
|
|
37
|
+
traits: p.traits,
|
|
38
|
+
path: "free exploration",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param playwright require("playwright")
|
|
43
|
+
* @param site 고객 사이트 주소
|
|
44
|
+
* @param siteAbout 사이트 한 줄 설명(영어, AI 에 간다)
|
|
45
|
+
* @param people 돌릴 사람들. 순서대로 쓴다
|
|
46
|
+
* @param ask (system, schema, text) => JSON
|
|
47
|
+
* @param inject { project, sdkSrc, serverOrigin } — 로컬 SDK 를 심을 때
|
|
48
|
+
* @param onVisit 방문 하나가 끝날 때마다 (row, person)
|
|
49
|
+
* @param log 사람이 읽을 한 줄
|
|
50
|
+
*/
|
|
51
|
+
function createRun({
|
|
52
|
+
playwright,
|
|
53
|
+
site,
|
|
54
|
+
siteAbout,
|
|
55
|
+
people,
|
|
56
|
+
ask,
|
|
57
|
+
agent = "qa-explore",
|
|
58
|
+
concurrency = 3,
|
|
59
|
+
stepCap = 40,
|
|
60
|
+
inject = null,
|
|
61
|
+
blockTrackers = true,
|
|
62
|
+
allowWritePaths = [],
|
|
63
|
+
/** 고객이 정한 금지 구역(browser/policy.js). 기본값은 사이트에 흔적을 남기는 조작을 막는다 */
|
|
64
|
+
policy = policyMod.load(process.env.USERCASE_POLICY || null),
|
|
65
|
+
headed = false,
|
|
66
|
+
storageState = undefined,
|
|
67
|
+
onVisit = () => {},
|
|
68
|
+
log = () => {},
|
|
69
|
+
} = {}) {
|
|
70
|
+
site = site.replace(/\/$/, "");
|
|
71
|
+
const origin = new URL(site).origin;
|
|
72
|
+
// robots 는 브라우저를 띄울 때 읽는다(네트워크). 함수로 넘겨 읽히는 대로 반영되게
|
|
73
|
+
let robots = null;
|
|
74
|
+
const guard = createGuard({ site, inject, blockTrackers, allowWritePaths, policy, robots: () => robots });
|
|
75
|
+
// 실행 전체에서 하나 — **반복되는지**가 "막혔나" 판단의 핵심이다
|
|
76
|
+
const blockWatch = createBlockWatch();
|
|
77
|
+
const queue = [...people];
|
|
78
|
+
const rows = [];
|
|
79
|
+
const state = { status: "starting", stopped: null, total: people.length, done: 0, running: [] };
|
|
80
|
+
let browser = null;
|
|
81
|
+
|
|
82
|
+
async function visitOnce(p, links) {
|
|
83
|
+
const traits = persona.coerceTraits(p.traits, personKey(p)).traits;
|
|
84
|
+
const rhythm = new persona.Rhythm({ traits, visits: 1, device: p.device, source: p.traits ? "given" : "seed", seed: persona.seedOf(personKey(p)) });
|
|
85
|
+
// 이번 방문을 몇 쪽이나 볼지는 그 사람이 정한다(튕김·훑기·깊게). 우리가 자르지 않는다
|
|
86
|
+
const shape = persona.drawShape(Math.random, traits, persona.familiarityOf(1));
|
|
87
|
+
const ctx = await openVisit(browser, playwright.devices, { agent, site, guard, device: p.device, persona: personaCard(p), storageState });
|
|
88
|
+
const page = await ctx.newPage();
|
|
89
|
+
blockWatch.watch(page, origin);
|
|
90
|
+
const row = { person: p.id, device: p.device, goal: p.goal, shape, at: new Date().toISOString() };
|
|
91
|
+
const startedAt = Date.now();
|
|
92
|
+
try {
|
|
93
|
+
// 어디로 들어오나. 검색·공유로 온 사람은 목록이 아니라 그 글에 바로 떨어진다
|
|
94
|
+
const how = entry.chooseEntry(p, links);
|
|
95
|
+
row.entry = how;
|
|
96
|
+
await page.goto(site + how.path, { waitUntil: "domcontentloaded", referer: how.referer || undefined });
|
|
97
|
+
// 차단 페이지는 정상 응답으로 와서 goto 가 성공한다. 화면을 보고 가린다
|
|
98
|
+
const blockedPage = blockWatch.notePage({
|
|
99
|
+
title: await page.title().catch(() => ""),
|
|
100
|
+
text: await page.evaluate(() => (document.body || {}).innerText || "").catch(() => ""),
|
|
101
|
+
path: how.path,
|
|
102
|
+
});
|
|
103
|
+
if (blockedPage) {
|
|
104
|
+
row.blocked = true;
|
|
105
|
+
row.steps = [];
|
|
106
|
+
log(` ${p.id.padEnd(5)} 차단 화면 — 걷지 않는다`);
|
|
107
|
+
} else {
|
|
108
|
+
const out = await runVisit(page, p, {
|
|
109
|
+
siteAbout,
|
|
110
|
+
ask,
|
|
111
|
+
rhythm,
|
|
112
|
+
shape,
|
|
113
|
+
maxStepsCap: stepCap,
|
|
114
|
+
policy,
|
|
115
|
+
log: (line) => log(` ${p.id.padEnd(5)} ${line}`),
|
|
116
|
+
});
|
|
117
|
+
row.steps = out.steps;
|
|
118
|
+
row.pages = [...new Set(out.steps.map((s) => s.path))];
|
|
119
|
+
row.card = out.card;
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
row.error = String(e.message).split("\n")[0].slice(0, 160);
|
|
123
|
+
}
|
|
124
|
+
row.ms = Date.now() - startedAt;
|
|
125
|
+
await sleep(1500);
|
|
126
|
+
await ctx.close();
|
|
127
|
+
return row;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function run() {
|
|
131
|
+
browser = await launchBrowser(playwright, { headed, localSdk: !!inject && /localhost|127\.0\.0\.1/.test(inject.sdkSrc) });
|
|
132
|
+
// 사이트가 적어 둔 robots.txt 를 먼저 읽는다. 우리가 보내는 첫 요청이라 여기서부터 자기를 밝힌다
|
|
133
|
+
const key = agentKey();
|
|
134
|
+
robots = await loadRobots(site, {
|
|
135
|
+
headers: { [AGENT_HEADER]: agent, "User-Agent": uaWith(agent, "Mozilla/5.0 (compatible)"), ...(key ? { [KEY_HEADER]: key } : {}) },
|
|
136
|
+
});
|
|
137
|
+
log(robots.describe());
|
|
138
|
+
try {
|
|
139
|
+
// 사이트 안 링크를 한 번 모은다. 검색·공유로 들어오는 사람이 떨어질 자리다.
|
|
140
|
+
// 이것도 고객 사이트 방문이다 — 같은 순서로 열어 가상 사용자로 밝힌다
|
|
141
|
+
const scout = await openVisit(browser, playwright.devices, { agent, site, guard, debugCards: false, storageState });
|
|
142
|
+
const scoutPage = await scout.newPage();
|
|
143
|
+
blockWatch.watch(scoutPage, origin);
|
|
144
|
+
const links = await entry.collectLinks(scoutPage, site).catch(() => []);
|
|
145
|
+
blockWatch.notePage({ title: await scoutPage.title().catch(() => ""), text: await scoutPage.evaluate(() => (document.body || {}).innerText || "").catch(() => ""), path: "/" });
|
|
146
|
+
await scout.close();
|
|
147
|
+
log(`사이트 안 링크 ${links.length}개 — 유입 후보`);
|
|
148
|
+
state.status = "running";
|
|
149
|
+
|
|
150
|
+
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, MAX_CONCURRENCY, queue.length)) }, async () => {
|
|
151
|
+
for (let p = queue.shift(); p; p = queue.shift()) {
|
|
152
|
+
if (state.stopped) break;
|
|
153
|
+
// 이미 막혔으면 더 두드리지 않는다
|
|
154
|
+
if (blockWatch.verdict()) {
|
|
155
|
+
state.stopped = state.stopped || "blocked";
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
log(`\n▶ ${p.id} (${p.device}) — ${p.goal}`);
|
|
159
|
+
state.running.push(p.id);
|
|
160
|
+
const row = await visitOnce(p, links);
|
|
161
|
+
state.running = state.running.filter((n) => n !== p.id);
|
|
162
|
+
rows.push(row);
|
|
163
|
+
state.done++;
|
|
164
|
+
onVisit(row, p);
|
|
165
|
+
log(
|
|
166
|
+
` ✔ ${state.done}/${state.total} · ${row.shape.kind}(${row.shape.screens}쪽) · ${row.entry?.how ?? "?"} → ${row.entry?.path ?? "/"} · 걸음 ${row.steps?.length ?? 0} · ${Math.round((row.ms ?? 0) / 1000)}초 · 카드 ${row.card ? row.card.result : row.blocked ? "차단" : "안 뜸"}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
await Promise.all(workers);
|
|
171
|
+
} finally {
|
|
172
|
+
await browser.close().catch(() => {});
|
|
173
|
+
}
|
|
174
|
+
state.status = state.stopped ? "stopped" : "done";
|
|
175
|
+
return result();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function result() {
|
|
179
|
+
const blocked = blockWatch.verdict();
|
|
180
|
+
return {
|
|
181
|
+
status: state.status,
|
|
182
|
+
stopped: state.stopped,
|
|
183
|
+
total: state.total,
|
|
184
|
+
done: state.done,
|
|
185
|
+
running: [...state.running],
|
|
186
|
+
rows: [...rows],
|
|
187
|
+
blocked,
|
|
188
|
+
blockedText: blocked ? describeBlock(blocked) : null,
|
|
189
|
+
guarded: guard.summary(),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
/** 끝까지 돈다. 결과를 돌려준다 */
|
|
195
|
+
run,
|
|
196
|
+
/** 지금 상태. 도는 중에도 부를 수 있다(MCP get_run) */
|
|
197
|
+
status: result,
|
|
198
|
+
/** 남은 사람을 안 돌린다. 지금 걷는 사람은 그 방문을 마친다 */
|
|
199
|
+
stop(reason = "stopped") {
|
|
200
|
+
state.stopped = reason;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { createRun, MAX_CONCURRENCY };
|