@reconcrap/boss-recommend-mcp 2.0.40 → 2.0.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.40",
3
+ "version": "2.0.42",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -60,6 +60,7 @@
60
60
  "live:infinite-list": "node scripts/live-infinite-list-smoke.js",
61
61
  "live:scroll-end": "node scripts/live-scroll-end-screenshot.js",
62
62
  "live:refresh-round": "node scripts/live-refresh-round-smoke.js",
63
+ "live:recommend-recovery": "node scripts/live-recommend-recovery-smoke.js",
63
64
  "live:self-heal": "node scripts/live-self-heal-smoke.js",
64
65
  "live:recommend-actions": "node scripts/live-recommend-actions-smoke.js",
65
66
  "live:recommend-phase10-full": "node scripts/live-recommend-phase10-full.js",
@@ -82,6 +83,7 @@
82
83
  "config/screening-config.example.json",
83
84
  "skills",
84
85
  "scripts/postinstall.cjs",
86
+ "scripts/live-recommend-recovery-smoke.js",
85
87
  "src/core",
86
88
  "src/domains",
87
89
  "src/chat-mcp.js",
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import {
6
+ assertNoForbiddenCdpCalls,
7
+ assertRuntimeEvaluateBlocked,
8
+ bringPageToFront,
9
+ connectToChromeTarget,
10
+ enableDomains,
11
+ getAttributesMap,
12
+ getOuterHTML,
13
+ querySelector,
14
+ sleep
15
+ } from "../src/core/browser/index.js";
16
+ import {
17
+ htmlToText,
18
+ normalizeText
19
+ } from "../src/core/screening/index.js";
20
+ import {
21
+ findRecommendJobTrigger,
22
+ getRecommendRoots,
23
+ refreshRecommendListAtEnd,
24
+ RECOMMEND_TARGET_URL,
25
+ waitForRecommendCardNodeIds
26
+ } from "../src/domains/recommend/index.js";
27
+
28
+ function parseArgs(argv) {
29
+ const options = {
30
+ host: "127.0.0.1",
31
+ port: 9222,
32
+ targetUrl: RECOMMEND_TARGET_URL,
33
+ targetUrlIncludes: RECOMMEND_TARGET_URL,
34
+ jobLabel: "",
35
+ pageScope: "recommend",
36
+ fallbackPageScope: "recommend",
37
+ forceNavigate: true,
38
+ forceRecentNotView: true,
39
+ reloadSettleMs: 12000,
40
+ cardTimeoutMs: 60000,
41
+ saveReport: ".live-artifacts/recommend-recovery-smoke.json",
42
+ filterGroups: [
43
+ {
44
+ group: "degree",
45
+ labels: ["本科", "硕士", "博士"],
46
+ selectAllLabels: true
47
+ },
48
+ {
49
+ group: "school",
50
+ labels: ["985", "211", "双一流院校", "国内外名校"],
51
+ selectAllLabels: true
52
+ }
53
+ ]
54
+ };
55
+
56
+ for (let index = 0; index < argv.length; index += 1) {
57
+ const arg = argv[index];
58
+ if (arg === "--host") options.host = argv[++index];
59
+ if (arg === "--port") options.port = Number(argv[++index]);
60
+ if (arg === "--target-url") options.targetUrl = argv[++index];
61
+ if (arg === "--target-url-includes") options.targetUrlIncludes = argv[++index];
62
+ if (arg === "--job") options.jobLabel = argv[++index];
63
+ if (arg === "--page-scope") options.pageScope = argv[++index];
64
+ if (arg === "--fallback-page-scope") options.fallbackPageScope = argv[++index];
65
+ if (arg === "--no-force-navigate") options.forceNavigate = false;
66
+ if (arg === "--no-recent-not-view") options.forceRecentNotView = false;
67
+ if (arg === "--reload-settle-ms") options.reloadSettleMs = Number(argv[++index]);
68
+ if (arg === "--card-timeout-ms") options.cardTimeoutMs = Number(argv[++index]);
69
+ if (arg === "--save-report") options.saveReport = argv[++index];
70
+ if (arg === "--no-save-report") options.saveReport = "";
71
+ if (arg === "--no-default-filter") options.filterGroups = [];
72
+ if (arg === "--filter") {
73
+ const raw = String(argv[++index] || "");
74
+ const [group, labelsRaw = ""] = raw.split(/[:=]/);
75
+ options.filterGroups.push({
76
+ group: group.trim(),
77
+ labels: labelsRaw.split(/[,,、|/]/).map((item) => item.trim()).filter(Boolean),
78
+ selectAllLabels: true
79
+ });
80
+ }
81
+ }
82
+
83
+ return options;
84
+ }
85
+
86
+ function methodSummary(methodLog) {
87
+ const summary = {};
88
+ for (const entry of methodLog) {
89
+ summary[entry.method] = (summary[entry.method] || 0) + 1;
90
+ }
91
+ return summary;
92
+ }
93
+
94
+ function writeJsonFile(filePath, payload) {
95
+ const resolved = path.resolve(filePath);
96
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
97
+ fs.writeFileSync(resolved, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
98
+ return resolved;
99
+ }
100
+
101
+ async function connectToRecommendSession(options) {
102
+ try {
103
+ return await connectToChromeTarget({
104
+ host: options.host,
105
+ port: options.port,
106
+ targetUrlIncludes: options.targetUrlIncludes
107
+ });
108
+ } catch (error) {
109
+ return connectToChromeTarget({
110
+ host: options.host,
111
+ port: options.port,
112
+ targetPredicate: (target) => (
113
+ target?.type === "page"
114
+ && String(target?.url || "").includes("zhipin.com/web/chat")
115
+ )
116
+ });
117
+ }
118
+ }
119
+
120
+ async function readCurrentJobLabel(client, frameDocumentNodeId) {
121
+ const labelNodeId = await querySelector(
122
+ client,
123
+ frameDocumentNodeId,
124
+ ".job-selecter-wrap .ui-dropmenu-label, .ui-dropmenu-label"
125
+ );
126
+ if (!labelNodeId) return "";
127
+ const html = await getOuterHTML(client, labelNodeId);
128
+ return normalizeText(htmlToText(html));
129
+ }
130
+
131
+ async function summarizeRecommendState(client) {
132
+ const roots = await getRecommendRoots(client);
133
+ const iframeAttributes = await getAttributesMap(client, roots.iframe.nodeId).catch(() => ({}));
134
+ const cardNodeIds = await waitForRecommendCardNodeIds(client, roots.iframe.documentNodeId, {
135
+ timeoutMs: 1200,
136
+ intervalMs: 200
137
+ }).catch(() => []);
138
+ const trigger = await findRecommendJobTrigger(client, roots.iframe.documentNodeId).catch(() => null);
139
+ const currentJobLabel = trigger
140
+ ? await readCurrentJobLabel(client, roots.iframe.documentNodeId).catch(() => "")
141
+ : "";
142
+
143
+ return {
144
+ roots,
145
+ summary: {
146
+ iframe_selector: roots.iframe.selector || "",
147
+ iframe_node_id: roots.iframe.nodeId,
148
+ iframe_document_node_id: roots.iframe.documentNodeId,
149
+ iframe_src: iframeAttributes.src || "",
150
+ current_job_label: currentJobLabel,
151
+ job_trigger_found: Boolean(trigger),
152
+ job_trigger_rect: trigger?.rect || null,
153
+ card_count: cardNodeIds.length
154
+ }
155
+ };
156
+ }
157
+
158
+ function compactRefreshResult(refreshResult = {}) {
159
+ return {
160
+ ok: Boolean(refreshResult.ok),
161
+ method: refreshResult.method || "",
162
+ reason: refreshResult.reason || null,
163
+ error: refreshResult.error || null,
164
+ forced_recent_not_view: Boolean(refreshResult.forced_recent_not_view),
165
+ target_url: refreshResult.target_url || null,
166
+ card_count: refreshResult.card_count || 0,
167
+ elapsed_ms: refreshResult.elapsed_ms || 0,
168
+ attempts: (refreshResult.attempts || []).map((attempt) => ({
169
+ ok: Boolean(attempt.ok),
170
+ method: attempt.method || "",
171
+ reason: attempt.reason || null,
172
+ error: attempt.error || null,
173
+ card_count: attempt.card_count || 0,
174
+ elapsed_ms: attempt.elapsed_ms || 0,
175
+ job_selection_attempts: attempt.job_selection_attempts || []
176
+ })),
177
+ job_selection: refreshResult.job_selection
178
+ ? {
179
+ requested: refreshResult.job_selection.requested,
180
+ selected: Boolean(refreshResult.job_selection.selected),
181
+ already_current: Boolean(refreshResult.job_selection.already_current),
182
+ reason: refreshResult.job_selection.reason || null,
183
+ selected_option: refreshResult.job_selection.selected_option || null,
184
+ refresh_attempts: refreshResult.job_selection.refresh_attempts || []
185
+ }
186
+ : null,
187
+ job_selection_attempts: refreshResult.job_selection_attempts || [],
188
+ page_scope: refreshResult.page_scope
189
+ ? {
190
+ requested_scope: refreshResult.page_scope.requested_scope,
191
+ effective_scope: refreshResult.page_scope.effective_scope,
192
+ selected: Boolean(refreshResult.page_scope.selected),
193
+ fallback_applied: Boolean(refreshResult.page_scope.fallback_applied),
194
+ reason: refreshResult.page_scope.reason || null,
195
+ card_count: refreshResult.page_scope.card_count || refreshResult.page_scope.after?.card_count || 0
196
+ }
197
+ : null,
198
+ filter: refreshResult.filter
199
+ ? {
200
+ confirmed: Boolean(refreshResult.filter.confirmed),
201
+ selected_option: refreshResult.filter.selected_option || null,
202
+ selected_options: refreshResult.filter.selected_options || []
203
+ }
204
+ : null,
205
+ filter_reapply_attempts: refreshResult.filter_reapply_attempts || []
206
+ };
207
+ }
208
+
209
+ async function run() {
210
+ const options = parseArgs(process.argv.slice(2));
211
+ let session;
212
+ const result = {
213
+ status: "UNKNOWN",
214
+ generated_at: new Date().toISOString(),
215
+ chrome: {
216
+ host: options.host,
217
+ port: options.port,
218
+ target_url_includes: options.targetUrlIncludes
219
+ },
220
+ input: {
221
+ target_url: options.targetUrl,
222
+ job_label: options.jobLabel,
223
+ page_scope: options.pageScope,
224
+ fallback_page_scope: options.fallbackPageScope,
225
+ force_navigate: options.forceNavigate,
226
+ force_recent_not_view: options.forceRecentNotView,
227
+ reload_settle_ms: options.reloadSettleMs,
228
+ card_timeout_ms: options.cardTimeoutMs,
229
+ filter_groups: options.filterGroups
230
+ },
231
+ before: null,
232
+ refresh: null,
233
+ after: null
234
+ };
235
+
236
+ try {
237
+ session = await connectToRecommendSession(options);
238
+ const { client, methodLog, target } = session;
239
+ result.chrome.target = {
240
+ id: target.id,
241
+ type: target.type,
242
+ url: target.url,
243
+ title: target.title
244
+ };
245
+ result.runtime_guard_probe = await assertRuntimeEvaluateBlocked(client);
246
+ await enableDomains(client, ["Page", "DOM", "Input", "Accessibility"]);
247
+ await bringPageToFront(client);
248
+
249
+ const beforeState = await summarizeRecommendState(client);
250
+ result.before = beforeState.summary;
251
+ const jobLabel = options.jobLabel || beforeState.summary.current_job_label;
252
+ if (!jobLabel) {
253
+ throw new Error("No recommend job label was provided or detectable; pass --job");
254
+ }
255
+ result.input.job_label = jobLabel;
256
+
257
+ const refreshResult = await refreshRecommendListAtEnd(client, {
258
+ rootState: beforeState.roots,
259
+ jobLabel,
260
+ pageScope: options.pageScope,
261
+ fallbackPageScope: options.fallbackPageScope,
262
+ filter: { filterGroups: options.filterGroups },
263
+ preferEndRefreshButton: false,
264
+ forceNavigate: options.forceNavigate,
265
+ targetUrl: options.targetUrl,
266
+ forceRecentNotView: options.forceRecentNotView,
267
+ cardTimeoutMs: options.cardTimeoutMs,
268
+ reloadSettleMs: options.reloadSettleMs
269
+ });
270
+ result.refresh = compactRefreshResult(refreshResult);
271
+
272
+ await sleep(1000);
273
+ const afterState = await summarizeRecommendState(client);
274
+ result.after = afterState.summary;
275
+ result.method_summary = methodSummary(methodLog);
276
+ assertNoForbiddenCdpCalls(methodLog);
277
+
278
+ if (!refreshResult.ok) {
279
+ throw new Error(`Recommend recovery refresh failed: ${refreshResult.reason || refreshResult.error || "unknown"}`);
280
+ }
281
+ if (!refreshResult.job_selection?.selected) {
282
+ throw new Error("Recommend recovery smoke did not select the requested job");
283
+ }
284
+ if (!refreshResult.card_count) {
285
+ throw new Error("Recommend recovery smoke found no cards after refresh");
286
+ }
287
+
288
+ result.status = "PASS";
289
+ } catch (error) {
290
+ result.status = "FAIL";
291
+ result.error = {
292
+ message: error?.message || String(error),
293
+ stack: error?.stack || ""
294
+ };
295
+ process.exitCode = 1;
296
+ } finally {
297
+ if (session) await session.close().catch(() => null);
298
+ if (options.saveReport) {
299
+ result.report_path = writeJsonFile(options.saveReport, result);
300
+ }
301
+ console.log(JSON.stringify(result, null, 2));
302
+ }
303
+ }
304
+
305
+ await run();
@@ -101,6 +101,91 @@ function compactFilterReapplyError(error) {
101
101
  return error?.message || String(error || "Recommend filter reapply failed");
102
102
  }
103
103
 
104
+ export function isRetryableRecommendJobSelectionError(error) {
105
+ const message = String(error?.message || error || "");
106
+ return /Recommend job trigger was not found|Recommend job dropdown did not mount options/i.test(message);
107
+ }
108
+
109
+ function compactJobSelectionAttempt({
110
+ ok = false,
111
+ attempt = 0,
112
+ iframeDocumentNodeId = 0,
113
+ error = null,
114
+ selection = null
115
+ } = {}) {
116
+ return {
117
+ ok: Boolean(ok),
118
+ method: "job_select",
119
+ reason: error ? "job_select_failed" : null,
120
+ error: error ? (error?.message || String(error)) : null,
121
+ attempt,
122
+ iframe_document_node_id: iframeDocumentNodeId || 0,
123
+ selected: Boolean(selection?.selected),
124
+ selection_reason: selection?.reason || null
125
+ };
126
+ }
127
+
128
+ export async function selectRecommendJobWithRootRefresh(client, rootState, {
129
+ jobLabel = "",
130
+ settleMs = 6000,
131
+ dropdownTimeoutMs = 4000,
132
+ totalTimeoutMs = 30000,
133
+ retryDelayMs = 1000
134
+ } = {}) {
135
+ const started = Date.now();
136
+ const attempts = [];
137
+ let currentRootState = rootState || null;
138
+ let lastError = null;
139
+ let attempt = 0;
140
+
141
+ while (Date.now() - started <= totalTimeoutMs) {
142
+ attempt += 1;
143
+ if (!currentRootState?.iframe?.documentNodeId) {
144
+ currentRootState = await getRecommendRoots(client);
145
+ }
146
+ const iframeDocumentNodeId = currentRootState?.iframe?.documentNodeId || 0;
147
+ try {
148
+ const selection = await selectRecommendJob(client, iframeDocumentNodeId, {
149
+ jobLabel,
150
+ settleMs,
151
+ dropdownTimeoutMs
152
+ });
153
+ attempts.push(compactJobSelectionAttempt({
154
+ ok: true,
155
+ attempt,
156
+ iframeDocumentNodeId,
157
+ selection
158
+ }));
159
+ return {
160
+ job_selection: {
161
+ ...selection,
162
+ refresh_attempts: attempts
163
+ },
164
+ root_state: currentRootState,
165
+ attempts
166
+ };
167
+ } catch (error) {
168
+ lastError = error;
169
+ attempts.push(compactJobSelectionAttempt({
170
+ ok: false,
171
+ attempt,
172
+ iframeDocumentNodeId,
173
+ error
174
+ }));
175
+ if (!isRetryableRecommendJobSelectionError(error) || Date.now() - started >= totalTimeoutMs) {
176
+ break;
177
+ }
178
+ if (retryDelayMs > 0) await sleep(retryDelayMs);
179
+ currentRootState = await getRecommendRoots(client);
180
+ }
181
+ }
182
+
183
+ const wrapped = new Error(lastError?.message || "Recommend job selection failed after refresh reload");
184
+ wrapped.cause = lastError;
185
+ wrapped.job_selection_attempts = attempts;
186
+ throw wrapped;
187
+ }
188
+
104
189
  async function selectAndConfirmRefreshFilter(client, rootState, filterOptions, {
105
190
  maxAttempts = 3,
106
191
  retryDelayMs = 1500
@@ -162,6 +247,7 @@ async function applyRefreshMethod(client, method, {
162
247
  const started = Date.now();
163
248
  let currentRootState = null;
164
249
  let jobSelection = null;
250
+ let jobSelectionAttempts = [];
165
251
  let pageScopeResult = null;
166
252
  let filterResult = null;
167
253
  let filterReapplyAttempts = [];
@@ -180,16 +266,19 @@ async function applyRefreshMethod(client, method, {
180
266
  throw new Error("Recommend iframe was not ready after refresh reload");
181
267
  }
182
268
  if (jobLabel) {
183
- const jobDropdownTimeoutMs = reloadSettleMs > 10000 ? 15000 : 12000;
184
- jobSelection = await selectRecommendJob(client, currentRootState.iframe.documentNodeId, {
269
+ const jobSelectionResult = await selectRecommendJobWithRootRefresh(client, currentRootState, {
185
270
  jobLabel,
186
271
  settleMs: reloadSettleMs > 10000 ? 12000 : 6000,
187
- dropdownTimeoutMs: jobDropdownTimeoutMs
272
+ dropdownTimeoutMs: 4000,
273
+ totalTimeoutMs: reloadSettleMs > 10000 ? 45000 : 30000,
274
+ retryDelayMs: 1200
188
275
  });
276
+ jobSelection = jobSelectionResult.job_selection;
277
+ jobSelectionAttempts = jobSelectionResult.attempts;
189
278
  if (!jobSelection.selected) {
190
279
  throw new Error(`Requested recommend job was not selected after refresh reload: ${jobSelection.reason}`);
191
280
  }
192
- currentRootState = await getRecommendRoots(client);
281
+ currentRootState = jobSelectionResult.root_state || await getRecommendRoots(client);
193
282
  }
194
283
  pageScopeResult = await selectRecommendPageScope(
195
284
  client,
@@ -228,6 +317,7 @@ async function applyRefreshMethod(client, method, {
228
317
  method,
229
318
  target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
230
319
  job_selection: jobSelection,
320
+ job_selection_attempts: jobSelectionAttempts,
231
321
  page_scope: pageScopeResult,
232
322
  filter: filterResult,
233
323
  filter_reapply_attempts: filterReapplyAttempts,
@@ -244,6 +334,7 @@ async function applyRefreshMethod(client, method, {
244
334
  error: error?.message || String(error),
245
335
  target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
246
336
  job_selection: jobSelection,
337
+ job_selection_attempts: error?.job_selection_attempts || jobSelectionAttempts,
247
338
  page_scope: pageScopeResult,
248
339
  filter: filterResult,
249
340
  filter_reapply_attempts: error?.filter_reapply_attempts || filterReapplyAttempts,
@@ -379,6 +379,16 @@ function compactRefreshAttempt(refreshAttempt) {
379
379
  error: attempt.error || null,
380
380
  attempt: attempt.attempt || 0
381
381
  })),
382
+ job_selection_attempts: (refreshAttempt.job_selection_attempts || []).map((attempt) => ({
383
+ ok: Boolean(attempt.ok),
384
+ method: attempt.method || "job_select",
385
+ reason: attempt.reason || null,
386
+ error: attempt.error || null,
387
+ attempt: attempt.attempt || 0,
388
+ iframe_document_node_id: attempt.iframe_document_node_id || 0,
389
+ selected: Boolean(attempt.selected),
390
+ selection_reason: attempt.selection_reason || null
391
+ })),
382
392
  job_selection: compactJobSelection(refreshAttempt.job_selection),
383
393
  page_scope: compactPageScopeSelection(refreshAttempt.page_scope),
384
394
  filter: compactFilterResult(refreshAttempt.filter)