@reconcrap/boss-recommend-mcp 1.0.11 → 1.0.13

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`(同步,兼容旧 agent
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,15 @@ 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`。
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` 继续保留,不影响旧流程。
153
+ - `run_recommend_pipeline` 默认异步,但不会跳过同步确认流程;若确实需要阻塞式返回,可传 `execution_mode=sync`。
154
+ - 定时心跳默认 120 秒一次;`updated_at` 仍会在阶段或进度变化时刷新。
153
155
  - 每个 run 会持久化到 `~/.boss-recommend-mcp/runs/<run_id>.json`(可通过 `BOSS_RECOMMEND_HOME` 覆盖)。
154
156
  - 轮询期间不要重复 `start`,优先复用已有 `run_id`,避免重复筛选。
155
157
 
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.13",
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,15 @@ 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
+ - 只有门禁通过后才会返回 `ACCEPTED + run_id`;否则会先返回 `NEED_INPUT/NEED_CONFIRMATION/FAILED`,必须先按提示补齐。
122
+ - 若宿主要显式拆成三步,也可使用:
120
123
  - `start_recommend_pipeline_run`
121
124
  - `get_recommend_pipeline_run`
122
125
  - `cancel_recommend_pipeline_run`
123
126
  - 建议轮询间隔 5~15 秒。
124
127
  - 已有 `run_id` 时不要重复 start,优先继续轮询同一个 run。
125
- - 若宿主明确只支持同步工具,再回退 `run_recommend_pipeline`。
128
+ - 若宿主明确需要阻塞式返回,再传 `execution_mode=sync`。
126
129
 
127
130
  ## Execution Notes
128
131
 
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,12 +221,12 @@ 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
  {
222
228
  name: TOOL_START_RUN,
223
- description: "异步启动 Boss 推荐页流水线,立即返回 run_id,后续通过 get_recommend_pipeline_run 轮询状态。",
229
+ description: "异步启动 Boss 推荐页流水线(含同步门禁预检);只有在前置确认与页面就绪通过后才返回 run_id",
224
230
  inputSchema: createRunInputSchema()
225
231
  },
226
232
  {
@@ -285,6 +291,49 @@ 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
+
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
+
288
337
  function safeUpdateRunState(runId, updater) {
289
338
  try {
290
339
  return updateRunState(runId, updater);
@@ -324,6 +373,11 @@ function createRuntimeCallbacks(runId, heartbeatIntervalMs) {
324
373
  patch.last_message = detailsMessage;
325
374
  }
326
375
  safeUpdateRunState(runId, patch);
376
+ try {
377
+ touchRunHeartbeat(runId, detailsMessage || undefined);
378
+ } catch {
379
+ // Ignore heartbeat persistence failures here; state updates above already best-effort.
380
+ }
327
381
  },
328
382
  onOutput(event) {
329
383
  const stage = normalizeText(event?.stage) || lastStage;
@@ -523,7 +577,38 @@ async function handleSyncRunTool({ workspaceRoot, args }) {
523
577
  return attachSyncRunMetadata(tracked.result, runId, tracked.lastStage);
524
578
  }
525
579
 
526
- 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
+
527
612
  cleanupExpiredRuns();
528
613
  const runId = createRunId();
529
614
  try {
@@ -699,9 +784,12 @@ async function handleRequest(message, workspaceRoot) {
699
784
  try {
700
785
  let payload;
701
786
  if (toolName === TOOL_RUN_PIPELINE) {
702
- payload = await handleSyncRunTool({ workspaceRoot, args });
787
+ const executionMode = normalizeExecutionMode(args.execution_mode);
788
+ payload = executionMode === RUN_MODE_SYNC
789
+ ? await handleSyncRunTool({ workspaceRoot, args })
790
+ : await handleStartRunTool({ workspaceRoot, args });
703
791
  } else if (toolName === TOOL_START_RUN) {
704
- payload = handleStartRunTool({ workspaceRoot, args });
792
+ payload = await handleStartRunTool({ workspaceRoot, args });
705
793
  } else if (toolName === TOOL_GET_RUN) {
706
794
  payload = handleGetRunTool(args);
707
795
  } else if (toolName === TOOL_CANCEL_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
-
@@ -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,41 @@ 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
+
175
+ const defaultAsyncResponse = await handleRequest(
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
+ }),
184
+ process.cwd()
185
+ );
186
+ const defaultAsyncPayload = await readToolPayload(defaultAsyncResponse);
187
+ assert.equal(defaultAsyncPayload.status, "ACCEPTED");
188
+ assert.equal(typeof defaultAsyncPayload.run_id, "string");
189
+ const completedDefaultAsyncRun = await waitForTerminalRunState(defaultAsyncPayload.run_id);
190
+ assert.equal(completedDefaultAsyncRun.state, "completed");
191
+
126
192
  const syncResponse = await handleRequest(
127
- makeToolCall(4, "run_recommend_pipeline", { instruction: "fast sync run" }),
193
+ makeToolCall(7, "run_recommend_pipeline", {
194
+ instruction: "fast forced sync run",
195
+ execution_mode: "sync",
196
+ confirmation: {
197
+ job_confirmed: true,
198
+ job_value: "mock job",
199
+ final_confirmed: true
200
+ }
201
+ }),
128
202
  process.cwd()
129
203
  );
130
204
  const syncPayload = await readToolPayload(syncResponse);
@@ -132,11 +206,19 @@ async function testAsyncStartStatusCancelAndSyncCompatibility() {
132
206
  assert.equal(typeof syncPayload.result.run_id, "string");
133
207
  assert.equal(syncPayload.result.processed_count, 40);
134
208
 
135
- const syncFailedResponse = await handleRequest(
136
- makeToolCall(5, "run_recommend_pipeline", { instruction: "force fail" }),
209
+ const failedSyncResponse = await handleRequest(
210
+ makeToolCall(8, "run_recommend_pipeline", {
211
+ instruction: "force fail",
212
+ execution_mode: "sync",
213
+ confirmation: {
214
+ job_confirmed: true,
215
+ job_value: "mock job",
216
+ final_confirmed: true
217
+ }
218
+ }),
137
219
  process.cwd()
138
220
  );
139
- const syncFailedPayload = await readToolPayload(syncFailedResponse);
221
+ const syncFailedPayload = await readToolPayload(failedSyncResponse);
140
222
  assert.equal(syncFailedPayload.status, "FAILED");
141
223
  assert.equal(typeof syncFailedPayload.diagnostics?.run_id, "string");
142
224
  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
  }