@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 +8 -6
- package/package.json +1 -1
- package/skills/boss-recommend-pipeline/SKILL.md +7 -4
- package/src/index.js +93 -5
- package/src/run-state.js +7 -3
- package/src/test-index-async.js +89 -7
- 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,8 +9,8 @@ Boss 推荐页自动化流水线 MCP(stdio)服务。
|
|
|
9
9
|
|
|
10
10
|
MCP 工具:
|
|
11
11
|
|
|
12
|
-
- `run_recommend_pipeline
|
|
13
|
-
- `start_recommend_pipeline_run
|
|
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.
|
|
147
|
-
2.
|
|
148
|
-
3.
|
|
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
|
-
-
|
|
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
|
@@ -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,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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
136
|
-
makeToolCall(
|
|
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(
|
|
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");
|
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
|
}
|