@reconcrap/boss-recommend-mcp 1.0.11 → 1.0.12

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,7 +9,7 @@ Boss 推荐页自动化流水线 MCP(stdio)服务。
9
9
 
10
10
  MCP 工具:
11
11
 
12
- - `run_recommend_pipeline`(同步,兼容旧 agent)
12
+ - `run_recommend_pipeline`(默认异步启动;传 `execution_mode=sync` 时同步执行)
13
13
  - `start_recommend_pipeline_run`(异步启动,立即返回 run_id)
14
14
  - `get_recommend_pipeline_run`(轮询 run_id 状态)
15
15
  - `cancel_recommend_pipeline_run`(取消运行中任务)
@@ -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. 先调用 `start_recommend_pipeline_run`,拿到 `run_id`。
146
+ 1. 优先直接调用 `run_recommend_pipeline`,默认会返回异步 `run_id`。
147
147
  2. 每 5~15 秒调用一次 `get_recommend_pipeline_run` 轮询。
148
148
  3. 若需终止,调用 `cancel_recommend_pipeline_run`。
149
149
 
150
150
  说明:
151
151
 
152
- - 同步工具 `run_recommend_pipeline` 继续保留,不影响旧流程。
152
+ - `run_recommend_pipeline` 现在默认异步;若确实需要阻塞式返回,可传 `execution_mode=sync`。
153
+ - 定时心跳默认 120 秒一次;`updated_at` 仍会在阶段或进度变化时刷新。
153
154
  - 每个 run 会持久化到 `~/.boss-recommend-mcp/runs/<run_id>.json`(可通过 `BOSS_RECOMMEND_HOME` 覆盖)。
154
155
  - 轮询期间不要重复 `start`,优先复用已有 `run_id`,避免重复筛选。
155
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -80,9 +80,10 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
80
80
 
81
81
  ## Tool Contract
82
82
 
83
- - Tool name(同步,默认兼容): `run_recommend_pipeline`
83
+ - Tool name(默认异步): `run_recommend_pipeline`
84
84
  - Input:
85
- - `instruction` (required)
85
+ - `execution_mode`(可选:`async|sync`,默认 `async`)
86
+ - `instruction` (required)
86
87
  - `confirmation`
87
88
  - `filters_confirmed`
88
89
  - `school_tag_confirmed`
@@ -116,13 +117,14 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
116
117
 
117
118
  长耗时宿主兼容(推荐):
118
119
 
119
- - 若宿主存在“工具超时误判”风险,优先改用异步三件套:
120
+ - `run_recommend_pipeline` 现在默认直接异步启动。
121
+ - 若宿主要显式拆成三步,也可使用:
120
122
  - `start_recommend_pipeline_run`
121
123
  - `get_recommend_pipeline_run`
122
124
  - `cancel_recommend_pipeline_run`
123
125
  - 建议轮询间隔 5~15 秒。
124
126
  - 已有 `run_id` 时不要重复 start,优先继续轮询同一个 run。
125
- - 若宿主明确只支持同步工具,再回退 `run_recommend_pipeline`。
127
+ - 若宿主明确需要阻塞式返回,再传 `execution_mode=sync`。
126
128
 
127
129
  ## Execution Notes
128
130
 
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  createRunStateSnapshot,
17
17
  getRunHeartbeatIntervalMs,
18
18
  readRunState,
19
+ touchRunHeartbeat,
19
20
  updateRunProgress,
20
21
  updateRunState,
21
22
  writeRunState
@@ -77,6 +78,11 @@ function createRunInputSchema() {
77
78
  type: "string",
78
79
  description: "用户自然语言推荐筛选指令"
79
80
  },
81
+ execution_mode: {
82
+ type: "string",
83
+ enum: [RUN_MODE_ASYNC, RUN_MODE_SYNC],
84
+ description: "执行模式;默认 async。"
85
+ },
80
86
  confirmation: {
81
87
  type: "object",
82
88
  properties: {
@@ -215,7 +221,7 @@ function createToolsSchema() {
215
221
  return [
216
222
  {
217
223
  name: TOOL_RUN_PIPELINE,
218
- description: "Boss 推荐页同步流水线:解析推荐筛选指令并执行 search+screen,直到返回最终结果。",
224
+ description: "Boss 推荐页流水线:默认异步启动并返回 run_id;传 execution_mode=sync 时改为同步执行。",
219
225
  inputSchema: createRunInputSchema()
220
226
  },
221
227
  {
@@ -285,6 +291,12 @@ function getLastOutputLine(text) {
285
291
  return lines.length > 0 ? lines[lines.length - 1] : null;
286
292
  }
287
293
 
294
+ function normalizeExecutionMode(value) {
295
+ const normalized = normalizeText(value).toLowerCase();
296
+ if (normalized === RUN_MODE_SYNC) return RUN_MODE_SYNC;
297
+ return RUN_MODE_ASYNC;
298
+ }
299
+
288
300
  function safeUpdateRunState(runId, updater) {
289
301
  try {
290
302
  return updateRunState(runId, updater);
@@ -324,6 +336,11 @@ function createRuntimeCallbacks(runId, heartbeatIntervalMs) {
324
336
  patch.last_message = detailsMessage;
325
337
  }
326
338
  safeUpdateRunState(runId, patch);
339
+ try {
340
+ touchRunHeartbeat(runId, detailsMessage || undefined);
341
+ } catch {
342
+ // Ignore heartbeat persistence failures here; state updates above already best-effort.
343
+ }
327
344
  },
328
345
  onOutput(event) {
329
346
  const stage = normalizeText(event?.stage) || lastStage;
@@ -699,7 +716,10 @@ async function handleRequest(message, workspaceRoot) {
699
716
  try {
700
717
  let payload;
701
718
  if (toolName === TOOL_RUN_PIPELINE) {
702
- payload = await handleSyncRunTool({ workspaceRoot, args });
719
+ const executionMode = normalizeExecutionMode(args.execution_mode);
720
+ payload = executionMode === RUN_MODE_SYNC
721
+ ? await handleSyncRunTool({ workspaceRoot, args })
722
+ : handleStartRunTool({ workspaceRoot, args });
703
723
  } else if (toolName === TOOL_START_RUN) {
704
724
  payload = handleStartRunTool({ workspaceRoot, args });
705
725
  } else if (toolName === TOOL_GET_RUN) {
package/src/run-state.js CHANGED
@@ -19,7 +19,7 @@ export const RUN_STAGE_SEARCH = "search";
19
19
  export const RUN_STAGE_SCREEN = "screen";
20
20
  export const RUN_STAGE_FINALIZE = "finalize";
21
21
 
22
- const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000;
22
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 120_000;
23
23
  const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
24
24
 
25
25
  const VALID_RUN_MODES = new Set([RUN_MODE_SYNC, RUN_MODE_ASYNC]);
@@ -229,13 +229,18 @@ export function updateRunState(runId, updater) {
229
229
  : current.last_message
230
230
  ),
231
231
  updated_at: now,
232
- heartbeat_at: String(patch.heartbeat_at || now)
232
+ heartbeat_at: String(
233
+ Object.prototype.hasOwnProperty.call(patch, "heartbeat_at")
234
+ ? (patch.heartbeat_at || now)
235
+ : current.heartbeat_at
236
+ )
233
237
  };
234
238
  return writeRunState(next);
235
239
  }
236
240
 
237
241
  export function touchRunHeartbeat(runId, message = null) {
238
242
  return updateRunState(runId, (current) => ({
243
+ heartbeat_at: toIsoNow(),
239
244
  last_message: message ?? current.last_message
240
245
  }));
241
246
  }
@@ -286,4 +291,3 @@ export function cleanupExpiredRuns(retentionMs = getRunRetentionMs()) {
286
291
  }
287
292
  return { removed, failed };
288
293
  }
289
-
@@ -123,20 +123,36 @@ async function testAsyncStartStatusCancelAndSyncCompatibility() {
123
123
  const canceledRun = await waitForTerminalRunState(started.run_id);
124
124
  assert.equal(canceledRun.state, "canceled");
125
125
 
126
- const syncResponse = await handleRequest(
126
+ const defaultAsyncResponse = await handleRequest(
127
127
  makeToolCall(4, "run_recommend_pipeline", { instruction: "fast sync run" }),
128
128
  process.cwd()
129
129
  );
130
- const syncPayload = await readToolPayload(syncResponse);
130
+ const defaultAsyncPayload = await readToolPayload(defaultAsyncResponse);
131
+ assert.equal(defaultAsyncPayload.status, "ACCEPTED");
132
+ assert.equal(typeof defaultAsyncPayload.run_id, "string");
133
+ const completedDefaultAsyncRun = await waitForTerminalRunState(defaultAsyncPayload.run_id);
134
+ assert.equal(completedDefaultAsyncRun.state, "completed");
135
+
136
+ const syncFailedResponse = await handleRequest(
137
+ makeToolCall(5, "run_recommend_pipeline", {
138
+ instruction: "fast forced sync run",
139
+ execution_mode: "sync"
140
+ }),
141
+ process.cwd()
142
+ );
143
+ const syncPayload = await readToolPayload(syncFailedResponse);
131
144
  assert.equal(syncPayload.status, "COMPLETED");
132
145
  assert.equal(typeof syncPayload.result.run_id, "string");
133
146
  assert.equal(syncPayload.result.processed_count, 40);
134
147
 
135
- const syncFailedResponse = await handleRequest(
136
- makeToolCall(5, "run_recommend_pipeline", { instruction: "force fail" }),
148
+ const failedSyncResponse = await handleRequest(
149
+ makeToolCall(6, "run_recommend_pipeline", {
150
+ instruction: "force fail",
151
+ execution_mode: "sync"
152
+ }),
137
153
  process.cwd()
138
154
  );
139
- const syncFailedPayload = await readToolPayload(syncFailedResponse);
155
+ const syncFailedPayload = await readToolPayload(failedSyncResponse);
140
156
  assert.equal(syncFailedPayload.status, "FAILED");
141
157
  assert.equal(typeof syncFailedPayload.diagnostics?.run_id, "string");
142
158
  assert.equal(typeof syncFailedPayload.diagnostics?.last_stage, "string");
@@ -13,6 +13,7 @@ import {
13
13
  createRunStateSnapshot,
14
14
  getRunsDir,
15
15
  readRunState,
16
+ touchRunHeartbeat,
16
17
  updateRunProgress,
17
18
  updateRunState,
18
19
  writeRunState
@@ -53,6 +54,7 @@ function testRunStateLifecycle() {
53
54
  });
54
55
  assert.equal(running.state, RUN_STATE_RUNNING);
55
56
  assert.equal(running.stage, RUN_STAGE_SCREEN);
57
+ const heartbeatBeforeProgress = running.heartbeat_at;
56
58
 
57
59
  const progressed = updateRunProgress(runId, {
58
60
  processed: 7,
@@ -64,6 +66,11 @@ function testRunStateLifecycle() {
64
66
  assert.equal(progressed.progress.passed, 2);
65
67
  assert.equal(progressed.progress.skipped, 5);
66
68
  assert.equal(progressed.progress.greet_count, 1);
69
+ assert.equal(progressed.heartbeat_at, heartbeatBeforeProgress);
70
+
71
+ const heartbeated = touchRunHeartbeat(runId, "still running");
72
+ assert.equal(heartbeated.last_message, "still running");
73
+ assert.equal(Date.parse(heartbeated.heartbeat_at) >= Date.parse(heartbeatBeforeProgress), true);
67
74
 
68
75
  const completed = updateRunState(runId, {
69
76
  state: RUN_STATE_COMPLETED,
@@ -105,4 +112,3 @@ function main() {
105
112
  }
106
113
 
107
114
  main();
108
-
@@ -368,6 +368,7 @@ const jsGetListState = `(() => {
368
368
  }
369
369
  const doc = frame.contentDocument;
370
370
  const body = doc.body;
371
+ const frameRect = frame.getBoundingClientRect();
371
372
  const cards = Array.from(doc.querySelectorAll('ul.card-list > li.card-item'));
372
373
  const candidateCards = cards.filter((card) => card.querySelector('.card-inner[data-geekid]'));
373
374
  return {
@@ -375,6 +376,15 @@ const jsGetListState = `(() => {
375
376
  scrollTop: body ? body.scrollTop : 0,
376
377
  scrollHeight: body ? body.scrollHeight : 0,
377
378
  clientHeight: body ? body.clientHeight : 0,
379
+ clientWidth: body ? body.clientWidth : 0,
380
+ frameRect: {
381
+ width: frameRect.width,
382
+ height: frameRect.height
383
+ },
384
+ viewport: {
385
+ width: (doc.defaultView && Number.isFinite(doc.defaultView.innerWidth)) ? doc.defaultView.innerWidth : 0,
386
+ height: (doc.defaultView && Number.isFinite(doc.defaultView.innerHeight)) ? doc.defaultView.innerHeight : 0
387
+ },
378
388
  candidateCount: candidateCards.length,
379
389
  totalCards: cards.length
380
390
  };
@@ -1024,7 +1034,9 @@ class RecommendScreenCli {
1024
1034
  this.Runtime = null;
1025
1035
  this.Input = null;
1026
1036
  this.Page = null;
1037
+ this.Browser = null;
1027
1038
  this.target = null;
1039
+ this.windowId = null;
1028
1040
  this.discoveredKeys = new Set();
1029
1041
  this.processedKeys = new Set();
1030
1042
  this.candidateQueue = [];
@@ -1053,12 +1065,21 @@ class RecommendScreenCli {
1053
1065
  throw this.buildError("RECOMMEND_PAGE_NOT_READY", "No debuggable recommend page target found.");
1054
1066
  }
1055
1067
  this.client = await CDP({ port: this.args.port, target: this.target });
1056
- const { Runtime, Input, Page } = this.client;
1068
+ const { Runtime, Input, Page, Browser } = this.client;
1057
1069
  this.Runtime = Runtime;
1058
1070
  this.Input = Input;
1059
1071
  this.Page = Page;
1072
+ this.Browser = Browser || null;
1060
1073
  await Runtime.enable();
1061
1074
  await Page.enable();
1075
+ if (this.Browser && typeof this.Browser.getWindowForTarget === "function") {
1076
+ try {
1077
+ const windowInfo = await this.Browser.getWindowForTarget();
1078
+ if (Number.isInteger(windowInfo?.windowId)) {
1079
+ this.windowId = windowInfo.windowId;
1080
+ }
1081
+ } catch {}
1082
+ }
1062
1083
  await Page.bringToFront();
1063
1084
  }
1064
1085
 
@@ -1155,7 +1176,92 @@ class RecommendScreenCli {
1155
1176
  return state?.closed === false;
1156
1177
  }
1157
1178
 
1179
+ async getListState() {
1180
+ const state = await this.evaluate(jsGetListState);
1181
+ if (state && typeof state === "object") return state;
1182
+ return { ok: false, error: "INVALID_LIST_STATE" };
1183
+ }
1184
+
1185
+ isListViewportCollapsed(state) {
1186
+ if (!state?.ok) return false;
1187
+ const clientHeight = Number(state.clientHeight || 0);
1188
+ const clientWidth = Number(state.clientWidth || 0);
1189
+ const frameWidth = Number(state.frameRect?.width || 0);
1190
+ const frameHeight = Number(state.frameRect?.height || 0);
1191
+ const viewportWidth = Number(state.viewport?.width || 0);
1192
+ const viewportHeight = Number(state.viewport?.height || 0);
1193
+
1194
+ return (
1195
+ (clientHeight > 0 && clientHeight < 260)
1196
+ || (clientWidth > 0 && clientWidth < 280)
1197
+ || (frameHeight > 0 && frameHeight < 320)
1198
+ || (frameWidth > 0 && frameWidth < 460)
1199
+ || (viewportHeight > 0 && viewportHeight < 260)
1200
+ || (viewportWidth > 0 && viewportWidth < 360)
1201
+ );
1202
+ }
1203
+
1204
+ async maximizeWindowIfPossible(reason = "unknown") {
1205
+ if (!this.Browser || !this.windowId || typeof this.Browser.setWindowBounds !== "function") {
1206
+ return false;
1207
+ }
1208
+ try {
1209
+ await this.Browser.setWindowBounds({
1210
+ windowId: this.windowId,
1211
+ bounds: {
1212
+ windowState: "maximized"
1213
+ }
1214
+ });
1215
+ log(`[视口恢复] 已尝试最大化 Chrome 窗口,原因: ${reason}`);
1216
+ return true;
1217
+ } catch (error) {
1218
+ log(`[视口恢复] 最大化窗口失败: ${error.message || error}`);
1219
+ return false;
1220
+ }
1221
+ }
1222
+
1223
+ async ensureHealthyListViewport(reason = "unknown") {
1224
+ let state = await this.getListState();
1225
+ if (!this.isListViewportCollapsed(state)) {
1226
+ return { ok: true, recovered: false, state };
1227
+ }
1228
+
1229
+ log(`[视口恢复] 检测到推荐列表视口异常缩小,尝试自动恢复。原因: ${reason}`);
1230
+ await this.maximizeWindowIfPossible(reason);
1231
+ await sleep(humanDelay(800, 120));
1232
+ state = await this.getListState();
1233
+ if (!this.isListViewportCollapsed(state)) {
1234
+ return { ok: true, recovered: true, state };
1235
+ }
1236
+
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
+ return {
1249
+ ok: false,
1250
+ recovered: false,
1251
+ state
1252
+ };
1253
+ }
1254
+
1158
1255
  async discoverCandidates() {
1256
+ const health = await this.ensureHealthyListViewport("discover_candidates");
1257
+ if (!health?.ok) {
1258
+ return {
1259
+ ok: false,
1260
+ error: "LIST_VIEWPORT_COLLAPSED",
1261
+ added: 0,
1262
+ list_state: health?.state || null
1263
+ };
1264
+ }
1159
1265
  const scan = await this.evaluate(buildListCandidatesExpr(Array.from(this.processedKeys)));
1160
1266
  if (!scan?.ok) {
1161
1267
  return {
@@ -1211,10 +1317,11 @@ class RecommendScreenCli {
1211
1317
  }
1212
1318
 
1213
1319
  async scrollAndLoadMore() {
1214
- const before = await this.evaluate(jsGetListState);
1320
+ const health = await this.ensureHealthyListViewport("scroll_and_load_more");
1321
+ const before = health?.state?.ok ? health.state : await this.getListState();
1215
1322
  const scrollResult = await this.evaluate(jsScrollList);
1216
1323
  await sleep(humanDelay(1200, 260));
1217
- const after = await this.evaluate(jsGetListState);
1324
+ const after = await this.getListState();
1218
1325
  const bottom = await this.evaluate(jsDetectBottom);
1219
1326
  return { before, scrollResult, after, bottom };
1220
1327
  }
@@ -1595,7 +1702,11 @@ class RecommendScreenCli {
1595
1702
  if (!startupListReady) {
1596
1703
  throw this.buildError("RECOMMEND_PAGE_NOT_READY", "推荐列表未就绪(可能仍停留在详情页)");
1597
1704
  }
1598
- const initialList = await this.evaluate(jsGetListState);
1705
+ const initialHealth = await this.ensureHealthyListViewport("startup");
1706
+ if (!initialHealth?.ok) {
1707
+ throw this.buildError("LIST_VIEWPORT_COLLAPSED", "推荐列表视口异常缩小,自动恢复失败。");
1708
+ }
1709
+ const initialList = initialHealth.state || await this.getListState();
1599
1710
  if (!initialList?.ok) {
1600
1711
  throw this.buildError("RECOMMEND_PAGE_NOT_READY", initialList?.error || "推荐列表不可用");
1601
1712
  }