@reconcrap/boss-recommend-mcp 2.0.1 → 2.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -57,6 +57,9 @@ description: "Use when users want Boss chat-page screening/outreach via the bund
57
57
  - 只在用户明确是 chat-only 任务时使用本 skill。
58
58
  - 只要用户提到推荐页、先找人后沟通、或需要推荐筛选阶段,禁止直接调用 `start_boss_chat_run`;必须先交给 `boss-recommend-pipeline` 完成推荐页任务。
59
59
  - 不得在 recommend 任务尚未完成时并行启动独立 chat run。
60
+ - 启动或准备 chat run 时,若本机默认 `127.0.0.1:9222` Chrome DevTools 端口不可连,工具会自动打开 Chrome 并导航到 `https://www.zhipin.com/web/chat/index`。
61
+ - 只有工具返回 `BOSS_LOGIN_REQUIRED` / `requires_login=true` 时,才要求用户在自动打开的 Chrome 窗口人工登录 Boss 后重试;不要把“没开 9222 Chrome”当作缺参。
62
+ - 若本机找不到 Chrome,可提示用户设置 `BOSS_MCP_CHROME_PATH` 或 `BOSS_RECOMMEND_CHROME_PATH`;非本机 debug host 不自动启动。
60
63
  - `job` / `start_from` / `criteria` 缺一不可;缺参时只补缺口。
61
64
  - `target_count` 在 chat-only 启动前也是必填项,不能默认省略。
62
65
  - 当用户说“全部候选人/所有候选人”时,必须按“扫到底(unlimited)”处理,不要再追问正整数。
@@ -31,6 +31,7 @@ description: "Use when users want Boss recommend-page filtering/screening via bo
31
31
  - `criteria` 必须是用户开放式自然语言;禁止“严格/宽松执行”等预设替代。
32
32
  - `post_action=greet` 时,必须确认 `max_greet_count`;禁止自动默认为 `target_count`。
33
33
  - 正式执行前必须 `final_confirmed=true`。
34
+ - 真实筛选禁止传 `detail_limit: 0`;recommend 默认必须打开候选人详情/CV。只有用户明确要求“卡片-only 调试”时,才允许同时传 `detail_limit: 0` 和 `allow_card_only_screening: true`。
34
35
 
35
36
  - **Instruction 原文锁定**
36
37
  - 首次用户需求原文锁定为 `locked_instruction_raw`。
@@ -117,8 +118,14 @@ description: "Use when users want Boss recommend-page filtering/screening via bo
117
118
 
118
119
  - 执行前必须通过:
119
120
  - `screening-config.json` 可用且非占位值(`baseUrl/apiKey/model`)
120
- - Chrome DevTools 端口可连
121
- - Boss 已登录且位于 `https://www.zhipin.com/web/chat/recommend`
121
+ - 工具可连接或自动启动本机 Chrome DevTools 端口(默认 `127.0.0.1:9222`)
122
+ - Boss 已登录;若当前没有 9222 Chrome,工具会自动打开 Chrome 并导航到 `https://www.zhipin.com/web/chat/recommend`
123
+ - 只有工具返回 `BOSS_LOGIN_REQUIRED` / `requires_login=true` 时,才要求用户人工登录 Boss 后重试
124
+
125
+ - 不要在运行前要求用户手动打开 9222 Chrome。只有这些情况需要人工介入:
126
+ - 工具明确报告 `BOSS_LOGIN_REQUIRED`
127
+ - 本机找不到 Chrome 可执行文件,并提示设置 `BOSS_MCP_CHROME_PATH` 或 `BOSS_RECOMMEND_CHROME_PATH`
128
+ - 用户配置的是非本机 debug host,工具无法安全自动启动
122
129
 
123
130
  - `PIPELINE_PREFLIGHT_FAILED` 处理顺序:
124
131
  1. 若 `screen_config` 失败:让用户提供真实 `baseUrl/apiKey/model`,并在 `guidance.config_path` 修改后明确回复“已修改完成”。
@@ -152,3 +159,4 @@ MCP 不可用时:
152
159
  - 页面就绪失败提示必须包含 `debug_port`、recommend URL、以及登录 URL(若未登录):
153
160
  - `https://www.zhipin.com/web/chat/recommend`
154
161
  - `https://www.zhipin.com/web/user/?ka=bticket`
162
+ - 若错误是 `BOSS_LOGIN_REQUIRED`,提示用户在自动打开的 Chrome 窗口完成登录,然后原参数重试;不要改用 search/recruit 路径。
@@ -25,6 +25,9 @@ description: "Use when users want Boss search/recruit-page screening via the uni
25
25
  - 如果用户说聊天页、未读、全部聊天、求简历,必须交给 `boss-chat`。
26
26
  - 禁止调用旧包:`@reconcrap/boss-recruit-mcp`、`boss-recruit-mcp`、旧本地 recruit repo、旧 vendor 脚本。
27
27
  - 浏览器自动化必须走 CDP-only 2.x MCP 工具;不得要求用户启用 legacy page-JS 或 `Runtime.evaluate` 路径。
28
+ - 启动 search/recruit run 时,若本机默认 `127.0.0.1:9222` Chrome DevTools 端口不可连,工具会自动打开 Chrome 并导航到 `https://www.zhipin.com/web/chat/search`。
29
+ - 只有工具返回 `BOSS_LOGIN_REQUIRED` / `requires_login=true` 时,才要求用户在自动打开的 Chrome 窗口人工登录 Boss 后重试;不要把“没开 9222 Chrome”当作缺参。
30
+ - 若本机找不到 Chrome,可提示用户设置 `BOSS_MCP_CHROME_PATH` 或 `BOSS_RECOMMEND_CHROME_PATH`;非本机 debug host 不自动启动。
28
31
  - 若用户未提供岗位,必须先询问岗位。搜索页岗位选择在关键词输入框旁边;不要猜测默认岗位。
29
32
  - 若用户提供城市、学历、学校、关键词、过滤已看、人选目标数、筛选条件、post action、max greet 等参数,必须逐项传入或确认。
30
33
  - `post_action=greet` 时必须确认 `max_greet_count`;不要默认等于 `target_count`。
package/src/chat-mcp.js CHANGED
@@ -4,8 +4,13 @@ import process from "node:process";
4
4
  import {
5
5
  assertNoForbiddenCdpCalls,
6
6
  bringPageToFront,
7
- connectToChromeTarget,
7
+ connectToChromeTargetOrOpen,
8
+ createBossLoginRequiredError,
9
+ detectBossLoginState,
8
10
  enableDomains,
11
+ getMainFrameUrl,
12
+ isBossLoginUrl,
13
+ waitForMainFrameUrl,
9
14
  sleep
10
15
  } from "./core/browser/index.js";
11
16
  import {
@@ -472,6 +477,14 @@ async function waitForHealthyChat(client, config, {
472
477
  const started = Date.now();
473
478
  let lastCheck = null;
474
479
  while (Date.now() - started <= timeoutMs) {
480
+ const loginDetection = await detectBossLoginState(client).catch(() => null);
481
+ if (loginDetection?.requires_login) {
482
+ return {
483
+ status: "login_required",
484
+ summary: "Boss login is required",
485
+ loginDetection
486
+ };
487
+ }
475
488
  const roots = await resolveChatSelfHealRoots(client, config);
476
489
  lastCheck = await runSelfHealCheck({
477
490
  client,
@@ -493,24 +506,18 @@ async function connectChatChromeSession({
493
506
  allowNavigate = true,
494
507
  slowLive = false
495
508
  } = {}) {
496
- let session;
497
- try {
498
- session = await connectToChromeTarget({
499
- host,
500
- port,
501
- targetUrlIncludes
502
- });
503
- } catch (error) {
504
- if (!allowNavigate) throw error;
505
- session = await connectToChromeTarget({
506
- host,
507
- port,
508
- targetPredicate: (target) => (
509
- target?.type === "page"
510
- && isRecoverableChatTargetUrl(target?.url)
511
- )
512
- });
513
- }
509
+ const session = await connectToChromeTargetOrOpen({
510
+ host,
511
+ port,
512
+ targetUrlIncludes,
513
+ targetUrl: CHAT_TARGET_URL,
514
+ allowNavigate,
515
+ slowLive,
516
+ fallbackTargetPredicate: (target) => (
517
+ target?.type === "page"
518
+ && (isRecoverableChatTargetUrl(target?.url) || String(target?.url || "").includes("zhipin.com"))
519
+ )
520
+ });
514
521
 
515
522
  const { client, target } = session;
516
523
  await enableDomains(client, ["Page", "DOM", "Input", "Network", "Accessibility"]);
@@ -527,20 +534,90 @@ async function connectChatChromeSession({
527
534
  if (allowNavigate && shouldNavigateToChat(targetUrl)) {
528
535
  await client.Page.navigate({ url: CHAT_TARGET_URL });
529
536
  const settleMs = slowLive ? 10000 : 5000;
530
- await sleep(settleMs);
537
+ const waited = await waitForMainFrameUrl(
538
+ client,
539
+ (url) => isBossLoginUrl(url) || !shouldNavigateToChat(url),
540
+ { timeoutMs: settleMs, intervalMs: 500 }
541
+ );
531
542
  navigation = {
532
543
  navigated: true,
533
544
  url: CHAT_TARGET_URL,
534
- settle_ms: settleMs
545
+ settle_ms: settleMs,
546
+ observed_url: waited.url || null,
547
+ observed_url_ok: waited.ok
535
548
  };
536
549
  }
550
+ let currentUrl = await getMainFrameUrl(client).catch(() => navigation.url || targetUrl);
551
+ if (allowNavigate && shouldNavigateToChat(currentUrl) && !isBossLoginUrl(currentUrl)) {
552
+ await client.Page.navigate({ url: CHAT_TARGET_URL });
553
+ const settleMs = slowLive ? 10000 : 5000;
554
+ const waited = await waitForMainFrameUrl(
555
+ client,
556
+ (url) => isBossLoginUrl(url) || !shouldNavigateToChat(url),
557
+ { timeoutMs: settleMs, intervalMs: 500 }
558
+ );
559
+ navigation = {
560
+ navigated: true,
561
+ url: CHAT_TARGET_URL,
562
+ settle_ms: settleMs,
563
+ observed_url: waited.url || null,
564
+ observed_url_ok: waited.ok,
565
+ reason: "observed_url_mismatch"
566
+ };
567
+ currentUrl = await getMainFrameUrl(client).catch(() => waited.url || currentUrl);
568
+ }
569
+ const loginDetection = await detectBossLoginState(client, { currentUrl }).catch(() => ({
570
+ requires_login: isBossLoginUrl(currentUrl),
571
+ reason: "login_detection_failed",
572
+ current_url: currentUrl
573
+ }));
574
+ if (loginDetection.requires_login) {
575
+ await session.close?.();
576
+ throw createBossLoginRequiredError({
577
+ domain: "chat",
578
+ currentUrl: loginDetection.current_url || currentUrl,
579
+ targetUrl: CHAT_TARGET_URL,
580
+ loginDetection,
581
+ chrome: session.chrome || null
582
+ });
583
+ }
584
+ if (shouldNavigateToChat(currentUrl)) {
585
+ await session.close?.();
586
+ throw new Error(`Boss chat page did not navigate to ${CHAT_TARGET_URL}; current URL: ${currentUrl || "unknown"}`);
587
+ }
537
588
 
538
589
  const selfHealConfig = buildChatSelfHealConfig();
539
590
  const health = await waitForHealthyChat(client, selfHealConfig, {
540
591
  timeoutMs: slowLive ? 180000 : 90000,
541
592
  intervalMs: slowLive ? 1200 : 800
542
593
  });
594
+ if (health?.loginDetection?.requires_login) {
595
+ await session.close?.();
596
+ throw createBossLoginRequiredError({
597
+ domain: "chat",
598
+ currentUrl: health.loginDetection.current_url || currentUrl,
599
+ targetUrl: CHAT_TARGET_URL,
600
+ loginDetection: health.loginDetection,
601
+ chrome: session.chrome || null
602
+ });
603
+ }
543
604
  if (!health || health.status !== HEALTH_STATUS.HEALTHY) {
605
+ const latestUrl = await getMainFrameUrl(client).catch(() => currentUrl);
606
+ const latestLoginDetection = await detectBossLoginState(client, { currentUrl: latestUrl }).catch(() => ({
607
+ requires_login: isBossLoginUrl(latestUrl),
608
+ reason: "login_detection_failed",
609
+ current_url: latestUrl
610
+ }));
611
+ if (latestLoginDetection.requires_login) {
612
+ await session.close?.();
613
+ throw createBossLoginRequiredError({
614
+ domain: "chat",
615
+ currentUrl: latestLoginDetection.current_url || latestUrl,
616
+ targetUrl: CHAT_TARGET_URL,
617
+ loginDetection: latestLoginDetection,
618
+ chrome: session.chrome || null
619
+ });
620
+ }
544
621
  throw new Error(`Boss chat page is not healthy: ${health?.status || "missing"}`);
545
622
  }
546
623
 
@@ -845,13 +922,21 @@ async function startBossChatRunInternal(args = {}, { workspaceRoot = "" } = {})
845
922
  slowLive: normalized.slowLive
846
923
  });
847
924
  } catch (error) {
925
+ const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
848
926
  return {
849
927
  status: "FAILED",
850
928
  error: {
851
- code: "BOSS_CHAT_PAGE_NOT_READY",
929
+ code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_CHAT_PAGE_NOT_READY",
852
930
  message: error?.message || "Boss chat page is not ready",
931
+ requires_login: Boolean(error?.requires_login),
932
+ login_url: error?.login_url || null,
933
+ login_detection: error?.login_detection || null,
934
+ chrome: error?.chrome || null,
935
+ current_url: error?.current_url || null,
936
+ target_url: error?.target_url || CHAT_TARGET_URL,
853
937
  retryable: true
854
- }
938
+ },
939
+ chrome: error?.chrome || null
855
940
  };
856
941
  }
857
942
 
@@ -880,7 +965,8 @@ async function startBossChatRunInternal(args = {}, { workspaceRoot = "" } = {})
880
965
  host: normalized.host,
881
966
  port: normalized.port,
882
967
  target_url: session.navigation?.url || session.target?.url || CHAT_TARGET_URL,
883
- target_id: session.target?.id || null
968
+ target_id: session.target?.id || null,
969
+ auto_launch: session.chrome || null
884
970
  },
885
971
  health: session.health || null
886
972
  });
@@ -912,12 +998,19 @@ export async function prepareBossChatRunTool({ workspaceRoot = "", args = {} } =
912
998
  slowLive: normalized.slowLive
913
999
  });
914
1000
  } catch (error) {
1001
+ const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
915
1002
  return {
916
1003
  status: "FAILED",
917
1004
  stage: "chat_run_setup",
918
1005
  error: {
919
- code: "BOSS_CHAT_PAGE_NOT_READY",
1006
+ code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_CHAT_PAGE_NOT_READY",
920
1007
  message: error?.message || "Boss chat page is not ready",
1008
+ requires_login: Boolean(error?.requires_login),
1009
+ login_url: error?.login_url || null,
1010
+ login_detection: error?.login_detection || null,
1011
+ chrome: error?.chrome || null,
1012
+ current_url: error?.current_url || null,
1013
+ target_url: error?.target_url || CHAT_TARGET_URL,
921
1014
  retryable: true
922
1015
  },
923
1016
  runtime_evaluate_used: false,
@@ -926,7 +1019,8 @@ export async function prepareBossChatRunTool({ workspaceRoot = "", args = {} } =
926
1019
  chrome: {
927
1020
  host: normalized.host,
928
1021
  port: normalized.port,
929
- target_url: CHAT_TARGET_URL
1022
+ target_url: CHAT_TARGET_URL,
1023
+ auto_launch: error?.chrome || null
930
1024
  }
931
1025
  };
932
1026
  }
@@ -979,16 +1073,24 @@ export async function prepareBossChatRunTool({ workspaceRoot = "", args = {} } =
979
1073
  host: normalized.host,
980
1074
  port: normalized.port,
981
1075
  target_url: session.navigation?.url || session.target?.url || CHAT_TARGET_URL,
982
- target_id: session.target?.id || null
1076
+ target_id: session.target?.id || null,
1077
+ auto_launch: session.chrome || null
983
1078
  }
984
1079
  };
985
1080
  } catch (error) {
1081
+ const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
986
1082
  return {
987
1083
  status: "FAILED",
988
1084
  stage: "chat_run_setup",
989
1085
  error: {
990
- code: "BOSS_CHAT_PREPARE_FAILED",
1086
+ code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_CHAT_PREPARE_FAILED",
991
1087
  message: error?.message || "Boss chat CDP-only prepare failed",
1088
+ requires_login: Boolean(error?.requires_login),
1089
+ login_url: error?.login_url || null,
1090
+ login_detection: error?.login_detection || null,
1091
+ chrome: error?.chrome || null,
1092
+ current_url: error?.current_url || null,
1093
+ target_url: error?.target_url || CHAT_TARGET_URL,
992
1094
  retryable: true
993
1095
  },
994
1096
  runtime_evaluate_used: false,
@@ -998,7 +1100,8 @@ export async function prepareBossChatRunTool({ workspaceRoot = "", args = {} } =
998
1100
  host: normalized.host,
999
1101
  port: normalized.port,
1000
1102
  target_url: session.navigation?.url || session.target?.url || CHAT_TARGET_URL,
1001
- target_id: session.target?.id || null
1103
+ target_id: session.target?.id || null,
1104
+ auto_launch: session.chrome || null
1002
1105
  }
1003
1106
  };
1004
1107
  } finally {
@@ -1073,17 +1176,25 @@ export async function bossChatHealthCheckTool({ workspaceRoot = "", args = {} }
1073
1176
  host,
1074
1177
  port,
1075
1178
  target_url: session.navigation?.url || session.target?.url || CHAT_TARGET_URL,
1076
- target_id: session.target?.id || null
1179
+ target_id: session.target?.id || null,
1180
+ auto_launch: session.chrome || null
1077
1181
  },
1078
1182
  message: "Boss chat CDP-only health check passed with shared self-heal probes."
1079
1183
  };
1080
1184
  } catch (error) {
1185
+ const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
1081
1186
  return {
1082
1187
  status: "FAILED",
1083
1188
  ...basePayload,
1084
1189
  error: {
1085
- code: "BOSS_CHAT_PAGE_NOT_READY",
1190
+ code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_CHAT_PAGE_NOT_READY",
1086
1191
  message: error?.message || "Boss chat page is not ready",
1192
+ requires_login: Boolean(error?.requires_login),
1193
+ login_url: error?.login_url || null,
1194
+ login_detection: error?.login_detection || null,
1195
+ chrome: error?.chrome || null,
1196
+ current_url: error?.current_url || null,
1197
+ target_url: error?.target_url || CHAT_TARGET_URL,
1087
1198
  retryable: true
1088
1199
  },
1089
1200
  runtime_evaluate_used: false,
@@ -1093,7 +1204,8 @@ export async function bossChatHealthCheckTool({ workspaceRoot = "", args = {} }
1093
1204
  host,
1094
1205
  port,
1095
1206
  target_url: session?.navigation?.url || session?.target?.url || targetUrlIncludes,
1096
- target_id: session?.target?.id || null
1207
+ target_id: session?.target?.id || null,
1208
+ auto_launch: error?.chrome || session?.chrome || null
1097
1209
  }
1098
1210
  };
1099
1211
  } finally {
@@ -1,7 +1,12 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
1
5
  import CDP from "chrome-remote-interface";
2
6
 
3
7
  export const DEFAULT_CHROME_HOST = "127.0.0.1";
4
8
  export const DEFAULT_CHROME_PORT = 9222;
9
+ export const BOSS_LOGIN_URL = "https://www.zhipin.com/web/user/?ka=bticket";
5
10
 
6
11
  export const ALLOWED_CDP_DOMAINS = new Set([
7
12
  "Accessibility",
@@ -14,6 +19,21 @@ export const ALLOWED_CDP_DOMAINS = new Set([
14
19
 
15
20
  export const FORBIDDEN_CDP_DOMAINS = new Set(["Runtime"]);
16
21
 
22
+ const BOSS_LOGIN_URL_PATTERN = /(?:zhipin\.com\/web\/user(?:\/|\?|$)|passport\.zhipin\.com|login\.zhipin\.com)/i;
23
+ const BOSS_LOGIN_TEXT_PATTERN = /扫码登录|验证码登录|密码登录|登录后|请登录|登录BOSS直聘|Boss登录|BOSS登录/i;
24
+ const CHROME_DEBUG_UNAVAILABLE_PATTERN = /ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|connect|socket hang up/i;
25
+ const BOSS_LOGIN_DOM_SELECTORS = [
26
+ ".login-box",
27
+ ".login-form",
28
+ ".login-dialog",
29
+ ".sign-form",
30
+ ".qrcode-box",
31
+ ".user-login",
32
+ "input[name='phone']",
33
+ "input[placeholder*='手机号']",
34
+ "input[placeholder*='验证码']"
35
+ ];
36
+
17
37
  function nowIso() {
18
38
  return new Date().toISOString();
19
39
  }
@@ -49,6 +69,378 @@ export function assertNoForbiddenCdpCalls(methodLog = []) {
49
69
  }
50
70
  }
51
71
 
72
+ export function isBossLoginUrl(url) {
73
+ return BOSS_LOGIN_URL_PATTERN.test(String(url || ""));
74
+ }
75
+
76
+ export function createBossLoginRequiredError({
77
+ domain = "boss",
78
+ currentUrl = "",
79
+ targetUrl = "",
80
+ loginUrl = BOSS_LOGIN_URL,
81
+ loginDetection = null,
82
+ chrome = null
83
+ } = {}) {
84
+ const error = new Error(`Boss login is required before starting the ${domain} run.`);
85
+ error.code = "BOSS_LOGIN_REQUIRED";
86
+ error.requires_login = true;
87
+ error.current_url = currentUrl || null;
88
+ error.target_url = targetUrl || null;
89
+ error.login_url = loginUrl;
90
+ error.login_detection = loginDetection || null;
91
+ error.chrome = chrome || null;
92
+ error.retryable = true;
93
+ return error;
94
+ }
95
+
96
+ export async function detectBossLoginState(client, { currentUrl = "" } = {}) {
97
+ const inspectedUrl = currentUrl || await getMainFrameUrl(client).catch(() => "");
98
+ if (isBossLoginUrl(inspectedUrl)) {
99
+ return {
100
+ requires_login: true,
101
+ reason: "url",
102
+ current_url: inspectedUrl,
103
+ matched_selectors: []
104
+ };
105
+ }
106
+
107
+ let root = null;
108
+ try {
109
+ root = await getDocumentRoot(client, { depth: 1, pierce: true });
110
+ } catch (error) {
111
+ return {
112
+ requires_login: false,
113
+ reason: "dom_unavailable",
114
+ current_url: inspectedUrl,
115
+ error: error?.message || String(error || "")
116
+ };
117
+ }
118
+
119
+ const matchedSelectors = [];
120
+ for (const selector of BOSS_LOGIN_DOM_SELECTORS) {
121
+ const nodeId = await querySelector(client, root.nodeId, selector).catch(() => 0);
122
+ if (nodeId) matchedSelectors.push(selector);
123
+ }
124
+
125
+ if (matchedSelectors.length === 0) {
126
+ return {
127
+ requires_login: false,
128
+ reason: "no_login_dom",
129
+ current_url: inspectedUrl,
130
+ matched_selectors: []
131
+ };
132
+ }
133
+
134
+ const html = await getOuterHTML(client, root.nodeId).catch(() => "");
135
+ const looksLikeLogin = BOSS_LOGIN_TEXT_PATTERN.test(html);
136
+ return {
137
+ requires_login: looksLikeLogin,
138
+ reason: looksLikeLogin ? "dom" : "login_selector_without_login_text",
139
+ current_url: inspectedUrl,
140
+ matched_selectors: matchedSelectors
141
+ };
142
+ }
143
+
144
+ export function isChromeDebugUnavailableError(error) {
145
+ return CHROME_DEBUG_UNAVAILABLE_PATTERN.test(String(error?.message || error || ""));
146
+ }
147
+
148
+ function pathExists(targetPath) {
149
+ try {
150
+ return Boolean(targetPath) && fs.existsSync(targetPath);
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ function ensureDir(targetPath) {
157
+ fs.mkdirSync(targetPath, { recursive: true });
158
+ }
159
+
160
+ function isLocalChromeHost(host) {
161
+ const normalized = String(host || "").trim().toLowerCase();
162
+ return !normalized || normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
163
+ }
164
+
165
+ function getCodexHome() {
166
+ return process.env.CODEX_HOME
167
+ ? path.resolve(process.env.CODEX_HOME)
168
+ : path.join(os.homedir(), ".codex");
169
+ }
170
+
171
+ function getDefaultChromeExecutableCandidates() {
172
+ const candidates = [
173
+ process.env.BOSS_MCP_CHROME_PATH,
174
+ process.env.BOSS_RECOMMEND_CHROME_PATH
175
+ ].filter(Boolean);
176
+ if (process.platform === "win32") {
177
+ candidates.push(
178
+ path.join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"),
179
+ path.join(process.env.ProgramFiles || "", "Google", "Chrome", "Application", "chrome.exe"),
180
+ path.join(process.env["ProgramFiles(x86)"] || "", "Google", "Chrome", "Application", "chrome.exe")
181
+ );
182
+ } else if (process.platform === "darwin") {
183
+ candidates.push(
184
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
185
+ path.join(os.homedir(), "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"),
186
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"
187
+ );
188
+ } else {
189
+ candidates.push(
190
+ "/usr/bin/google-chrome",
191
+ "/usr/bin/google-chrome-stable",
192
+ "/usr/bin/chromium-browser",
193
+ "/usr/bin/chromium",
194
+ "/snap/bin/chromium"
195
+ );
196
+ }
197
+ return Array.from(new Set(candidates.filter(Boolean)));
198
+ }
199
+
200
+ export function getChromeExecutable() {
201
+ return getDefaultChromeExecutableCandidates().find((candidate) => pathExists(candidate)) || null;
202
+ }
203
+
204
+ export function getBossChromeUserDataDir(port = DEFAULT_CHROME_PORT) {
205
+ const sharedPath = path.join(getCodexHome(), "boss-mcp", `chrome-profile-${port}`);
206
+ ensureDir(sharedPath);
207
+ return sharedPath;
208
+ }
209
+
210
+ export async function waitForChromeDebugPort({
211
+ host = DEFAULT_CHROME_HOST,
212
+ port = DEFAULT_CHROME_PORT,
213
+ timeoutMs = 8000,
214
+ intervalMs = 300
215
+ } = {}) {
216
+ const started = Date.now();
217
+ let lastError = null;
218
+ while (Date.now() - started <= timeoutMs) {
219
+ try {
220
+ const targets = await listChromeTargets({ host, port });
221
+ return {
222
+ ok: true,
223
+ elapsed_ms: Date.now() - started,
224
+ targets
225
+ };
226
+ } catch (error) {
227
+ lastError = error;
228
+ await sleep(intervalMs);
229
+ }
230
+ }
231
+ return {
232
+ ok: false,
233
+ elapsed_ms: Date.now() - started,
234
+ error: lastError?.message || String(lastError || "Chrome debug port did not become ready")
235
+ };
236
+ }
237
+
238
+ export async function launchChromeDebugInstance({
239
+ host = DEFAULT_CHROME_HOST,
240
+ port = DEFAULT_CHROME_PORT,
241
+ url = "about:blank",
242
+ slowLive = false
243
+ } = {}) {
244
+ if (!isLocalChromeHost(host)) {
245
+ throw new Error(`Cannot auto-launch Chrome for non-local debug host: ${host}`);
246
+ }
247
+ const chromePath = getChromeExecutable();
248
+ if (!chromePath) {
249
+ throw new Error("Chrome executable not found. Set BOSS_MCP_CHROME_PATH or BOSS_RECOMMEND_CHROME_PATH.");
250
+ }
251
+ const userDataDir = getBossChromeUserDataDir(port);
252
+ const args = [
253
+ `--remote-debugging-port=${port}`,
254
+ `--user-data-dir=${userDataDir}`,
255
+ "--no-first-run",
256
+ "--no-default-browser-check",
257
+ "--new-window",
258
+ url
259
+ ];
260
+ const child = spawn(chromePath, args, {
261
+ detached: true,
262
+ stdio: "ignore",
263
+ windowsHide: false
264
+ });
265
+ child.unref();
266
+ const readiness = await waitForChromeDebugPort({
267
+ host,
268
+ port,
269
+ timeoutMs: slowLive ? 30000 : 12000,
270
+ intervalMs: slowLive ? 700 : 300
271
+ });
272
+ if (!readiness.ok) {
273
+ throw new Error(`Chrome launched but DevTools port ${port} did not become reachable: ${readiness.error}`);
274
+ }
275
+ return {
276
+ launched: true,
277
+ chrome_path: chromePath,
278
+ user_data_dir: userDataDir,
279
+ port,
280
+ url,
281
+ readiness: {
282
+ elapsed_ms: readiness.elapsed_ms,
283
+ target_count: readiness.targets.length
284
+ }
285
+ };
286
+ }
287
+
288
+ export async function ensureChromeDebugPort({
289
+ host = DEFAULT_CHROME_HOST,
290
+ port = DEFAULT_CHROME_PORT,
291
+ url = "about:blank",
292
+ slowLive = false,
293
+ launchIfMissing = true
294
+ } = {}) {
295
+ try {
296
+ const targets = await listChromeTargets({ host, port });
297
+ return {
298
+ launched: false,
299
+ reused: true,
300
+ port,
301
+ target_count: targets.length
302
+ };
303
+ } catch (error) {
304
+ if (!launchIfMissing || !isChromeDebugUnavailableError(error)) {
305
+ throw error;
306
+ }
307
+ return launchChromeDebugInstance({ host, port, url, slowLive });
308
+ }
309
+ }
310
+
311
+ export async function openChromeTarget({
312
+ host = DEFAULT_CHROME_HOST,
313
+ port = DEFAULT_CHROME_PORT,
314
+ url
315
+ } = {}) {
316
+ const encodedUrl = encodeURIComponent(url || "about:blank");
317
+ const endpoint = `http://${host}:${port}/json/new?${encodedUrl}`;
318
+ const methods = ["PUT", "GET"];
319
+ let lastError = null;
320
+ for (const method of methods) {
321
+ try {
322
+ const response = await fetch(endpoint, { method });
323
+ if (response.ok) {
324
+ let payload = null;
325
+ try {
326
+ payload = await response.json();
327
+ } catch {}
328
+ return {
329
+ ok: true,
330
+ method,
331
+ target_id: payload?.id || null,
332
+ url: payload?.url || url || null
333
+ };
334
+ }
335
+ lastError = new Error(`DevTools /json/new returned ${response.status}`);
336
+ } catch (error) {
337
+ lastError = error;
338
+ }
339
+ }
340
+ return {
341
+ ok: false,
342
+ error: lastError?.message || "Failed to open Chrome target"
343
+ };
344
+ }
345
+
346
+ export async function connectToChromeTargetOrOpen({
347
+ host = DEFAULT_CHROME_HOST,
348
+ port = DEFAULT_CHROME_PORT,
349
+ targetUrlIncludes,
350
+ targetPredicate,
351
+ fallbackTargetPredicate,
352
+ targetUrl,
353
+ allowNavigate = true,
354
+ slowLive = false,
355
+ launchIfMissing = true
356
+ } = {}) {
357
+ let chrome = null;
358
+ if (allowNavigate && targetUrl) {
359
+ chrome = await ensureChromeDebugPort({
360
+ host,
361
+ port,
362
+ url: targetUrl,
363
+ slowLive,
364
+ launchIfMissing
365
+ });
366
+ }
367
+
368
+ try {
369
+ const session = await connectToChromeTarget({
370
+ host,
371
+ port,
372
+ targetUrlIncludes,
373
+ targetPredicate
374
+ });
375
+ return {
376
+ ...session,
377
+ chrome: {
378
+ ...(chrome || { launched: false, reused: true, port }),
379
+ target_created: false
380
+ }
381
+ };
382
+ } catch (primaryError) {
383
+ if (!allowNavigate) throw primaryError;
384
+
385
+ if (typeof fallbackTargetPredicate === "function") {
386
+ try {
387
+ const session = await connectToChromeTarget({
388
+ host,
389
+ port,
390
+ targetPredicate: fallbackTargetPredicate
391
+ });
392
+ return {
393
+ ...session,
394
+ chrome: {
395
+ ...(chrome || { launched: false, reused: true, port }),
396
+ target_created: false,
397
+ fallback_target: true
398
+ }
399
+ };
400
+ } catch {}
401
+ }
402
+
403
+ let openAttempt = null;
404
+ if (targetUrl) {
405
+ openAttempt = await openChromeTarget({ host, port, url: targetUrl });
406
+ if (openAttempt.ok) {
407
+ const session = await connectToChromeTarget({
408
+ host,
409
+ port,
410
+ targetPredicate: (target) => (
411
+ (openAttempt.target_id && target?.id === openAttempt.target_id)
412
+ || String(target?.url || "").includes(targetUrlIncludes || targetUrl)
413
+ || (targetUrl.includes("zhipin.com") && String(target?.url || "").includes("zhipin.com"))
414
+ )
415
+ });
416
+ return {
417
+ ...session,
418
+ chrome: {
419
+ ...(chrome || { launched: false, reused: true, port }),
420
+ target_created: true,
421
+ open_attempt: openAttempt
422
+ }
423
+ };
424
+ }
425
+ }
426
+
427
+ const session = await connectToChromeTarget({
428
+ host,
429
+ port,
430
+ targetPredicate: (target) => target?.type === "page"
431
+ });
432
+ return {
433
+ ...session,
434
+ chrome: {
435
+ ...(chrome || { launched: false, reused: true, port }),
436
+ target_created: false,
437
+ open_attempt: openAttempt,
438
+ fallback_any_page: true
439
+ }
440
+ };
441
+ }
442
+ }
443
+
52
444
  export function createGuardedCdpClient(client, { methodLog = [] } = {}) {
53
445
  return new Proxy(client, {
54
446
  get(target, property, receiver) {
@@ -332,7 +332,7 @@ export async function runRecommendWorkflow({
332
332
  fallbackPageScope = "recommend",
333
333
  filter = {},
334
334
  maxCandidates = 5,
335
- detailLimit = 0,
335
+ detailLimit,
336
336
  closeDetail = true,
337
337
  delayMs = 0,
338
338
  cardTimeoutMs = 10000,
@@ -362,7 +362,7 @@ export async function runRecommendWorkflow({
362
362
  const normalizedFallbackPageScope = normalizeRecommendPageScope(fallbackPageScope) || "recommend";
363
363
  const postActionEnabled = normalizedPostAction !== "none";
364
364
  const limit = Math.max(1, Number(maxCandidates) || 1);
365
- const detailCountLimit = Math.max(0, Number(detailLimit) || 0);
365
+ const detailCountLimit = detailLimit == null ? limit : Math.max(0, Number(detailLimit) || 0);
366
366
  const effectiveDetailLimit = postActionEnabled ? limit : detailCountLimit;
367
367
  const networkRecorder = effectiveDetailLimit > 0
368
368
  ? createRecommendDetailNetworkRecorder(client)
@@ -785,7 +785,7 @@ export function createRecommendRunService({
785
785
  fallbackPageScope = "recommend",
786
786
  filter = {},
787
787
  maxCandidates = 5,
788
- detailLimit = 0,
788
+ detailLimit,
789
789
  closeDetail = true,
790
790
  delayMs = 0,
791
791
  cardTimeoutMs = 10000,
@@ -814,6 +814,8 @@ export function createRecommendRunService({
814
814
  const normalizedPostAction = normalizeRecommendPostAction(postAction) || "none";
815
815
  const requestedPageScope = normalizeRecommendPageScope(pageScope) || "recommend";
816
816
  const normalizedFallbackPageScope = normalizeRecommendPageScope(fallbackPageScope) || "recommend";
817
+ const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
818
+ const normalizedDetailLimit = detailLimit == null ? candidateLimit : Math.max(0, Number(detailLimit) || 0);
817
819
  return manager.startRun({
818
820
  name,
819
821
  context: {
@@ -825,7 +827,7 @@ export function createRecommendRunService({
825
827
  fallback_page_scope: normalizedFallbackPageScope,
826
828
  filter: normalizedFilter,
827
829
  max_candidates: maxCandidates,
828
- detail_limit: detailLimit,
830
+ detail_limit: normalizedDetailLimit,
829
831
  close_detail: closeDetail,
830
832
  cv_acquisition_mode: cvAcquisitionMode,
831
833
  max_image_pages: maxImagePages,
@@ -846,7 +848,7 @@ export function createRecommendRunService({
846
848
  },
847
849
  progress: {
848
850
  card_count: 0,
849
- target_count: Math.max(1, Number(maxCandidates) || 1),
851
+ target_count: candidateLimit,
850
852
  processed: 0,
851
853
  screened: 0,
852
854
  detail_opened: 0,
@@ -864,7 +866,7 @@ export function createRecommendRunService({
864
866
  fallbackPageScope: normalizedFallbackPageScope,
865
867
  filter: normalizedFilter,
866
868
  maxCandidates,
867
- detailLimit,
869
+ detailLimit: normalizedDetailLimit,
868
870
  closeDetail,
869
871
  delayMs,
870
872
  cardTimeoutMs,
package/src/index.js CHANGED
@@ -540,7 +540,11 @@ function createRunInputSchema() {
540
540
  detail_limit: {
541
541
  type: "integer",
542
542
  minimum: 0,
543
- description: "打开详情的人数上限;默认 00 表示只用卡片信息"
543
+ description: "打开详情/CV 的人数上限;默认跟随 target_count/max_candidates。生产筛选不应传 0;只有 allow_card_only_screening=true 时才会接受 0 作为调试卡片-only 模式"
544
+ },
545
+ allow_card_only_screening: {
546
+ type: "boolean",
547
+ description: "高级调试开关;默认 false。只有显式为 true 时,recommend 才会尊重 detail_limit=0 并只用卡片信息筛选"
544
548
  },
545
549
  delay_ms: {
546
550
  type: "integer",
@@ -4,8 +4,13 @@ import process from "node:process";
4
4
  import {
5
5
  assertNoForbiddenCdpCalls,
6
6
  bringPageToFront,
7
- connectToChromeTarget,
7
+ connectToChromeTargetOrOpen,
8
+ createBossLoginRequiredError,
9
+ detectBossLoginState,
8
10
  enableDomains,
11
+ getMainFrameUrl,
12
+ isBossLoginUrl,
13
+ waitForMainFrameUrl,
9
14
  sleep
10
15
  } from "./core/browser/index.js";
11
16
  import {
@@ -75,6 +80,15 @@ function parseNonNegativeInteger(raw, fallback) {
75
80
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
76
81
  }
77
82
 
83
+ function resolveRecommendDetailLimit(args = {}, normalized = {}) {
84
+ const fallback = parsePositiveInteger(normalized.targetCount, 5);
85
+ const requested = parseNonNegativeInteger(args.detail_limit, fallback);
86
+ if (requested === 0 && args.allow_card_only_screening !== true) {
87
+ return fallback;
88
+ }
89
+ return requested;
90
+ }
91
+
78
92
  function methodSummary(methodLog = []) {
79
93
  const summary = {};
80
94
  for (const entry of methodLog || []) {
@@ -517,27 +531,36 @@ export async function listRecommendJobsTool({ workspaceRoot = "", args = {} } =
517
531
  host,
518
532
  port,
519
533
  target_url: session.navigation?.url || session.target?.url || RECOMMEND_TARGET_URL,
520
- target_id: session.target?.id || null
534
+ target_id: session.target?.id || null,
535
+ auto_launch: session.chrome || null
521
536
  },
522
537
  method_summary: methodSummary(session.methodLog || []),
523
538
  method_log: session.methodLog || []
524
539
  };
525
540
  } catch (error) {
526
541
  const methodLog = session?.methodLog || [];
542
+ const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
527
543
  return {
528
544
  status: "FAILED",
529
545
  stage: "recommend_job_list",
530
546
  cdp_only: true,
531
547
  runtime_evaluate_used: methodLog.some((entry) => String(entry?.method || entry).startsWith("Runtime.")),
532
548
  error: {
533
- code: "RECOMMEND_JOB_LIST_FAILED",
549
+ code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "RECOMMEND_JOB_LIST_FAILED",
534
550
  message: error?.message || "Failed to read recommend job list",
551
+ requires_login: Boolean(error?.requires_login),
552
+ login_url: error?.login_url || null,
553
+ login_detection: error?.login_detection || null,
554
+ current_url: error?.current_url || null,
555
+ target_url: error?.target_url || RECOMMEND_TARGET_URL,
556
+ chrome: error?.chrome || null,
535
557
  retryable: true
536
558
  },
537
559
  chrome: {
538
560
  host,
539
561
  port,
540
- target_url: targetUrlIncludes
562
+ target_url: targetUrlIncludes,
563
+ auto_launch: error?.chrome || session?.chrome || null
541
564
  },
542
565
  method_summary: methodSummary(methodLog),
543
566
  method_log: methodLog
@@ -576,6 +599,14 @@ async function waitForHealthyRecommend(client, config, {
576
599
  const started = Date.now();
577
600
  let lastCheck = null;
578
601
  while (Date.now() - started <= timeoutMs) {
602
+ const loginDetection = await detectBossLoginState(client).catch(() => null);
603
+ if (loginDetection?.requires_login) {
604
+ return {
605
+ status: "login_required",
606
+ summary: "Boss login is required",
607
+ loginDetection
608
+ };
609
+ }
579
610
  const roots = await resolveRecommendSelfHealRoots(client, config);
580
611
  lastCheck = await runSelfHealCheck({
581
612
  client,
@@ -601,24 +632,18 @@ async function connectRecommendChromeSession({
601
632
  allowNavigate = true,
602
633
  slowLive = false
603
634
  } = {}) {
604
- let session;
605
- try {
606
- session = await connectToChromeTarget({
607
- host,
608
- port,
609
- targetUrlIncludes
610
- });
611
- } catch (error) {
612
- if (!allowNavigate) throw error;
613
- session = await connectToChromeTarget({
614
- host,
615
- port,
616
- targetPredicate: (target) => (
617
- target?.type === "page"
618
- && String(target?.url || "").includes("zhipin.com/web/chat")
619
- )
620
- });
621
- }
635
+ const session = await connectToChromeTargetOrOpen({
636
+ host,
637
+ port,
638
+ targetUrlIncludes,
639
+ targetUrl: RECOMMEND_TARGET_URL,
640
+ allowNavigate,
641
+ slowLive,
642
+ fallbackTargetPredicate: (target) => (
643
+ target?.type === "page"
644
+ && String(target?.url || "").includes("zhipin.com")
645
+ )
646
+ });
622
647
 
623
648
  const { client, target } = session;
624
649
  await enableDomains(client, ["Page", "DOM", "Input", "Network", "Accessibility"]);
@@ -635,12 +660,56 @@ async function connectRecommendChromeSession({
635
660
  if (allowNavigate && shouldNavigateToRecommend(targetUrl)) {
636
661
  await client.Page.navigate({ url: RECOMMEND_TARGET_URL });
637
662
  const settleMs = slowLive ? 12000 : 5000;
638
- await sleep(settleMs);
663
+ const waited = await waitForMainFrameUrl(
664
+ client,
665
+ (url) => isBossLoginUrl(url) || !shouldNavigateToRecommend(url),
666
+ { timeoutMs: settleMs, intervalMs: 500 }
667
+ );
668
+ navigation = {
669
+ navigated: true,
670
+ url: RECOMMEND_TARGET_URL,
671
+ settle_ms: settleMs,
672
+ observed_url: waited.url || null,
673
+ observed_url_ok: waited.ok
674
+ };
675
+ }
676
+ let currentUrl = await getMainFrameUrl(client).catch(() => navigation.url || targetUrl);
677
+ if (allowNavigate && shouldNavigateToRecommend(currentUrl) && !isBossLoginUrl(currentUrl)) {
678
+ await client.Page.navigate({ url: RECOMMEND_TARGET_URL });
679
+ const settleMs = slowLive ? 12000 : 5000;
680
+ const waited = await waitForMainFrameUrl(
681
+ client,
682
+ (url) => isBossLoginUrl(url) || !shouldNavigateToRecommend(url),
683
+ { timeoutMs: settleMs, intervalMs: 500 }
684
+ );
639
685
  navigation = {
640
686
  navigated: true,
641
687
  url: RECOMMEND_TARGET_URL,
642
- settle_ms: settleMs
688
+ settle_ms: settleMs,
689
+ observed_url: waited.url || null,
690
+ observed_url_ok: waited.ok,
691
+ reason: "observed_url_mismatch"
643
692
  };
693
+ currentUrl = await getMainFrameUrl(client).catch(() => waited.url || currentUrl);
694
+ }
695
+ const loginDetection = await detectBossLoginState(client, { currentUrl }).catch(() => ({
696
+ requires_login: isBossLoginUrl(currentUrl),
697
+ reason: "login_detection_failed",
698
+ current_url: currentUrl
699
+ }));
700
+ if (loginDetection.requires_login) {
701
+ await session.close?.();
702
+ throw createBossLoginRequiredError({
703
+ domain: "recommend",
704
+ currentUrl: loginDetection.current_url || currentUrl,
705
+ targetUrl: RECOMMEND_TARGET_URL,
706
+ loginDetection,
707
+ chrome: session.chrome || null
708
+ });
709
+ }
710
+ if (shouldNavigateToRecommend(currentUrl)) {
711
+ await session.close?.();
712
+ throw new Error(`Boss recommend page did not navigate to ${RECOMMEND_TARGET_URL}; current URL: ${currentUrl || "unknown"}`);
644
713
  }
645
714
 
646
715
  const selfHealConfig = buildRecommendSelfHealConfig();
@@ -648,7 +717,33 @@ async function connectRecommendChromeSession({
648
717
  timeoutMs: slowLive ? 180000 : 90000,
649
718
  intervalMs: slowLive ? 1200 : 800
650
719
  });
720
+ if (health?.loginDetection?.requires_login) {
721
+ await session.close?.();
722
+ throw createBossLoginRequiredError({
723
+ domain: "recommend",
724
+ currentUrl: health.loginDetection.current_url || currentUrl,
725
+ targetUrl: RECOMMEND_TARGET_URL,
726
+ loginDetection: health.loginDetection,
727
+ chrome: session.chrome || null
728
+ });
729
+ }
651
730
  if (!health || health.status !== HEALTH_STATUS.HEALTHY) {
731
+ const latestUrl = await getMainFrameUrl(client).catch(() => currentUrl);
732
+ const latestLoginDetection = await detectBossLoginState(client, { currentUrl: latestUrl }).catch(() => ({
733
+ requires_login: isBossLoginUrl(latestUrl),
734
+ reason: "login_detection_failed",
735
+ current_url: latestUrl
736
+ }));
737
+ if (latestLoginDetection.requires_login) {
738
+ await session.close?.();
739
+ throw createBossLoginRequiredError({
740
+ domain: "recommend",
741
+ currentUrl: latestLoginDetection.current_url || latestUrl,
742
+ targetUrl: RECOMMEND_TARGET_URL,
743
+ loginDetection: latestLoginDetection,
744
+ chrome: session.chrome || null
745
+ });
746
+ }
652
747
  throw new Error(`Boss recommend page is not healthy: ${health?.status || "missing"}`);
653
748
  }
654
749
 
@@ -867,7 +962,7 @@ function getRunOptions(args, parsed, normalized, session) {
867
962
  fallbackPageScope: "recommend",
868
963
  filter: normalized.filter,
869
964
  maxCandidates: normalized.targetCount,
870
- detailLimit: parseNonNegativeInteger(args.detail_limit, 0),
965
+ detailLimit: resolveRecommendDetailLimit(args, normalized),
871
966
  closeDetail: true,
872
967
  delayMs: parseNonNegativeInteger(args.delay_ms, 0),
873
968
  cardTimeoutMs: slowLive ? 180000 : 90000,
@@ -955,13 +1050,21 @@ async function startRecommendPipelineRunInternal(args = {}, { workspaceRoot = ""
955
1050
  slowLive: normalized.slowLive
956
1051
  });
957
1052
  } catch (error) {
1053
+ const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
958
1054
  return {
959
1055
  status: "FAILED",
960
1056
  error: {
961
- code: "BOSS_RECOMMEND_PAGE_NOT_READY",
1057
+ code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_RECOMMEND_PAGE_NOT_READY",
962
1058
  message: error?.message || "Boss recommend page is not ready",
1059
+ requires_login: Boolean(error?.requires_login),
1060
+ login_url: error?.login_url || null,
1061
+ login_detection: error?.login_detection || null,
1062
+ chrome: error?.chrome || null,
1063
+ current_url: error?.current_url || null,
1064
+ target_url: error?.target_url || RECOMMEND_TARGET_URL,
963
1065
  retryable: true
964
- }
1066
+ },
1067
+ chrome: error?.chrome || null
965
1068
  };
966
1069
  }
967
1070
 
@@ -991,7 +1094,8 @@ async function startRecommendPipelineRunInternal(args = {}, { workspaceRoot = ""
991
1094
  host: normalized.host,
992
1095
  port: normalized.port,
993
1096
  target_url: session.navigation?.url || session.target?.url || RECOMMEND_TARGET_URL,
994
- target_id: session.target?.id || null
1097
+ target_id: session.target?.id || null,
1098
+ auto_launch: session.chrome || null
995
1099
  },
996
1100
  health: session.health || null
997
1101
  });
@@ -4,8 +4,13 @@ import path from "node:path";
4
4
  import {
5
5
  assertNoForbiddenCdpCalls,
6
6
  bringPageToFront,
7
- connectToChromeTarget,
7
+ connectToChromeTargetOrOpen,
8
+ createBossLoginRequiredError,
9
+ detectBossLoginState,
8
10
  enableDomains,
11
+ getMainFrameUrl,
12
+ isBossLoginUrl,
13
+ waitForMainFrameUrl,
9
14
  sleep
10
15
  } from "./core/browser/index.js";
11
16
  import {
@@ -586,6 +591,32 @@ function attachMethodEvidence(payload, runId) {
586
591
  };
587
592
  }
588
593
 
594
+ async function waitForRecruitSearchControlsOrLogin(client, {
595
+ timeoutMs = 90000,
596
+ intervalMs = 300
597
+ } = {}) {
598
+ const started = Date.now();
599
+ let lastControls = null;
600
+ while (Date.now() - started <= timeoutMs) {
601
+ const loginDetection = await detectBossLoginState(client).catch(() => null);
602
+ if (loginDetection?.requires_login) {
603
+ return {
604
+ ok: false,
605
+ reason: "login_required",
606
+ loginDetection
607
+ };
608
+ }
609
+ const remainingMs = Math.max(1, timeoutMs - (Date.now() - started));
610
+ lastControls = await waitForRecruitSearchControls(client, {
611
+ timeoutMs: Math.min(remainingMs, 1500),
612
+ intervalMs
613
+ });
614
+ if (lastControls.ok) return lastControls;
615
+ await sleep(intervalMs);
616
+ }
617
+ return lastControls || { ok: false, reason: "timeout" };
618
+ }
619
+
589
620
  async function connectRecruitChromeSession({
590
621
  host = DEFAULT_RECRUIT_HOST,
591
622
  port = DEFAULT_RECRUIT_PORT,
@@ -593,24 +624,18 @@ async function connectRecruitChromeSession({
593
624
  allowNavigate = true,
594
625
  slowLive = false
595
626
  } = {}) {
596
- let session;
597
- try {
598
- session = await connectToChromeTarget({
599
- host,
600
- port,
601
- targetUrlIncludes
602
- });
603
- } catch (error) {
604
- if (!allowNavigate) throw error;
605
- session = await connectToChromeTarget({
606
- host,
607
- port,
608
- targetPredicate: (target) => (
609
- target?.type === "page"
610
- && String(target?.url || "").includes("zhipin.com/web/chat")
611
- )
612
- });
613
- }
627
+ const session = await connectToChromeTargetOrOpen({
628
+ host,
629
+ port,
630
+ targetUrlIncludes,
631
+ targetUrl: RECRUIT_TARGET_URL,
632
+ allowNavigate,
633
+ slowLive,
634
+ fallbackTargetPredicate: (target) => (
635
+ target?.type === "page"
636
+ && String(target?.url || "").includes("zhipin.com")
637
+ )
638
+ });
614
639
 
615
640
  const { client, target } = session;
616
641
  await enableDomains(client, ["Page", "DOM", "Input", "Network", "Accessibility"]);
@@ -620,21 +645,102 @@ async function connectRecruitChromeSession({
620
645
  await bringPageToFront(client);
621
646
 
622
647
  const targetUrl = String(target?.url || "");
648
+ let navigation = {
649
+ navigated: false,
650
+ url: targetUrl
651
+ };
623
652
  if (allowNavigate && !targetUrl.includes(targetUrlIncludes)) {
624
653
  await client.Page.navigate({ url: RECRUIT_TARGET_URL });
625
- await sleep(slowLive ? 8000 : 3000);
654
+ const settleMs = slowLive ? 8000 : 3000;
655
+ const waited = await waitForMainFrameUrl(
656
+ client,
657
+ (url) => isBossLoginUrl(url) || String(url || "").includes(RECRUIT_TARGET_URL),
658
+ { timeoutMs: settleMs, intervalMs: 500 }
659
+ );
660
+ navigation = {
661
+ navigated: true,
662
+ url: RECRUIT_TARGET_URL,
663
+ settle_ms: settleMs,
664
+ observed_url: waited.url || null,
665
+ observed_url_ok: waited.ok
666
+ };
667
+ }
668
+ let currentUrl = await getMainFrameUrl(client).catch(() => targetUrl);
669
+ if (allowNavigate && !String(currentUrl || "").includes(RECRUIT_TARGET_URL) && !isBossLoginUrl(currentUrl)) {
670
+ await client.Page.navigate({ url: RECRUIT_TARGET_URL });
671
+ const settleMs = slowLive ? 8000 : 3000;
672
+ const waited = await waitForMainFrameUrl(
673
+ client,
674
+ (url) => isBossLoginUrl(url) || String(url || "").includes(RECRUIT_TARGET_URL),
675
+ { timeoutMs: settleMs, intervalMs: 500 }
676
+ );
677
+ navigation = {
678
+ navigated: true,
679
+ url: RECRUIT_TARGET_URL,
680
+ settle_ms: settleMs,
681
+ observed_url: waited.url || null,
682
+ observed_url_ok: waited.ok,
683
+ reason: "observed_url_mismatch"
684
+ };
685
+ currentUrl = await getMainFrameUrl(client).catch(() => waited.url || currentUrl);
686
+ }
687
+ const loginDetection = await detectBossLoginState(client, { currentUrl }).catch(() => ({
688
+ requires_login: isBossLoginUrl(currentUrl),
689
+ reason: "login_detection_failed",
690
+ current_url: currentUrl
691
+ }));
692
+ if (loginDetection.requires_login) {
693
+ await session.close?.();
694
+ throw createBossLoginRequiredError({
695
+ domain: "search",
696
+ currentUrl: loginDetection.current_url || currentUrl,
697
+ targetUrl: RECRUIT_TARGET_URL,
698
+ loginDetection,
699
+ chrome: session.chrome || null
700
+ });
701
+ }
702
+ if (!String(currentUrl || "").includes(RECRUIT_TARGET_URL)) {
703
+ await session.close?.();
704
+ throw new Error(`Boss search page did not navigate to ${RECRUIT_TARGET_URL}; current URL: ${currentUrl || "unknown"}`);
626
705
  }
627
706
 
628
- const controls = await waitForRecruitSearchControls(client, {
707
+ const controls = await waitForRecruitSearchControlsOrLogin(client, {
629
708
  timeoutMs: slowLive ? 180000 : 90000,
630
709
  intervalMs: 300
631
710
  });
711
+ if (controls.loginDetection?.requires_login) {
712
+ await session.close?.();
713
+ throw createBossLoginRequiredError({
714
+ domain: "search",
715
+ currentUrl: controls.loginDetection.current_url || currentUrl,
716
+ targetUrl: RECRUIT_TARGET_URL,
717
+ loginDetection: controls.loginDetection,
718
+ chrome: session.chrome || null
719
+ });
720
+ }
632
721
  if (!controls.ok) {
722
+ const latestUrl = await getMainFrameUrl(client).catch(() => currentUrl);
723
+ const latestLoginDetection = await detectBossLoginState(client, { currentUrl: latestUrl }).catch(() => ({
724
+ requires_login: isBossLoginUrl(latestUrl),
725
+ reason: "login_detection_failed",
726
+ current_url: latestUrl
727
+ }));
728
+ if (latestLoginDetection.requires_login) {
729
+ await session.close?.();
730
+ throw createBossLoginRequiredError({
731
+ domain: "search",
732
+ currentUrl: latestLoginDetection.current_url || latestUrl,
733
+ targetUrl: RECRUIT_TARGET_URL,
734
+ loginDetection: latestLoginDetection,
735
+ chrome: session.chrome || null
736
+ });
737
+ }
633
738
  throw new Error("Boss recruit search page did not expose ready search controls");
634
739
  }
635
740
 
636
741
  return {
637
742
  ...session,
743
+ navigation,
638
744
  controls
639
745
  };
640
746
  }
@@ -712,13 +818,21 @@ async function startRecruitPipelineRunInternal(args = {}, { workspaceRoot = "" }
712
818
  slowLive: args.slow_live === true
713
819
  });
714
820
  } catch (error) {
821
+ const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
715
822
  return {
716
823
  status: "FAILED",
717
824
  error: {
718
- code: "BOSS_SEARCH_PAGE_NOT_READY",
825
+ code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_SEARCH_PAGE_NOT_READY",
719
826
  message: error?.message || "Boss recruit search page is not ready",
827
+ requires_login: Boolean(error?.requires_login),
828
+ login_url: error?.login_url || null,
829
+ login_detection: error?.login_detection || null,
830
+ chrome: error?.chrome || null,
831
+ current_url: error?.current_url || null,
832
+ target_url: error?.target_url || RECRUIT_TARGET_URL,
720
833
  retryable: true
721
- }
834
+ },
835
+ chrome: error?.chrome || null
722
836
  };
723
837
  }
724
838
 
@@ -747,7 +861,8 @@ async function startRecruitPipelineRunInternal(args = {}, { workspaceRoot = "" }
747
861
  host: normalizeText(args.host) || DEFAULT_RECRUIT_HOST,
748
862
  port: parsePositiveInteger(args.port, DEFAULT_RECRUIT_PORT),
749
863
  target_url: session.target?.url || RECRUIT_TARGET_URL,
750
- target_id: session.target?.id || null
864
+ target_id: session.target?.id || null,
865
+ auto_launch: session.chrome || null
751
866
  },
752
867
  parsed
753
868
  });