@reconcrap/boss-recommend-mcp 1.0.12 → 1.0.14

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 CHANGED
@@ -9,8 +9,8 @@ Boss 推荐页自动化流水线 MCP(stdio)服务。
9
9
 
10
10
  MCP 工具:
11
11
 
12
- - `run_recommend_pipeline`(默认异步启动;传 `execution_mode=sync` 时同步执行)
13
- - `start_recommend_pipeline_run`(异步启动,立即返回 run_id)
12
+ - `run_recommend_pipeline`(默认异步,但会先执行与同步一致的前置门禁;仅门禁通过后返回 run_id)
13
+ - `start_recommend_pipeline_run`(异步启动;同样先经过前置门禁,通过后返回 run_id)
14
14
  - `get_recommend_pipeline_run`(轮询 run_id 状态)
15
15
  - `cancel_recommend_pipeline_run`(取消运行中任务)
16
16
 
@@ -143,13 +143,14 @@ node src/cli.js run --instruction-file request.txt --confirmation-file confirmat
143
143
 
144
144
  当宿主 agent 对“长时间无回包”敏感(容易误判失败)时,建议改用异步工具:
145
145
 
146
- 1. 优先直接调用 `run_recommend_pipeline`,默认会返回异步 `run_id`。
147
- 2. 5~15 秒调用一次 `get_recommend_pipeline_run` 轮询。
148
- 3. 若需终止,调用 `cancel_recommend_pipeline_run`。
146
+ 1. 优先调用 `run_recommend_pipeline`(默认异步)。
147
+ 2. 若返回 `NEED_INPUT/NEED_CONFIRMATION/FAILED`,按同步流程先补齐前置条件(登录、页面就绪、岗位确认、最终确认)。
148
+ 3. 仅当门禁通过时,接口才会返回 `ACCEPTED + run_id`;随后每 5~15 秒调用一次 `get_recommend_pipeline_run` 轮询。
149
+ 4. 若需终止,调用 `cancel_recommend_pipeline_run`。
149
150
 
150
151
  说明:
151
152
 
152
- - `run_recommend_pipeline` 现在默认异步;若确实需要阻塞式返回,可传 `execution_mode=sync`。
153
+ - `run_recommend_pipeline` 默认异步,但不会跳过同步确认流程;若确实需要阻塞式返回,可传 `execution_mode=sync`。
153
154
  - 定时心跳默认 120 秒一次;`updated_at` 仍会在阶段或进度变化时刷新。
154
155
  - 每个 run 会持久化到 `~/.boss-recommend-mcp/runs/<run_id>.json`(可通过 `BOSS_RECOMMEND_HOME` 覆盖)。
155
156
  - 轮询期间不要重复 `start`,优先复用已有 `run_id`,避免重复筛选。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -117,7 +117,8 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
117
117
 
118
118
  长耗时宿主兼容(推荐):
119
119
 
120
- - `run_recommend_pipeline` 现在默认直接异步启动。
120
+ - `run_recommend_pipeline` 默认异步,但开始前会先走与同步一致的前置门禁(登录/页面就绪/岗位确认/最终确认)。
121
+ - 只有门禁通过后才会返回 `ACCEPTED + run_id`;否则会先返回 `NEED_INPUT/NEED_CONFIRMATION/FAILED`,必须先按提示补齐。
121
122
  - 若宿主要显式拆成三步,也可使用:
122
123
  - `start_recommend_pipeline_run`
123
124
  - `get_recommend_pipeline_run`
package/src/index.js CHANGED
@@ -221,12 +221,12 @@ function createToolsSchema() {
221
221
  return [
222
222
  {
223
223
  name: TOOL_RUN_PIPELINE,
224
- description: "Boss 推荐页流水线:默认异步启动并返回 run_id;传 execution_mode=sync 时改为同步执行。",
224
+ description: "Boss 推荐页流水线:默认异步,但会先走与同步一致的前置确认/页面就绪门禁;仅在门禁通过后返回 run_id。传 execution_mode=sync 可改为全程同步执行。",
225
225
  inputSchema: createRunInputSchema()
226
226
  },
227
227
  {
228
228
  name: TOOL_START_RUN,
229
- description: "异步启动 Boss 推荐页流水线,立即返回 run_id,后续通过 get_recommend_pipeline_run 轮询状态。",
229
+ description: "异步启动 Boss 推荐页流水线(含同步门禁预检);只有在前置确认与页面就绪通过后才返回 run_id",
230
230
  inputSchema: createRunInputSchema()
231
231
  },
232
232
  {
@@ -297,6 +297,43 @@ function normalizeExecutionMode(value) {
297
297
  return RUN_MODE_ASYNC;
298
298
  }
299
299
 
300
+ function normalizeRequiredConfirmations(value) {
301
+ if (!Array.isArray(value)) return [];
302
+ return value
303
+ .map((item) => normalizeText(item))
304
+ .filter(Boolean);
305
+ }
306
+
307
+ function hasExplicitFinalConfirmation(args) {
308
+ return args?.confirmation?.final_confirmed === true;
309
+ }
310
+
311
+ function buildAsyncPrecheckConfirmation(confirmation) {
312
+ if (!confirmation || typeof confirmation !== "object") {
313
+ return {
314
+ final_confirmed: false
315
+ };
316
+ }
317
+ return {
318
+ ...confirmation,
319
+ final_confirmed: false
320
+ };
321
+ }
322
+
323
+ function buildAsyncPrecheckArgs(args) {
324
+ return {
325
+ instruction: args.instruction,
326
+ confirmation: buildAsyncPrecheckConfirmation(args.confirmation),
327
+ overrides: args.overrides
328
+ };
329
+ }
330
+
331
+ function isFinalReviewOnlyConfirmation(result) {
332
+ if (result?.status !== "NEED_CONFIRMATION") return false;
333
+ const required = normalizeRequiredConfirmations(result.required_confirmations);
334
+ return required.length > 0 && required.every((item) => item === "final_review");
335
+ }
336
+
300
337
  function safeUpdateRunState(runId, updater) {
301
338
  try {
302
339
  return updateRunState(runId, updater);
@@ -540,7 +577,38 @@ async function handleSyncRunTool({ workspaceRoot, args }) {
540
577
  return attachSyncRunMetadata(tracked.result, runId, tracked.lastStage);
541
578
  }
542
579
 
543
- function handleStartRunTool({ workspaceRoot, args }) {
580
+ async function handleStartRunTool({ workspaceRoot, args }) {
581
+ const precheckArgs = buildAsyncPrecheckArgs(args);
582
+ let precheckResult;
583
+ try {
584
+ precheckResult = await runPipelineImpl(
585
+ {
586
+ workspaceRoot,
587
+ instruction: precheckArgs.instruction,
588
+ confirmation: precheckArgs.confirmation,
589
+ overrides: precheckArgs.overrides
590
+ },
591
+ undefined,
592
+ null
593
+ );
594
+ } catch (error) {
595
+ precheckResult = {
596
+ status: "FAILED",
597
+ error: {
598
+ code: "UNEXPECTED_ERROR",
599
+ message: error?.message || "Unexpected error",
600
+ retryable: true
601
+ }
602
+ };
603
+ }
604
+
605
+ if (precheckResult?.status !== "NEED_CONFIRMATION") {
606
+ return precheckResult;
607
+ }
608
+ if (!hasExplicitFinalConfirmation(args) || !isFinalReviewOnlyConfirmation(precheckResult)) {
609
+ return precheckResult;
610
+ }
611
+
544
612
  cleanupExpiredRuns();
545
613
  const runId = createRunId();
546
614
  try {
@@ -719,9 +787,9 @@ async function handleRequest(message, workspaceRoot) {
719
787
  const executionMode = normalizeExecutionMode(args.execution_mode);
720
788
  payload = executionMode === RUN_MODE_SYNC
721
789
  ? await handleSyncRunTool({ workspaceRoot, args })
722
- : handleStartRunTool({ workspaceRoot, args });
790
+ : await handleStartRunTool({ workspaceRoot, args });
723
791
  } else if (toolName === TOOL_START_RUN) {
724
- payload = handleStartRunTool({ workspaceRoot, args });
792
+ payload = await handleStartRunTool({ workspaceRoot, args });
725
793
  } else if (toolName === TOOL_GET_RUN) {
726
794
  payload = handleGetRunTool(args);
727
795
  } else if (toolName === TOOL_CANCEL_RUN) {
@@ -52,6 +52,31 @@ async function testAsyncStartStatusCancelAndSyncCompatibility() {
52
52
  const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "boss-recommend-index-async-"));
53
53
 
54
54
  setRunPipelineImplForTests(async (input, _deps, runtime) => {
55
+ if (input.confirmation?.job_confirmed !== true) {
56
+ return {
57
+ status: "NEED_CONFIRMATION",
58
+ required_confirmations: ["job"],
59
+ pending_questions: [
60
+ {
61
+ field: "job",
62
+ question: "请先确认岗位"
63
+ }
64
+ ]
65
+ };
66
+ }
67
+ if (input.confirmation?.final_confirmed !== true) {
68
+ return {
69
+ status: "NEED_CONFIRMATION",
70
+ required_confirmations: ["final_review"],
71
+ pending_questions: [
72
+ {
73
+ field: "final_review",
74
+ question: "请做最终确认"
75
+ }
76
+ ]
77
+ };
78
+ }
79
+
55
80
  runtime?.onStage?.({ stage: "preflight", message: "preflight started" });
56
81
  await sleep(50);
57
82
  if (input.instruction.includes("fail")) {
@@ -96,17 +121,33 @@ async function testAsyncStartStatusCancelAndSyncCompatibility() {
96
121
  process.env.BOSS_RECOMMEND_HOME = tempHome;
97
122
 
98
123
  try {
99
- const startResponse = await handleRequest(
124
+ const gatedStartResponse = await handleRequest(
100
125
  makeToolCall(1, "start_recommend_pipeline_run", { instruction: "slow task for cancel" }),
101
126
  process.cwd()
102
127
  );
128
+ const gatedStartPayload = await readToolPayload(gatedStartResponse);
129
+ assert.equal(gatedStartPayload.status, "NEED_CONFIRMATION");
130
+ assert.deepEqual(gatedStartPayload.required_confirmations, ["job"]);
131
+ assert.equal(gatedStartPayload.run_id, undefined);
132
+
133
+ const startResponse = await handleRequest(
134
+ makeToolCall(2, "start_recommend_pipeline_run", {
135
+ instruction: "slow task for cancel",
136
+ confirmation: {
137
+ job_confirmed: true,
138
+ job_value: "mock job",
139
+ final_confirmed: true
140
+ }
141
+ }),
142
+ process.cwd()
143
+ );
103
144
  const started = await readToolPayload(startResponse);
104
145
  assert.equal(started.status, "ACCEPTED");
105
146
  assert.equal(typeof started.run_id, "string");
106
147
  assert.equal(started.poll_after_sec >= 5 && started.poll_after_sec <= 15, true);
107
148
 
108
149
  const statusResponse = await handleRequest(
109
- makeToolCall(2, "get_recommend_pipeline_run", { run_id: started.run_id }),
150
+ makeToolCall(3, "get_recommend_pipeline_run", { run_id: started.run_id }),
110
151
  process.cwd()
111
152
  );
112
153
  const initialStatus = await readToolPayload(statusResponse);
@@ -114,7 +155,7 @@ async function testAsyncStartStatusCancelAndSyncCompatibility() {
114
155
  assert.equal(["queued", "running"].includes(initialStatus.run.state), true);
115
156
 
116
157
  const cancelResponse = await handleRequest(
117
- makeToolCall(3, "cancel_recommend_pipeline_run", { run_id: started.run_id }),
158
+ makeToolCall(4, "cancel_recommend_pipeline_run", { run_id: started.run_id }),
118
159
  process.cwd()
119
160
  );
120
161
  const canceled = await readToolPayload(cancelResponse);
@@ -123,8 +164,23 @@ async function testAsyncStartStatusCancelAndSyncCompatibility() {
123
164
  const canceledRun = await waitForTerminalRunState(started.run_id);
124
165
  assert.equal(canceledRun.state, "canceled");
125
166
 
167
+ const defaultAsyncGatedResponse = await handleRequest(
168
+ makeToolCall(5, "run_recommend_pipeline", { instruction: "fast default async gate" }),
169
+ process.cwd()
170
+ );
171
+ const defaultAsyncGatedPayload = await readToolPayload(defaultAsyncGatedResponse);
172
+ assert.equal(defaultAsyncGatedPayload.status, "NEED_CONFIRMATION");
173
+ assert.deepEqual(defaultAsyncGatedPayload.required_confirmations, ["job"]);
174
+
126
175
  const defaultAsyncResponse = await handleRequest(
127
- makeToolCall(4, "run_recommend_pipeline", { instruction: "fast sync run" }),
176
+ makeToolCall(6, "run_recommend_pipeline", {
177
+ instruction: "fast async accepted run",
178
+ confirmation: {
179
+ job_confirmed: true,
180
+ job_value: "mock job",
181
+ final_confirmed: true
182
+ }
183
+ }),
128
184
  process.cwd()
129
185
  );
130
186
  const defaultAsyncPayload = await readToolPayload(defaultAsyncResponse);
@@ -133,22 +189,32 @@ async function testAsyncStartStatusCancelAndSyncCompatibility() {
133
189
  const completedDefaultAsyncRun = await waitForTerminalRunState(defaultAsyncPayload.run_id);
134
190
  assert.equal(completedDefaultAsyncRun.state, "completed");
135
191
 
136
- const syncFailedResponse = await handleRequest(
137
- makeToolCall(5, "run_recommend_pipeline", {
192
+ const syncResponse = await handleRequest(
193
+ makeToolCall(7, "run_recommend_pipeline", {
138
194
  instruction: "fast forced sync run",
139
- execution_mode: "sync"
195
+ execution_mode: "sync",
196
+ confirmation: {
197
+ job_confirmed: true,
198
+ job_value: "mock job",
199
+ final_confirmed: true
200
+ }
140
201
  }),
141
202
  process.cwd()
142
203
  );
143
- const syncPayload = await readToolPayload(syncFailedResponse);
204
+ const syncPayload = await readToolPayload(syncResponse);
144
205
  assert.equal(syncPayload.status, "COMPLETED");
145
206
  assert.equal(typeof syncPayload.result.run_id, "string");
146
207
  assert.equal(syncPayload.result.processed_count, 40);
147
208
 
148
209
  const failedSyncResponse = await handleRequest(
149
- makeToolCall(6, "run_recommend_pipeline", {
210
+ makeToolCall(8, "run_recommend_pipeline", {
150
211
  instruction: "force fail",
151
- execution_mode: "sync"
212
+ execution_mode: "sync",
213
+ confirmation: {
214
+ job_confirmed: true,
215
+ job_value: "mock job",
216
+ final_confirmed: true
217
+ }
152
218
  }),
153
219
  process.cwd()
154
220
  );
@@ -1008,23 +1008,7 @@ const jsClickCloseFallback = `(() => {
1008
1008
  })()`;
1009
1009
 
1010
1010
  const jsReloadRecommendFrame = `(() => {
1011
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1012
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1013
- || document.querySelector('iframe');
1014
- if (!frame || !frame.contentWindow) {
1015
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1016
- }
1017
- try {
1018
- const current = String(frame.contentWindow.location.href || '');
1019
- if (current.includes('/web/frame/recommend/')) {
1020
- frame.contentWindow.location.reload();
1021
- } else {
1022
- frame.contentWindow.location.href = 'https://www.zhipin.com/web/frame/recommend/';
1023
- }
1024
- return { ok: true };
1025
- } catch (error) {
1026
- return { ok: false, error: String(error && error.message ? error.message : error) };
1027
- }
1011
+ return { ok: false, error: 'RELOAD_DISABLED_BY_POLICY' };
1028
1012
  })()`;
1029
1013
 
1030
1014
  class RecommendScreenCli {
@@ -1201,7 +1185,20 @@ class RecommendScreenCli {
1201
1185
  );
1202
1186
  }
1203
1187
 
1204
- async maximizeWindowIfPossible(reason = "unknown") {
1188
+ async getCurrentWindowState() {
1189
+ if (!this.Browser || !this.windowId || typeof this.Browser.getWindowBounds !== "function") {
1190
+ return null;
1191
+ }
1192
+ try {
1193
+ const info = await this.Browser.getWindowBounds({ windowId: this.windowId });
1194
+ const state = String(info?.bounds?.windowState || "").toLowerCase();
1195
+ return state || null;
1196
+ } catch {
1197
+ return null;
1198
+ }
1199
+ }
1200
+
1201
+ async setWindowStateIfPossible(windowState, reason = "unknown") {
1205
1202
  if (!this.Browser || !this.windowId || typeof this.Browser.setWindowBounds !== "function") {
1206
1203
  return false;
1207
1204
  }
@@ -1209,17 +1206,38 @@ class RecommendScreenCli {
1209
1206
  await this.Browser.setWindowBounds({
1210
1207
  windowId: this.windowId,
1211
1208
  bounds: {
1212
- windowState: "maximized"
1209
+ windowState
1213
1210
  }
1214
1211
  });
1215
- log(`[视口恢复] 已尝试最大化 Chrome 窗口,原因: ${reason}`);
1212
+ log(`[视口恢复] 已设置窗口状态为 ${windowState},原因: ${reason}`);
1216
1213
  return true;
1217
1214
  } catch (error) {
1218
- log(`[视口恢复] 最大化窗口失败: ${error.message || error}`);
1215
+ log(`[视口恢复] 设置窗口状态 ${windowState} 失败: ${error.message || error}`);
1219
1216
  return false;
1220
1217
  }
1221
1218
  }
1222
1219
 
1220
+ async toggleWindowStateForViewportRecovery(reason = "unknown") {
1221
+ const currentState = await this.getCurrentWindowState();
1222
+ const sequence = currentState === "normal"
1223
+ ? ["maximized", "normal"]
1224
+ : ["normal", "maximized"];
1225
+ let applied = false;
1226
+ for (const state of sequence) {
1227
+ const ok = await this.setWindowStateIfPossible(state, reason);
1228
+ if (ok) {
1229
+ applied = true;
1230
+ await sleep(humanDelay(520, 80));
1231
+ }
1232
+ }
1233
+ if (applied && this.Page && typeof this.Page.bringToFront === "function") {
1234
+ try {
1235
+ await this.Page.bringToFront();
1236
+ } catch {}
1237
+ }
1238
+ return applied;
1239
+ }
1240
+
1223
1241
  async ensureHealthyListViewport(reason = "unknown") {
1224
1242
  let state = await this.getListState();
1225
1243
  if (!this.isListViewportCollapsed(state)) {
@@ -1227,24 +1245,13 @@ class RecommendScreenCli {
1227
1245
  }
1228
1246
 
1229
1247
  log(`[视口恢复] 检测到推荐列表视口异常缩小,尝试自动恢复。原因: ${reason}`);
1230
- await this.maximizeWindowIfPossible(reason);
1231
- await sleep(humanDelay(800, 120));
1248
+ await this.toggleWindowStateForViewportRecovery(reason);
1249
+ await sleep(humanDelay(900, 130));
1232
1250
  state = await this.getListState();
1233
1251
  if (!this.isListViewportCollapsed(state)) {
1234
1252
  return { ok: true, recovered: true, state };
1235
1253
  }
1236
1254
 
1237
- const reloaded = await this.evaluate(jsReloadRecommendFrame);
1238
- if (reloaded?.ok) {
1239
- log("[视口恢复] 已触发 recommendFrame reload。");
1240
- await this.waitForListReady(45);
1241
- await sleep(humanDelay(900, 150));
1242
- state = await this.getListState();
1243
- if (!this.isListViewportCollapsed(state)) {
1244
- return { ok: true, recovered: true, state };
1245
- }
1246
- }
1247
-
1248
1255
  return {
1249
1256
  ok: false,
1250
1257
  recovered: false,
@@ -1597,35 +1604,21 @@ class RecommendScreenCli {
1597
1604
  }
1598
1605
  }
1599
1606
 
1600
- log(`[关闭详情] 常规关闭失败,进入强制恢复。最后状态: ${state?.reason || "unknown"}`);
1607
+ log(`[关闭详情] 常规关闭失败,进入额外 ESC 重试(禁用刷新/跳转)。最后状态: ${state?.reason || "unknown"}`);
1601
1608
  for (let retry = 0; retry < 2; retry += 1) {
1609
+ await sleep(2000);
1602
1610
  await this.pressEsc();
1603
- await sleep(900 + retry * 250);
1611
+ await sleep(humanDelay(260, 60));
1604
1612
  state = await this.getDetailClosedState();
1605
1613
  if (state?.closed) {
1606
- log(`[关闭详情] 强制ESC成功: ${state.reason || "closed"}`);
1614
+ log(`[关闭详情] 额外ESC成功: ${state.reason || "closed"}`);
1607
1615
  return true;
1608
1616
  }
1609
1617
  }
1610
1618
 
1611
- const reloaded = await this.evaluate(jsReloadRecommendFrame);
1612
- if (reloaded?.ok) {
1613
- log("[关闭详情] 已触发 recommendFrame reload,等待列表恢复。");
1614
- const listReady = await this.waitForListReady(45);
1615
- if (listReady) {
1616
- state = await this.getDetailClosedState();
1617
- if (state?.closed) {
1618
- log(`[关闭详情] reload 后恢复成功: ${state.reason || "closed"}`);
1619
- return true;
1620
- }
1621
- }
1622
- } else {
1623
- log(`[关闭详情] recommendFrame reload 失败: ${reloaded?.error || "unknown"}`);
1624
- }
1625
-
1626
1619
  state = await this.getDetailClosedState();
1627
- log(`[关闭详情] 失败,当前状态: ${state?.reason || "unknown"}`);
1628
- return false;
1620
+ log(`[关闭详情] 连续 ESC 后仍未确认关闭(${state?.reason || "unknown"}),按策略视为检测误差并继续下一位。`);
1621
+ return true;
1629
1622
  }
1630
1623
 
1631
1624
  async waitForListReady(maxRounds = 30) {