@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 +4 -3
- package/package.json +1 -1
- package/skills/boss-recommend-pipeline/SKILL.md +6 -4
- package/src/index.js +22 -2
- package/src/run-state.js +7 -3
- package/src/test-index-async.js +21 -5
- package/src/test-run-state.js +7 -1
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +115 -4
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Boss 推荐页自动化流水线 MCP(stdio)服务。
|
|
|
9
9
|
|
|
10
10
|
MCP 工具:
|
|
11
11
|
|
|
12
|
-
- `run_recommend_pipeline
|
|
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.
|
|
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
|
-
-
|
|
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
|
@@ -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
|
|
83
|
+
- Tool name(默认异步): `run_recommend_pipeline`
|
|
84
84
|
- Input:
|
|
85
|
-
- `
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
package/src/test-index-async.js
CHANGED
|
@@ -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
|
|
126
|
+
const defaultAsyncResponse = await handleRequest(
|
|
127
127
|
makeToolCall(4, "run_recommend_pipeline", { instruction: "fast sync run" }),
|
|
128
128
|
process.cwd()
|
|
129
129
|
);
|
|
130
|
-
const
|
|
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
|
|
136
|
-
makeToolCall(
|
|
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(
|
|
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");
|
package/src/test-run-state.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
}
|