@reconcrap/boss-recommend-mcp 0.1.13 → 0.1.15
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 +3 -0
- package/package.json +1 -1
- package/skills/boss-recommend-pipeline/SKILL.md +18 -5
- package/src/adapters.js +13 -0
- package/src/cli.js +122 -13
- package/src/index.js +1 -0
- package/src/pipeline.js +50 -9
- package/src/test-pipeline.js +32 -0
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +28 -4
- package/vendor/boss-recommend-search-cli/src/cli.js +27 -12
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ MCP 工具名:`run_recommend_pipeline`
|
|
|
25
25
|
- 学历支持单选与多选语义:如“本科及以上”会展开为 `本科/硕士/博士`;如“大专、本科”只勾选这两项
|
|
26
26
|
- 执行前会逐项确认筛选参数:学校标签 / 学历 / 性别 / 是否过滤近14天已看
|
|
27
27
|
- 页面就绪(已登录且在 recommend 页)后,会先提取岗位栏全部岗位并要求用户确认本次岗位;确认后先点击岗位,再执行 search/screen
|
|
28
|
+
- 在真正开始 search/screen 前,会进行最后一轮全参数总确认(岗位 + 全部筛选参数 + criteria + target_count + post_action + max_greet_count)
|
|
28
29
|
- npm 全局安装后会自动执行 install:生成 skill、导出 MCP 模板,并自动尝试写入已检测到的外部 agent MCP 配置(含 Trae / trae-cn / Cursor / Claude / OpenClaw)
|
|
29
30
|
- `post_action` 必须在每次完整运行开始时确认一次
|
|
30
31
|
- `target_count` 会在每次运行开始时询问一次(可留空,不设上限)
|
|
@@ -64,6 +65,7 @@ BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS # JSON 数组或系统 path 分隔路径列
|
|
|
64
65
|
|
|
65
66
|
```bash
|
|
66
67
|
node src/cli.js run --instruction "推荐页筛选985男生,近14天没有,有大模型平台经验,符合标准的收藏"
|
|
68
|
+
npx -y @reconcrap/boss-recommend-mcp@latest run --instruction "推荐页筛选985男生,近14天没有,有大模型平台经验,符合标准的收藏"
|
|
67
69
|
```
|
|
68
70
|
|
|
69
71
|
## 配置
|
|
@@ -135,6 +137,7 @@ node src/cli.js run --instruction-file request.txt --confirmation-file confirmat
|
|
|
135
137
|
"target_count_value": 20,
|
|
136
138
|
"post_action_confirmed": true,
|
|
137
139
|
"post_action_value": "greet",
|
|
140
|
+
"final_confirmed": true,
|
|
138
141
|
"job_confirmed": true,
|
|
139
142
|
"job_value": "算法工程师(视频/图像模型方向) _ 杭州",
|
|
140
143
|
"max_greet_count_confirmed": true,
|
package/package.json
CHANGED
|
@@ -51,7 +51,11 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
|
|
|
51
51
|
- 严禁在未询问用户的情况下自动把 `max_greet_count` 设为 `target_count` 或其他默认值
|
|
52
52
|
- 岗位(`job`)是否确定
|
|
53
53
|
- 必须先列出 recommend 页岗位栏里识别到的全部岗位,让用户明确选择
|
|
54
|
+
- 即使前序步骤已提取到 `job` 参数,执行前也必须再次展示岗位列表并让用户二次确认
|
|
54
55
|
- 用户确认后必须先点击该岗位,再开始 search 和 screen
|
|
56
|
+
- 正式开始 search/screen 前,必须做最后一轮“全参数总确认”
|
|
57
|
+
- 需要向用户复述并确认:岗位、school_tag、degree、gender、recent_not_view、criteria、target_count、post_action、max_greet_count
|
|
58
|
+
- 只有用户明确最终确认后才允许执行
|
|
55
59
|
|
|
56
60
|
`post_action` 的确认是**单次运行级别**的:
|
|
57
61
|
|
|
@@ -75,6 +79,7 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
|
|
|
75
79
|
- `target_count_value` (integer, optional)
|
|
76
80
|
- `post_action_confirmed`
|
|
77
81
|
- `post_action_value` (`favorite|greet`)
|
|
82
|
+
- `final_confirmed`
|
|
78
83
|
- `job_confirmed`
|
|
79
84
|
- `job_value` (string)
|
|
80
85
|
- `max_greet_count_confirmed`
|
|
@@ -98,11 +103,17 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
|
|
|
98
103
|
- recommend-screen-cli 负责滚动推荐列表、打开详情、提取完整简历图、调用多模态模型判断,并按单次确认的 `post_action` 执行收藏或打招呼。
|
|
99
104
|
- 详情页处理完成后必须关闭详情页并确认已关闭。
|
|
100
105
|
|
|
101
|
-
## Fallback
|
|
102
|
-
|
|
103
|
-
如果 MCP 不可用,改用:
|
|
104
|
-
|
|
105
|
-
`boss-recommend-mcp run --instruction "..." [--confirmation-json '{...}'] [--overrides-json '{...}']`
|
|
106
|
+
## Fallback
|
|
107
|
+
|
|
108
|
+
如果 MCP 不可用,改用:
|
|
109
|
+
|
|
110
|
+
`npx -y @reconcrap/boss-recommend-mcp@latest run --instruction "..." [--confirmation-json '{...}'] [--overrides-json '{...}']`
|
|
111
|
+
|
|
112
|
+
禁止错误回退:
|
|
113
|
+
|
|
114
|
+
- 不能把 recommend 请求回退到 `boss-recruit-mcp` / `run_recruit_pipeline`
|
|
115
|
+
- 不能执行 `boss-recruit-mcp doctor` 作为 recommend 流程的环境检查
|
|
116
|
+
- 若检测到当前环境只有 recruit MCP,应先修复 recommend MCP 配置,再继续
|
|
106
117
|
|
|
107
118
|
CLI fallback 的状态机与 MCP 保持一致:
|
|
108
119
|
|
|
@@ -135,6 +146,8 @@ CLI fallback 的状态机与 MCP 保持一致:
|
|
|
135
146
|
当工具返回 `status=FAILED` 且 `error.code=PIPELINE_PREFLIGHT_FAILED` 时:
|
|
136
147
|
|
|
137
148
|
1. 若 `diagnostics.checks` 中 `screen_config` 失败,优先引导用户填写 `screening-config.json` 的 `baseUrl/apiKey/model`(必须让用户提供真实值,不可保留模板值)。
|
|
149
|
+
- 禁止 agent 自行代填或猜测示例值(如 `test-key` / `mock-key` / `https://example.com` / `gpt-4` 占位等)
|
|
150
|
+
- 必须逐项向用户确认 `baseUrl`、`apiKey`、`model` 后再写入
|
|
138
151
|
2. 优先查看 `diagnostics.auto_repair`,若有自动修复动作则先基于其结果继续执行或给出最小化补救提示。
|
|
139
152
|
3. 若自动修复后仍失败,再读取 `diagnostics.recovery.agent_prompt`,直接把这段提示词交给 AI agent 执行环境修复。
|
|
140
153
|
4. 若 `diagnostics.recovery.agent_prompt` 不存在,使用下面的兜底提示词(严格顺序,不可跳步):
|
package/src/adapters.js
CHANGED
|
@@ -860,6 +860,19 @@ export async function inspectBossRecommendPageState(port, options = {}) {
|
|
|
860
860
|
(tab) => typeof tab?.url === "string" && tab.url.includes("/web/chat/recommend")
|
|
861
861
|
);
|
|
862
862
|
if (exactTab) {
|
|
863
|
+
if (isBossLoginTab(exactTab)) {
|
|
864
|
+
return buildBossPageState({
|
|
865
|
+
ok: false,
|
|
866
|
+
state: "LOGIN_REQUIRED",
|
|
867
|
+
path: exactTab.url || bossLoginUrl,
|
|
868
|
+
current_url: exactTab.url || bossLoginUrl,
|
|
869
|
+
title: exactTab.title || null,
|
|
870
|
+
requires_login: true,
|
|
871
|
+
expected_url: expectedUrl,
|
|
872
|
+
login_url: bossLoginUrl,
|
|
873
|
+
message: "当前标签页虽在 recommend 路径,但检测到登录态页面特征,请先完成 Boss 登录。"
|
|
874
|
+
});
|
|
875
|
+
}
|
|
863
876
|
return buildBossPageState({
|
|
864
877
|
ok: true,
|
|
865
878
|
state: "RECOMMEND_READY",
|
package/src/cli.js
CHANGED
|
@@ -27,6 +27,8 @@ const supportedMcpClients = ["generic", "cursor", "trae", "claudecode", "opencla
|
|
|
27
27
|
const defaultMcpServerName = "boss-recommend";
|
|
28
28
|
const defaultMcpCommand = "npx";
|
|
29
29
|
const defaultMcpArgs = ["-y", "@reconcrap/boss-recommend-mcp@latest", "start"];
|
|
30
|
+
const recommendMcpPackageName = "@reconcrap/boss-recommend-mcp";
|
|
31
|
+
const recommendMcpBinaryName = "boss-recommend-mcp";
|
|
30
32
|
const autoSyncSkipCommands = new Set(["install", "install-skill", "where", "help", "--help", "-h"]);
|
|
31
33
|
const externalMcpTargetsEnv = "BOSS_RECOMMEND_MCP_CONFIG_TARGETS";
|
|
32
34
|
const externalSkillDirsEnv = "BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS";
|
|
@@ -91,6 +93,32 @@ function dedupePaths(items) {
|
|
|
91
93
|
return result;
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
function dedupeLower(values = []) {
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
const result = [];
|
|
99
|
+
for (const value of values) {
|
|
100
|
+
const normalized = String(value || "").trim();
|
|
101
|
+
if (!normalized) continue;
|
|
102
|
+
const key = normalized.toLowerCase();
|
|
103
|
+
if (seen.has(key)) continue;
|
|
104
|
+
seen.add(key);
|
|
105
|
+
result.push(normalized);
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function discoverAppDataDirsByPattern(baseDir, pattern) {
|
|
111
|
+
try {
|
|
112
|
+
if (!pathExists(baseDir)) return [];
|
|
113
|
+
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
|
|
114
|
+
return entries
|
|
115
|
+
.filter((entry) => entry.isDirectory() && pattern.test(entry.name))
|
|
116
|
+
.map((entry) => entry.name);
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
94
122
|
function getDesktopDir() {
|
|
95
123
|
return path.join(os.homedir(), "Desktop");
|
|
96
124
|
}
|
|
@@ -306,10 +334,19 @@ function parseAgentTargets(rawValue) {
|
|
|
306
334
|
function getKnownExternalMcpConfigPathsByAgent() {
|
|
307
335
|
const home = os.homedir();
|
|
308
336
|
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
337
|
+
const traeDirNames = dedupeLower([
|
|
338
|
+
"Trae",
|
|
339
|
+
"Trae CN",
|
|
340
|
+
"TraeCN",
|
|
341
|
+
"trae-cn",
|
|
342
|
+
"trae_cn",
|
|
343
|
+
...discoverAppDataDirsByPattern(appData, /^trae(?:[\s\-_]?cn)?$/i)
|
|
344
|
+
]);
|
|
345
|
+
const traeConfigPaths = traeDirNames.map((dir) => path.join(appData, dir, "User", "mcp.json"));
|
|
309
346
|
return {
|
|
310
347
|
cursor: [path.join(appData, "Cursor", "User", "mcp.json"), path.join(home, ".cursor", "mcp.json")],
|
|
311
|
-
trae: [path.join(
|
|
312
|
-
"trae-cn": [path.join(
|
|
348
|
+
trae: [...traeConfigPaths, path.join(home, ".trae", "mcp.json"), path.join(home, ".trae-cn", "mcp.json")],
|
|
349
|
+
"trae-cn": [...traeConfigPaths, path.join(home, ".trae-cn", "mcp.json"), path.join(home, ".trae", "mcp.json")],
|
|
313
350
|
claude: [path.join(home, ".claude", "mcp.json")],
|
|
314
351
|
openclaw: [path.join(home, ".openclaw", "mcp.json")]
|
|
315
352
|
};
|
|
@@ -383,15 +420,76 @@ function installExternalMcpConfigs(options = {}) {
|
|
|
383
420
|
function getKnownExternalSkillBaseDirsByAgent() {
|
|
384
421
|
const home = os.homedir();
|
|
385
422
|
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
423
|
+
const traeDirNames = dedupeLower([
|
|
424
|
+
"Trae",
|
|
425
|
+
"Trae CN",
|
|
426
|
+
"TraeCN",
|
|
427
|
+
"trae-cn",
|
|
428
|
+
"trae_cn",
|
|
429
|
+
...discoverAppDataDirsByPattern(appData, /^trae(?:[\s\-_]?cn)?$/i)
|
|
430
|
+
]);
|
|
431
|
+
const traeSkillDirs = traeDirNames.map((dir) => path.join(appData, dir, "User", "skills"));
|
|
386
432
|
return {
|
|
387
433
|
cursor: [path.join(home, ".cursor", "skills"), path.join(appData, "Cursor", "User", "skills")],
|
|
388
|
-
trae: [path.join(home, ".trae", "skills"), path.join(
|
|
389
|
-
"trae-cn": [path.join(home, ".trae-cn", "skills"), path.join(
|
|
434
|
+
trae: [path.join(home, ".trae", "skills"), path.join(home, ".trae-cn", "skills"), ...traeSkillDirs],
|
|
435
|
+
"trae-cn": [path.join(home, ".trae-cn", "skills"), path.join(home, ".trae", "skills"), ...traeSkillDirs],
|
|
390
436
|
claude: [path.join(home, ".claude", "skills")],
|
|
391
437
|
openclaw: [path.join(home, ".openclaw", "skills"), path.join(appData, "OpenClaw", "User", "skills")]
|
|
392
438
|
};
|
|
393
439
|
}
|
|
394
440
|
|
|
441
|
+
function isRecommendMcpLaunchConfig(launchConfig) {
|
|
442
|
+
if (!launchConfig || typeof launchConfig !== "object") return false;
|
|
443
|
+
const command = String(launchConfig.command || "").toLowerCase();
|
|
444
|
+
const args = Array.isArray(launchConfig.args) ? launchConfig.args : [];
|
|
445
|
+
const joined = `${command} ${args.map((item) => String(item || "")).join(" ")}`.toLowerCase();
|
|
446
|
+
return (
|
|
447
|
+
joined.includes(recommendMcpPackageName.toLowerCase())
|
|
448
|
+
|| joined.includes(`${recommendMcpBinaryName} start`)
|
|
449
|
+
|| (command.endsWith(recommendMcpBinaryName) && args.includes("start"))
|
|
450
|
+
|| command === recommendMcpBinaryName
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function inspectMcpServerEntries(filePath) {
|
|
455
|
+
if (!pathExists(filePath)) {
|
|
456
|
+
return {
|
|
457
|
+
exists: false,
|
|
458
|
+
has_boss_recommend: false,
|
|
459
|
+
has_boss_recruit: false,
|
|
460
|
+
recommend_server_names: [],
|
|
461
|
+
recruit_server_names: []
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const parsed = readJsonObjectFileSafe(filePath);
|
|
465
|
+
const servers = parsed?.mcpServers && typeof parsed.mcpServers === "object" && !Array.isArray(parsed.mcpServers)
|
|
466
|
+
? parsed.mcpServers
|
|
467
|
+
: {};
|
|
468
|
+
const recommendNames = [];
|
|
469
|
+
const recruitNames = [];
|
|
470
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
471
|
+
const lowerName = String(name || "").toLowerCase();
|
|
472
|
+
if (isRecommendMcpLaunchConfig(config) || lowerName.includes("boss-recommend")) {
|
|
473
|
+
recommendNames.push(name);
|
|
474
|
+
}
|
|
475
|
+
const serialized = JSON.stringify(config || {}).toLowerCase();
|
|
476
|
+
if (
|
|
477
|
+
lowerName.includes("boss-recruit")
|
|
478
|
+
|| serialized.includes("@reconcrap/boss-recruit-mcp")
|
|
479
|
+
|| serialized.includes("boss-recruit-mcp")
|
|
480
|
+
) {
|
|
481
|
+
recruitNames.push(name);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
exists: true,
|
|
486
|
+
has_boss_recommend: recommendNames.length > 0,
|
|
487
|
+
has_boss_recruit: recruitNames.length > 0,
|
|
488
|
+
recommend_server_names: recommendNames,
|
|
489
|
+
recruit_server_names: recruitNames
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
395
493
|
function resolveExternalSkillBaseDirs(options = {}) {
|
|
396
494
|
const fromEnv = parsePathListFromEnv(process.env[externalSkillDirsEnv]);
|
|
397
495
|
const pathMap = getKnownExternalSkillBaseDirsByAgent();
|
|
@@ -650,16 +748,21 @@ function inspectAgentIntegration(agentRaw) {
|
|
|
650
748
|
|
|
651
749
|
const mcpPathMap = getKnownExternalMcpConfigPathsByAgent();
|
|
652
750
|
const skillPathMap = getKnownExternalSkillBaseDirsByAgent();
|
|
653
|
-
const mcp_paths = dedupePaths(
|
|
654
|
-
|
|
751
|
+
const mcp_paths = dedupePaths([
|
|
752
|
+
...(mcpPathMap[agent] || []),
|
|
753
|
+
...parsePathListFromEnv(process.env[externalMcpTargetsEnv])
|
|
754
|
+
]);
|
|
755
|
+
const skill_bases = dedupePaths([
|
|
756
|
+
...(skillPathMap[agent] || []),
|
|
757
|
+
...parsePathListFromEnv(process.env[externalSkillDirsEnv])
|
|
758
|
+
]);
|
|
655
759
|
|
|
656
760
|
const mcp_checks = mcp_paths.map((mcpPath) => {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
return { path: mcpPath, exists: true, has_boss_recommend: has };
|
|
761
|
+
const detail = inspectMcpServerEntries(mcpPath);
|
|
762
|
+
return {
|
|
763
|
+
path: mcpPath,
|
|
764
|
+
...detail
|
|
765
|
+
};
|
|
663
766
|
});
|
|
664
767
|
|
|
665
768
|
const hasRecommendIntent = (content) => /(recommend|推荐页|boss recommend|recommend page)/i.test(content);
|
|
@@ -736,6 +839,10 @@ async function printDoctor(options = {}) {
|
|
|
736
839
|
if (typeof options.agent === "string" && options.agent.trim()) {
|
|
737
840
|
agentIntegration = inspectAgentIntegration(options.agent.trim());
|
|
738
841
|
const agentMcpOk = agentIntegration.mcp_checks.some((item) => item.has_boss_recommend);
|
|
842
|
+
const agentRecruitOnly = (
|
|
843
|
+
!agentMcpOk
|
|
844
|
+
&& agentIntegration.mcp_checks.some((item) => item.has_boss_recruit)
|
|
845
|
+
);
|
|
739
846
|
const agentSkillOk = agentIntegration.skill_checks.some((item) => item.exists);
|
|
740
847
|
const agentRecruitRouteGuardOk = agentIntegration.skill_checks.every(
|
|
741
848
|
(item) => !item.recruit_skill_exists || item.recruit_route_guard
|
|
@@ -750,7 +857,9 @@ async function printDoctor(options = {}) {
|
|
|
750
857
|
path: agentIntegration.mcp_checks.map((item) => item.path).join(" | "),
|
|
751
858
|
message: agentMcpOk
|
|
752
859
|
? "目标 Agent MCP 配置已检测到 boss-recommend。"
|
|
753
|
-
:
|
|
860
|
+
: agentRecruitOnly
|
|
861
|
+
? "目标 Agent MCP 配置未检测到 boss-recommend,但检测到 boss-recruit(可能导致错误调用 recruit pipeline)。"
|
|
862
|
+
: "目标 Agent MCP 配置未检测到 boss-recommend。"
|
|
754
863
|
});
|
|
755
864
|
checks.push({
|
|
756
865
|
key: `agent_${agentIntegration.agent}_skill`,
|
package/src/index.js
CHANGED
package/src/pipeline.js
CHANGED
|
@@ -19,13 +19,14 @@ function normalizeText(value) {
|
|
|
19
19
|
function normalizeJobTitle(value) {
|
|
20
20
|
const text = normalizeText(value);
|
|
21
21
|
if (!text) return "";
|
|
22
|
-
const
|
|
22
|
+
const byGap = text.split(/\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
|
|
23
|
+
const strippedRange = byGap
|
|
23
24
|
.replace(/\s+\d+(?:\.\d+)?\s*(?:-|~|—|至)\s*\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)?$/u, "")
|
|
24
25
|
.trim();
|
|
25
26
|
const strippedSingle = strippedRange
|
|
26
27
|
.replace(/\s+\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)$/u, "")
|
|
27
28
|
.trim();
|
|
28
|
-
return strippedSingle ||
|
|
29
|
+
return strippedSingle || byGap;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function normalizeJobOptions(jobOptions = []) {
|
|
@@ -299,8 +300,8 @@ function buildNeedInputResponse(parsedResult) {
|
|
|
299
300
|
};
|
|
300
301
|
}
|
|
301
302
|
|
|
302
|
-
function buildNeedConfirmationResponse(parsedResult) {
|
|
303
|
-
return {
|
|
303
|
+
function buildNeedConfirmationResponse(parsedResult) {
|
|
304
|
+
return {
|
|
304
305
|
status: "NEED_CONFIRMATION",
|
|
305
306
|
required_confirmations: buildRequiredConfirmations(parsedResult),
|
|
306
307
|
search_params: parsedResult.searchParams,
|
|
@@ -311,10 +312,22 @@ function buildNeedConfirmationResponse(parsedResult) {
|
|
|
311
312
|
max_greet_count: parsedResult.proposed_max_greet_count || parsedResult.screenParams.max_greet_count
|
|
312
313
|
},
|
|
313
314
|
pending_questions: parsedResult.pending_questions,
|
|
314
|
-
review: parsedResult.review
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
|
|
315
|
+
review: parsedResult.review
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function buildFinalReviewQuestion({ searchParams, screenParams, selectedJob }) {
|
|
320
|
+
return {
|
|
321
|
+
field: "final_review",
|
|
322
|
+
question: "开始执行搜索和筛选前,请最后确认全部参数(岗位/筛选条件/筛选 criteria/目标人数/post_action/max_greet_count)无误。",
|
|
323
|
+
value: {
|
|
324
|
+
job: selectedJob?.title || selectedJob?.label || selectedJob?.value || null,
|
|
325
|
+
search_params: searchParams,
|
|
326
|
+
screen_params: screenParams
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
318
331
|
function buildFailedResponse(code, message, extra = {}) {
|
|
319
332
|
return {
|
|
320
333
|
status: "FAILED",
|
|
@@ -445,7 +458,7 @@ export async function runRecommendPipeline(
|
|
|
445
458
|
"2) apiKey",
|
|
446
459
|
"3) model",
|
|
447
460
|
`配置文件路径:${screenConfigCheck.path}`,
|
|
448
|
-
"注意:不要使用模板占位符(例如 replace-with-openai-api-key
|
|
461
|
+
"注意:不要使用模板占位符(例如 replace-with-openai-api-key),也不要由 agent 自行猜测或代填示例值。必须向用户逐项确认真实可用值后再重试。"
|
|
449
462
|
].join("\n")
|
|
450
463
|
}
|
|
451
464
|
: undefined,
|
|
@@ -559,6 +572,34 @@ export async function runRecommendPipeline(
|
|
|
559
572
|
}
|
|
560
573
|
const selectedJob = selectedJobResolution.job;
|
|
561
574
|
const selectedJobToken = selectedJob.value || selectedJob.title || selectedJob.label;
|
|
575
|
+
if (confirmation?.final_confirmed !== true) {
|
|
576
|
+
const pendingQuestions = (parsed.pending_questions || []).filter((item) => item?.field !== "final_review");
|
|
577
|
+
pendingQuestions.push(buildFinalReviewQuestion({
|
|
578
|
+
searchParams: parsed.searchParams,
|
|
579
|
+
screenParams: {
|
|
580
|
+
...parsed.screenParams,
|
|
581
|
+
target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
|
|
582
|
+
post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
|
|
583
|
+
max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
|
|
584
|
+
},
|
|
585
|
+
selectedJob
|
|
586
|
+
}));
|
|
587
|
+
return {
|
|
588
|
+
status: "NEED_CONFIRMATION",
|
|
589
|
+
required_confirmations: dedupe([...buildRequiredConfirmations(parsed), "final_review"]),
|
|
590
|
+
search_params: parsed.searchParams,
|
|
591
|
+
screen_params: {
|
|
592
|
+
...parsed.screenParams,
|
|
593
|
+
target_count: parsed.proposed_target_count ?? parsed.screenParams.target_count,
|
|
594
|
+
post_action: parsed.proposed_post_action || parsed.screenParams.post_action,
|
|
595
|
+
max_greet_count: parsed.proposed_max_greet_count || parsed.screenParams.max_greet_count
|
|
596
|
+
},
|
|
597
|
+
selected_job: selectedJob,
|
|
598
|
+
pending_questions: pendingQuestions,
|
|
599
|
+
review: parsed.review,
|
|
600
|
+
job_options: jobOptions
|
|
601
|
+
};
|
|
602
|
+
}
|
|
562
603
|
|
|
563
604
|
const searchResult = await searchCli({
|
|
564
605
|
workspaceRoot,
|
package/src/test-pipeline.js
CHANGED
|
@@ -30,6 +30,14 @@ function createJobListResult() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function createJobConfirmedConfirmation() {
|
|
33
|
+
return {
|
|
34
|
+
job_confirmed: true,
|
|
35
|
+
job_value: "数据分析实习生 _ 杭州",
|
|
36
|
+
final_confirmed: true
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createJobConfirmedWithoutFinalConfirmation() {
|
|
33
41
|
return {
|
|
34
42
|
job_confirmed: true,
|
|
35
43
|
job_value: "数据分析实习生 _ 杭州"
|
|
@@ -318,6 +326,29 @@ async function testNeedJobConfirmationGate() {
|
|
|
318
326
|
assert.equal(result.job_options.length, 2);
|
|
319
327
|
}
|
|
320
328
|
|
|
329
|
+
async function testNeedFinalReviewConfirmationGate() {
|
|
330
|
+
const result = await runRecommendPipeline(
|
|
331
|
+
{
|
|
332
|
+
workspaceRoot: process.cwd(),
|
|
333
|
+
instruction: "test",
|
|
334
|
+
confirmation: createJobConfirmedWithoutFinalConfirmation(),
|
|
335
|
+
overrides: {}
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
parseRecommendInstruction: () => createParsed(),
|
|
339
|
+
runPipelinePreflight: () => ({ ok: true, checks: [], debug_port: 9222 }),
|
|
340
|
+
ensureBossRecommendPageReady: async () => ({ ok: true, state: "RECOMMEND_READY", page_state: {} }),
|
|
341
|
+
listRecommendJobs: async () => createJobListResult(),
|
|
342
|
+
runRecommendSearchCli: async () => ({ ok: true, summary: {} }),
|
|
343
|
+
runRecommendScreenCli: async () => ({ ok: true, summary: {} })
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
assert.equal(result.status, "NEED_CONFIRMATION");
|
|
348
|
+
assert.equal(result.required_confirmations.includes("final_review"), true);
|
|
349
|
+
assert.equal(result.pending_questions.some((item) => item.field === "final_review"), true);
|
|
350
|
+
}
|
|
351
|
+
|
|
321
352
|
async function testLoginRequiredShouldReturnGuidance() {
|
|
322
353
|
const result = await runRecommendPipeline(
|
|
323
354
|
{
|
|
@@ -587,6 +618,7 @@ async function main() {
|
|
|
587
618
|
await testNeedMaxGreetCountConfirmationGate();
|
|
588
619
|
await testNeedInputGate();
|
|
589
620
|
await testNeedJobConfirmationGate();
|
|
621
|
+
await testNeedFinalReviewConfirmationGate();
|
|
590
622
|
await testCompletedPipeline();
|
|
591
623
|
await testSearchFailure();
|
|
592
624
|
await testLoginRequiredShouldReturnGuidance();
|
|
@@ -1407,14 +1407,26 @@ class RecommendScreenCli {
|
|
|
1407
1407
|
}
|
|
1408
1408
|
}
|
|
1409
1409
|
|
|
1410
|
+
const isListReadyNow = async () => {
|
|
1411
|
+
const detailState = await this.getDetailClosedState();
|
|
1412
|
+
if (!detailState?.closed) return false;
|
|
1413
|
+
const listState = await this.evaluate(jsGetListState);
|
|
1414
|
+
return Boolean(listState?.ok);
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1410
1417
|
let know = null;
|
|
1411
1418
|
for (let index = 0; index < 10; index += 1) {
|
|
1412
1419
|
await sleep(humanDelay(260, 80));
|
|
1413
1420
|
know = await this.evaluate(jsGetKnowButtonState);
|
|
1414
1421
|
if (know?.ok) break;
|
|
1422
|
+
if (await isListReadyNow()) {
|
|
1423
|
+
log("[打招呼] 未检测到“知道了”弹窗,页面已自动返回列表,继续下一位候选人。");
|
|
1424
|
+
return { actionTaken: "greet", ackMode: "auto_return_no_popup" };
|
|
1425
|
+
}
|
|
1415
1426
|
}
|
|
1416
1427
|
if (!know?.ok) {
|
|
1417
|
-
|
|
1428
|
+
log(`[打招呼] 未检测到“知道了”弹窗(${know?.error || "ACK_BUTTON_NOT_FOUND"}),按无弹窗流程继续。`);
|
|
1429
|
+
return { actionTaken: "greet", ackMode: "no_popup_detected" };
|
|
1418
1430
|
}
|
|
1419
1431
|
|
|
1420
1432
|
try {
|
|
@@ -1422,7 +1434,12 @@ class RecommendScreenCli {
|
|
|
1422
1434
|
} catch {
|
|
1423
1435
|
const fallback = await this.evaluate(jsClickKnowFallback);
|
|
1424
1436
|
if (!fallback?.ok) {
|
|
1425
|
-
|
|
1437
|
+
if (await isListReadyNow()) {
|
|
1438
|
+
log("[打招呼] “知道了”点击兜底失败,但页面已回列表,继续下一位候选人。");
|
|
1439
|
+
return { actionTaken: "greet", ackMode: "ack_click_failed_but_list_ready" };
|
|
1440
|
+
}
|
|
1441
|
+
log(`[打招呼] “知道了”按钮点击失败(${fallback?.error || "unknown"}),后续由详情关闭流程兜底。`);
|
|
1442
|
+
return { actionTaken: "greet", ackMode: "ack_click_failed" };
|
|
1426
1443
|
}
|
|
1427
1444
|
}
|
|
1428
1445
|
|
|
@@ -1430,11 +1447,18 @@ class RecommendScreenCli {
|
|
|
1430
1447
|
await sleep(humanDelay(220, 60));
|
|
1431
1448
|
const state = await this.evaluate(jsGetKnowButtonState);
|
|
1432
1449
|
if (!state?.ok) {
|
|
1433
|
-
return { actionTaken: "greet" };
|
|
1450
|
+
return { actionTaken: "greet", ackMode: "ack_closed" };
|
|
1451
|
+
}
|
|
1452
|
+
if (await isListReadyNow()) {
|
|
1453
|
+
return { actionTaken: "greet", ackMode: "auto_return_after_ack" };
|
|
1434
1454
|
}
|
|
1435
1455
|
}
|
|
1436
1456
|
|
|
1437
|
-
|
|
1457
|
+
if (await isListReadyNow()) {
|
|
1458
|
+
return { actionTaken: "greet", ackMode: "ack_still_visible_but_list_ready" };
|
|
1459
|
+
}
|
|
1460
|
+
log("[打招呼] “知道了”弹窗关闭未确认,后续由详情关闭流程兜底。");
|
|
1461
|
+
return { actionTaken: "greet", ackMode: "ack_close_unconfirmed" };
|
|
1438
1462
|
}
|
|
1439
1463
|
|
|
1440
1464
|
async closeDetailPage(maxRetries = 3) {
|
|
@@ -19,13 +19,14 @@ function normalizeText(value) {
|
|
|
19
19
|
function normalizeJobTitle(value) {
|
|
20
20
|
const text = normalizeText(value);
|
|
21
21
|
if (!text) return "";
|
|
22
|
-
const
|
|
22
|
+
const byGap = text.split(/\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
|
|
23
|
+
const strippedRange = byGap
|
|
23
24
|
.replace(/\s+\d+(?:\.\d+)?\s*(?:-|~|—|至)\s*\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)?$/u, "")
|
|
24
25
|
.trim();
|
|
25
26
|
const strippedSingle = strippedRange
|
|
26
27
|
.replace(/\s+\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)$/u, "")
|
|
27
28
|
.trim();
|
|
28
|
-
return strippedSingle ||
|
|
29
|
+
return strippedSingle || byGap;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function parsePositiveInteger(raw) {
|
|
@@ -419,13 +420,14 @@ class RecommendSearchCli {
|
|
|
419
420
|
const normalizeTitle = (value) => {
|
|
420
421
|
const text = normalize(value);
|
|
421
422
|
if (!text) return '';
|
|
422
|
-
const
|
|
423
|
+
const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
|
|
424
|
+
const strippedRange = byGap
|
|
423
425
|
.replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
|
|
424
426
|
.trim();
|
|
425
427
|
const strippedSingle = strippedRange
|
|
426
428
|
.replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
|
|
427
429
|
.trim();
|
|
428
|
-
return strippedSingle ||
|
|
430
|
+
return strippedSingle || byGap;
|
|
429
431
|
};
|
|
430
432
|
const isVisible = (el) => {
|
|
431
433
|
if (!el) return false;
|
|
@@ -437,9 +439,13 @@ class RecommendSearchCli {
|
|
|
437
439
|
return rect.width > 2 && rect.height > 2;
|
|
438
440
|
};
|
|
439
441
|
|
|
440
|
-
const items = Array.from(
|
|
441
|
-
|
|
442
|
-
|
|
442
|
+
const items = Array.from(doc.querySelectorAll([
|
|
443
|
+
'.ui-dropmenu-list .job-list .job-item',
|
|
444
|
+
'.job-selecter-options .job-list .job-item',
|
|
445
|
+
'.job-selector-options .job-list .job-item',
|
|
446
|
+
'.dropmenu-list .job-list .job-item',
|
|
447
|
+
'.job-list .job-item'
|
|
448
|
+
].join(',')));
|
|
443
449
|
const jobs = [];
|
|
444
450
|
const seen = new Set();
|
|
445
451
|
for (const item of items) {
|
|
@@ -481,9 +487,13 @@ class RecommendSearchCli {
|
|
|
481
487
|
const doc = frame.contentDocument;
|
|
482
488
|
const selectors = [
|
|
483
489
|
'.chat-job-select',
|
|
490
|
+
'.chat-job-selector',
|
|
484
491
|
'.job-selecter',
|
|
492
|
+
'.job-selector',
|
|
485
493
|
'.job-select-wrap',
|
|
486
494
|
'.job-select',
|
|
495
|
+
'.job-select-box',
|
|
496
|
+
'.job-wrap',
|
|
487
497
|
'.chat-job-name',
|
|
488
498
|
'.top-chat-search'
|
|
489
499
|
];
|
|
@@ -564,17 +574,22 @@ class RecommendSearchCli {
|
|
|
564
574
|
const normalizeTitle = (value) => {
|
|
565
575
|
const text = normalize(value);
|
|
566
576
|
if (!text) return '';
|
|
567
|
-
const
|
|
577
|
+
const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
|
|
578
|
+
const strippedRange = byGap
|
|
568
579
|
.replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
|
|
569
580
|
.trim();
|
|
570
581
|
const strippedSingle = strippedRange
|
|
571
582
|
.replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
|
|
572
583
|
.trim();
|
|
573
|
-
return strippedSingle ||
|
|
584
|
+
return strippedSingle || byGap;
|
|
574
585
|
};
|
|
575
|
-
const items = Array.from(
|
|
576
|
-
|
|
577
|
-
|
|
586
|
+
const items = Array.from(doc.querySelectorAll([
|
|
587
|
+
'.ui-dropmenu-list .job-list .job-item',
|
|
588
|
+
'.job-selecter-options .job-list .job-item',
|
|
589
|
+
'.job-selector-options .job-list .job-item',
|
|
590
|
+
'.dropmenu-list .job-list .job-item',
|
|
591
|
+
'.job-list .job-item'
|
|
592
|
+
].join(',')));
|
|
578
593
|
const target = items.find((item) => {
|
|
579
594
|
const value = normalize(item.getAttribute('value') || item.dataset?.value || '');
|
|
580
595
|
const label = normalize(item.querySelector('.label')?.textContent || item.textContent || '');
|