@reconcrap/boss-recommend-mcp 1.2.6 → 1.2.8

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
@@ -14,6 +14,12 @@ MCP 工具:
14
14
  - `cancel_recommend_pipeline_run`(取消运行中任务)
15
15
  - `pause_recommend_pipeline_run`(请求暂停 run;会在当前候选人处理完成后进入 paused)
16
16
  - `resume_recommend_pipeline_run`(继续 paused run;沿用同 run_id 与同 CSV)
17
+ - `run_recommend_self_heal`(手动运维工具;扫描 Boss recommend 的 selector / network 规则漂移,并在确认后应用高置信度修复)
18
+ - `validation_profile=safe`:只做非破坏性扫描与被动 network 观察
19
+ - `validation_profile=full`:会主动打开候选人详情,并执行收藏往返校验与一次打招呼校验;若完整交互没跑通,会明确返回验证异常而不是静默跳过
20
+ - 扫描会主动覆盖 recommend/latest/featured 三个 tab 的详情链路(详情打开、详情内关键 selector、popup 关闭)
21
+ - 搜索链路 selector 会在状态触发后验证:职位下拉、职位搜索输入、职位 label、筛选面板、筛选分组(school/degree/gender/recentNotView)、筛选滚动容器与筛选项激活态
22
+ - 对关闭弹层相关 selector,会同时验证 close 按钮与 fallback close 候选 selector
17
23
 
18
24
  状态机:
19
25
 
@@ -52,6 +58,7 @@ MCP 工具:
52
58
  - 不会对每位候选人重复确认
53
59
  - 推荐页详情处理完成后,会强制关闭详情页并确认已关闭
54
60
  - 简历提取采用“分段滚动截图 + 拼成长图”的方式,再交给多模态模型判断
61
+ - 提供显式运维自愈工具:只在手动调用 `run_recommend_self_heal` 时运行,不会接入正常 run / doctor / preflight 自动链路
55
62
  - 运行前会自动做依赖体检(Node.js、Python、Pillow、`chrome-remote-interface`、`ws`),缺失时会在 `doctor` 与流水线失败诊断中明确提示
56
63
  - 若 preflight 失败,返回 `diagnostics.recovery`(含有序修复步骤与 `agent_prompt`),可直接交给 AI agent 自动按顺序安装依赖
57
64
  - 不依赖 PowerShell;Windows / macOS 均可运行(命令提示会按平台给出)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -25,6 +25,7 @@
25
25
  "test:run-state": "node src/test-run-state.js",
26
26
  "test:runtime": "node src/test-adapters-runtime.js",
27
27
  "test:async": "node src/test-index-async.js",
28
+ "test:self-heal": "node src/test-self-heal.js",
28
29
  "test:search-job": "node vendor/boss-recommend-search-cli/src/test-job-selection.js",
29
30
  "test:screen-runtime": "node vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs"
30
31
  },
package/src/adapters.js CHANGED
@@ -1,9 +1,13 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { spawn, spawnSync } from "node:child_process";
5
- import { fileURLToPath } from "node:url";
6
- import CDP from "chrome-remote-interface";
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import CDP from "chrome-remote-interface";
7
+ import {
8
+ buildFirstSelectorLookupExpression,
9
+ getRecommendSelectorRule
10
+ } from "./recommend-healing-config.js";
7
11
 
8
12
  const currentFilePath = fileURLToPath(import.meta.url);
9
13
  const packagedMcpDir = path.resolve(path.dirname(currentFilePath), "..");
@@ -23,6 +27,44 @@ const PAGE_SCOPE_TO_TAB_STATUS = {
23
27
  latest: "1",
24
28
  featured: "3"
25
29
  };
30
+ const RECOMMEND_IFRAME_SELECTORS = getRecommendSelectorRule(
31
+ ["top", "recommend_iframe"],
32
+ ['iframe[name="recommendFrame"]', 'iframe[src*="/web/frame/recommend/"]', "iframe"]
33
+ );
34
+ const RECOMMEND_TAB_SELECTORS = getRecommendSelectorRule(
35
+ ["frame", "tab_items"],
36
+ ['li.tab-item[data-status]', 'li[data-status][class*="tab"]']
37
+ );
38
+ const RECOMMEND_CARD_SELECTORS = getRecommendSelectorRule(["frame", "recommend_cards"], ['ul.card-list > li.card-item']);
39
+ const FEATURED_CARD_SELECTORS = getRecommendSelectorRule(["frame", "featured_cards"], ['li.geek-info-card']);
40
+ const LATEST_CARD_SELECTORS = getRecommendSelectorRule(["frame", "latest_cards"], ['.candidate-card-wrap .card-inner[data-geek]', '.candidate-card-wrap [data-geek]']);
41
+ const DETAIL_POPUP_SELECTORS = getRecommendSelectorRule(
42
+ ["detail", "popup"],
43
+ [
44
+ ".dialog-wrap.active",
45
+ ".boss-popup__wrapper",
46
+ ".boss-popup_wrapper",
47
+ ".boss-dialog_wrapper",
48
+ ".boss-dialog",
49
+ ".resume-item-detail",
50
+ ".geek-detail-modal",
51
+ '[class*="popup"][class*="wrapper"]',
52
+ '[class*="dialog"][class*="wrapper"]'
53
+ ]
54
+ );
55
+ const DETAIL_RESUME_IFRAME_SELECTORS = getRecommendSelectorRule(
56
+ ["detail", "resume_iframe"],
57
+ ['iframe[src*="/web/frame/c-resume/"]', 'iframe[name*="resume"]']
58
+ );
59
+ const REFRESH_FINISHED_WRAP_SELECTORS = getRecommendSelectorRule(["frame", "refresh_finished_wrap"], [".finished-wrap"]);
60
+ const REFRESH_BUTTON_SELECTORS = getRecommendSelectorRule(
61
+ ["frame", "refresh_button"],
62
+ [".finished-wrap .btn.btn-refresh", ".finished-wrap .btn-refresh", ".no-data-refresh .btn-refresh"]
63
+ );
64
+
65
+ function buildRecommendIframeLookupExpression(rootExpr = "document") {
66
+ return buildFirstSelectorLookupExpression(RECOMMEND_IFRAME_SELECTORS, rootExpr);
67
+ }
26
68
 
27
69
  function getCodexHome() {
28
70
  return process.env.CODEX_HOME
@@ -1575,9 +1617,7 @@ function buildRecommendIframeProbeExpression() {
1575
1617
  title
1576
1618
  };
1577
1619
  }
1578
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1579
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1580
- || document.querySelector('iframe');
1620
+ const frame = ${buildRecommendIframeLookupExpression()};
1581
1621
  const frameUrl = (() => {
1582
1622
  try {
1583
1623
  return String(frame?.contentWindow?.location?.href || frame?.src || "");
@@ -1798,15 +1838,16 @@ async function evaluateCdpExpression(client, expression) {
1798
1838
 
1799
1839
  function buildRecommendTabStateExpression() {
1800
1840
  return `(() => {
1801
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1802
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1803
- || document.querySelector('iframe');
1841
+ const frame = ${buildRecommendIframeLookupExpression()};
1804
1842
  if (!frame || !frame.contentDocument) {
1805
1843
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1806
1844
  }
1807
1845
  const doc = frame.contentDocument;
1808
1846
  const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
1809
- const tabs = Array.from(doc.querySelectorAll('li.tab-item[data-status], li[data-status][class*="tab"]')).map((node) => {
1847
+ const tabs = Array.from(new Set(${JSON.stringify(RECOMMEND_TAB_SELECTORS)}
1848
+ .flatMap((selector) => {
1849
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
1850
+ }))).map((node) => {
1810
1851
  const status = normalize(node.getAttribute('data-status'));
1811
1852
  const className = normalize(node.className);
1812
1853
  const active = (
@@ -1823,9 +1864,18 @@ function buildRecommendTabStateExpression() {
1823
1864
  };
1824
1865
  });
1825
1866
  const activeTab = tabs.find((item) => item.active && item.status) || null;
1826
- const featuredCount = doc.querySelectorAll('li.geek-info-card').length;
1827
- const recommendCount = doc.querySelectorAll('ul.card-list > li.card-item').length;
1828
- const latestCount = doc.querySelectorAll('.candidate-card-wrap .card-inner[data-geek], .candidate-card-wrap [data-geek]').length;
1867
+ const featuredCount = ${JSON.stringify(FEATURED_CARD_SELECTORS)}
1868
+ .reduce((sum, selector) => {
1869
+ try { return sum + doc.querySelectorAll(selector).length; } catch { return sum; }
1870
+ }, 0);
1871
+ const recommendCount = ${JSON.stringify(RECOMMEND_CARD_SELECTORS)}
1872
+ .reduce((sum, selector) => {
1873
+ try { return sum + doc.querySelectorAll(selector).length; } catch { return sum; }
1874
+ }, 0);
1875
+ const latestCount = ${JSON.stringify(LATEST_CARD_SELECTORS)}
1876
+ .reduce((sum, selector) => {
1877
+ try { return sum + doc.querySelectorAll(selector).length; } catch { return sum; }
1878
+ }, 0);
1829
1879
  let inferredStatus = activeTab?.status || null;
1830
1880
  if (!inferredStatus) {
1831
1881
  if (featuredCount > 0 && recommendCount === 0 && latestCount === 0) inferredStatus = '3';
@@ -1847,15 +1897,16 @@ function buildRecommendTabStateExpression() {
1847
1897
 
1848
1898
  function buildRecommendTabSwitchExpression(targetStatus) {
1849
1899
  return `((targetStatus) => {
1850
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1851
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1852
- || document.querySelector('iframe');
1900
+ const frame = ${buildRecommendIframeLookupExpression()};
1853
1901
  if (!frame || !frame.contentDocument) {
1854
1902
  return { ok: false, state: 'NO_RECOMMEND_IFRAME' };
1855
1903
  }
1856
1904
  const doc = frame.contentDocument;
1857
1905
  const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
1858
- const tabs = Array.from(doc.querySelectorAll('li.tab-item[data-status], li[data-status][class*="tab"]'));
1906
+ const tabs = Array.from(new Set(${JSON.stringify(RECOMMEND_TAB_SELECTORS)}
1907
+ .flatMap((selector) => {
1908
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
1909
+ })));
1859
1910
  const target = tabs.find((node) => normalize(node.getAttribute('data-status')) === String(targetStatus)) || null;
1860
1911
  if (!target) {
1861
1912
  return { ok: false, state: 'TAB_NOT_FOUND', target_status: String(targetStatus) };
@@ -1883,19 +1934,7 @@ function buildRecommendTabSwitchExpression(targetStatus) {
1883
1934
 
1884
1935
  function buildRecommendDetailStateExpression() {
1885
1936
  return `(() => {
1886
- const selectors = [
1887
- '.dialog-wrap.active',
1888
- '.boss-popup__wrapper',
1889
- '.boss-popup_wrapper',
1890
- '.boss-dialog_wrapper',
1891
- '.boss-dialog',
1892
- '.resume-item-detail',
1893
- '.geek-detail-modal',
1894
- 'iframe[src*="/web/frame/c-resume/"]',
1895
- 'iframe[name*="resume"]',
1896
- '[class*="popup"][class*="wrapper"]',
1897
- '[class*="dialog"][class*="wrapper"]'
1898
- ];
1937
+ const selectors = ${JSON.stringify([...DETAIL_POPUP_SELECTORS, ...DETAIL_RESUME_IFRAME_SELECTORS])};
1899
1938
  const isVisible = (node) => {
1900
1939
  if (!node || !node.getBoundingClientRect) return false;
1901
1940
  const rect = node.getBoundingClientRect();
@@ -1929,9 +1968,7 @@ function buildRecommendDetailStateExpression() {
1929
1968
  const topDetail = findVisibleDetail(document, 'top');
1930
1969
  if (topDetail) return topDetail;
1931
1970
 
1932
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1933
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1934
- || null;
1971
+ const frame = ${buildRecommendIframeLookupExpression()} || null;
1935
1972
  if (!frame || !frame.contentDocument) {
1936
1973
  return { ok: true, open: false, reason: 'NO_RECOMMEND_IFRAME' };
1937
1974
  }
@@ -2195,14 +2232,12 @@ export async function switchRecommendTab(workspaceRoot, options = {}) {
2195
2232
  }
2196
2233
  }
2197
2234
 
2198
- function buildRecommendRefreshStateExpression() {
2199
- return `(() => {
2200
- const frame = document.querySelector('iframe[name="recommendFrame"]')
2201
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
2202
- || document.querySelector('iframe');
2203
- if (!frame || !frame.contentDocument) {
2204
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
2205
- }
2235
+ function buildRecommendRefreshStateExpression() {
2236
+ return `(() => {
2237
+ const frame = ${buildRecommendIframeLookupExpression()};
2238
+ if (!frame || !frame.contentDocument) {
2239
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
2240
+ }
2206
2241
  const doc = frame.contentDocument;
2207
2242
  const isVisible = (el) => {
2208
2243
  if (!el) return false;
@@ -2215,13 +2250,29 @@ function buildRecommendRefreshStateExpression() {
2215
2250
  const rect = el.getBoundingClientRect();
2216
2251
  return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
2217
2252
  };
2218
- const finishedWrap = Array.from(doc.querySelectorAll('.finished-wrap')).find((el) => isVisible(el)) || null;
2219
- const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
2220
- .find((el) => isVisible(el)) || null;
2221
- const cards = Array.from(doc.querySelectorAll('ul.card-list > li.card-item'));
2253
+ const finishedWrap = ${JSON.stringify(REFRESH_FINISHED_WRAP_SELECTORS)}
2254
+ .flatMap((selector) => {
2255
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2256
+ })
2257
+ .find((el) => isVisible(el)) || null;
2258
+ const refreshButton = ${JSON.stringify(REFRESH_BUTTON_SELECTORS)}
2259
+ .flatMap((selector) => {
2260
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2261
+ })
2262
+ .find((el) => isVisible(el)) || null;
2263
+ const cards = ${JSON.stringify(RECOMMEND_CARD_SELECTORS)}
2264
+ .flatMap((selector) => {
2265
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2266
+ });
2222
2267
  const candidateCards = cards.filter((card) => card.querySelector('.card-inner[data-geekid]'));
2223
- const latestCards = Array.from(doc.querySelectorAll('.candidate-card-wrap .card-inner[data-geek], .candidate-card-wrap [data-geek]'));
2224
- const tabs = Array.from(doc.querySelectorAll('li.tab-item[data-status], li[data-status][class*="tab"]'));
2268
+ const latestCards = ${JSON.stringify(LATEST_CARD_SELECTORS)}
2269
+ .flatMap((selector) => {
2270
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2271
+ });
2272
+ const tabs = ${JSON.stringify(RECOMMEND_TAB_SELECTORS)}
2273
+ .flatMap((selector) => {
2274
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2275
+ });
2225
2276
  const activeTab = tabs.find((node) => /(?:^|\\s)(?:curr|current|active|selected)(?:\\s|$)/i.test(String(node.className || ''))) || null;
2226
2277
  const activeStatus = activeTab ? String(activeTab.getAttribute('data-status') || '') : '';
2227
2278
  const inferredStatus = activeStatus
@@ -2247,14 +2298,12 @@ function buildRecommendRefreshStateExpression() {
2247
2298
  })()`;
2248
2299
  }
2249
2300
 
2250
- function buildRecommendRefreshClickExpression() {
2251
- return `(() => {
2252
- const frame = document.querySelector('iframe[name="recommendFrame"]')
2253
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
2254
- || document.querySelector('iframe');
2255
- if (!frame || !frame.contentDocument) {
2256
- return { ok: false, state: 'NO_RECOMMEND_IFRAME' };
2257
- }
2301
+ function buildRecommendRefreshClickExpression() {
2302
+ return `(() => {
2303
+ const frame = ${buildRecommendIframeLookupExpression()};
2304
+ if (!frame || !frame.contentDocument) {
2305
+ return { ok: false, state: 'NO_RECOMMEND_IFRAME' };
2306
+ }
2258
2307
  const doc = frame.contentDocument;
2259
2308
  const isVisible = (el) => {
2260
2309
  if (!el) return false;
@@ -2267,8 +2316,11 @@ function buildRecommendRefreshClickExpression() {
2267
2316
  const rect = el.getBoundingClientRect();
2268
2317
  return rect.width > 2 && rect.height > 2 && el.offsetParent !== null;
2269
2318
  };
2270
- const refreshButton = Array.from(doc.querySelectorAll('.finished-wrap .btn.btn-refresh, .finished-wrap .btn-refresh, .no-data-refresh .btn-refresh'))
2271
- .find((el) => isVisible(el)) || null;
2319
+ const refreshButton = ${JSON.stringify(REFRESH_BUTTON_SELECTORS)}
2320
+ .flatMap((selector) => {
2321
+ try { return Array.from(doc.querySelectorAll(selector)); } catch { return []; }
2322
+ })
2323
+ .find((el) => isVisible(el)) || null;
2272
2324
  if (!refreshButton) {
2273
2325
  return { ok: false, state: 'REFRESH_BUTTON_NOT_FOUND' };
2274
2326
  }
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  runRecommendCalibration
10
10
  } from "./adapters.js";
11
11
  import { runRecommendPipeline } from "./pipeline.js";
12
+ import { runRecommendSelfHeal } from "./self-heal.js";
12
13
  import {
13
14
  RUN_MODE_ASYNC,
14
15
  RUN_STAGE_PREFLIGHT,
@@ -39,6 +40,7 @@ const TOOL_PAUSE_RUN = "pause_recommend_pipeline_run";
39
40
  const TOOL_RESUME_RUN = "resume_recommend_pipeline_run";
40
41
  const TOOL_RUN_FEATURED_CALIBRATION = "run_featured_calibration";
41
42
  const TOOL_GET_FEATURED_CALIBRATION_STATUS = "get_featured_calibration_status";
43
+ const TOOL_RUN_RECOMMEND_SELF_HEAL = "run_recommend_self_heal";
42
44
 
43
45
  const SERVER_NAME = "boss-recommend-mcp";
44
46
  const FRAMING_UNKNOWN = "unknown";
@@ -49,6 +51,7 @@ const DETACHED_WORKER_RUN_ID_FLAG = "--run-id";
49
51
  const DETACHED_WORKER_RESUME_FLAG = "--resume";
50
52
 
51
53
  let runPipelineImpl = runRecommendPipeline;
54
+ let runSelfHealImpl = runRecommendSelfHeal;
52
55
  let spawnProcessImpl = spawn;
53
56
  const TERMINAL_RUN_STATES = new Set([RUN_STATE_COMPLETED, RUN_STATE_FAILED, RUN_STATE_CANCELED]);
54
57
 
@@ -321,6 +324,43 @@ function createRunFeaturedCalibrationInputSchema() {
321
324
  };
322
325
  }
323
326
 
327
+ function createRunRecommendSelfHealInputSchema() {
328
+ return {
329
+ type: "object",
330
+ properties: {
331
+ mode: {
332
+ type: "string",
333
+ enum: ["scan", "apply"],
334
+ description: "scan=扫描漂移;apply=按 repair_session_id 应用高置信度修复"
335
+ },
336
+ scope: {
337
+ type: "string",
338
+ enum: ["full", "search_screen", "selectors_only"],
339
+ description: "扫描范围,默认 full"
340
+ },
341
+ validation_profile: {
342
+ type: "string",
343
+ enum: ["safe", "full"],
344
+ description: "校验强度,默认 full"
345
+ },
346
+ port: {
347
+ type: "integer",
348
+ minimum: 1,
349
+ description: "可选,Boss Chrome 远程调试端口"
350
+ },
351
+ repair_session_id: {
352
+ type: "string",
353
+ description: "apply 模式必填,来自 scan 返回值"
354
+ },
355
+ confirm_apply: {
356
+ type: "boolean",
357
+ description: "apply 模式必填,必须显式传 true"
358
+ }
359
+ },
360
+ additionalProperties: false
361
+ };
362
+ }
363
+
324
364
  function createToolsSchema() {
325
365
  return [
326
366
  {
@@ -389,6 +429,11 @@ function createToolsSchema() {
389
429
  properties: {},
390
430
  additionalProperties: false
391
431
  }
432
+ },
433
+ {
434
+ name: TOOL_RUN_RECOMMEND_SELF_HEAL,
435
+ description: "手动运维自愈工具:扫描 Boss recommend 相关 selector / network 规则漂移,并在确认后应用高置信度修复。",
436
+ inputSchema: createRunRecommendSelfHealInputSchema()
392
437
  }
393
438
  ];
394
439
  }
@@ -448,6 +493,42 @@ function validateRunFeaturedCalibrationArgs(args) {
448
493
  return null;
449
494
  }
450
495
 
496
+ function validateRunRecommendSelfHealArgs(args) {
497
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
498
+ return "arguments must be an object";
499
+ }
500
+
501
+ if (Object.prototype.hasOwnProperty.call(args, "mode")) {
502
+ const mode = normalizeText(args.mode).toLowerCase();
503
+ if (!["scan", "apply"].includes(mode)) {
504
+ return "mode must be one of: scan, apply";
505
+ }
506
+ }
507
+
508
+ if (Object.prototype.hasOwnProperty.call(args, "scope")) {
509
+ const scope = normalizeText(args.scope).toLowerCase();
510
+ if (!["full", "search_screen", "selectors_only"].includes(scope)) {
511
+ return "scope must be one of: full, search_screen, selectors_only";
512
+ }
513
+ }
514
+
515
+ if (Object.prototype.hasOwnProperty.call(args, "validation_profile")) {
516
+ const profile = normalizeText(args.validation_profile).toLowerCase();
517
+ if (!["safe", "full"].includes(profile)) {
518
+ return "validation_profile must be one of: safe, full";
519
+ }
520
+ }
521
+
522
+ if (Object.prototype.hasOwnProperty.call(args, "port")) {
523
+ const port = Number.parseInt(String(args.port), 10);
524
+ if (!Number.isFinite(port) || port <= 0) {
525
+ return "port must be a positive integer";
526
+ }
527
+ }
528
+
529
+ return null;
530
+ }
531
+
451
532
  function getLastOutputLine(text) {
452
533
  const lines = String(text || "")
453
534
  .split(/\r?\n/)
@@ -1340,6 +1421,10 @@ async function handleRunFeaturedCalibrationTool({ workspaceRoot, args }) {
1340
1421
  };
1341
1422
  }
1342
1423
 
1424
+ async function handleRunRecommendSelfHealTool({ workspaceRoot, args }) {
1425
+ return runSelfHealImpl({ workspaceRoot, args });
1426
+ }
1427
+
1343
1428
  async function handleRequest(message, workspaceRoot) {
1344
1429
  if (!message || message.jsonrpc !== "2.0") {
1345
1430
  return createJsonRpcError(null, -32600, "Invalid JSON-RPC request");
@@ -1396,6 +1481,13 @@ async function handleRequest(message, workspaceRoot) {
1396
1481
  }
1397
1482
  }
1398
1483
 
1484
+ if (toolName === TOOL_RUN_RECOMMEND_SELF_HEAL) {
1485
+ const inputError = validateRunRecommendSelfHealArgs(args);
1486
+ if (inputError) {
1487
+ return createJsonRpcError(id, -32602, inputError);
1488
+ }
1489
+ }
1490
+
1399
1491
  if ([TOOL_GET_RUN, TOOL_CANCEL_RUN, TOOL_PAUSE_RUN, TOOL_RESUME_RUN].includes(toolName)) {
1400
1492
  if (!args || typeof args.run_id !== "string" || !normalizeText(args.run_id)) {
1401
1493
  return createJsonRpcError(id, -32602, "run_id is required and must be a string");
@@ -1418,6 +1510,8 @@ async function handleRequest(message, workspaceRoot) {
1418
1510
  payload = handleGetFeaturedCalibrationStatusTool(workspaceRoot);
1419
1511
  } else if (toolName === TOOL_RUN_FEATURED_CALIBRATION) {
1420
1512
  payload = await handleRunFeaturedCalibrationTool({ workspaceRoot, args });
1513
+ } else if (toolName === TOOL_RUN_RECOMMEND_SELF_HEAL) {
1514
+ payload = await handleRunRecommendSelfHealTool({ workspaceRoot, args });
1421
1515
  } else {
1422
1516
  return createJsonRpcError(id, -32602, `Unknown tool: ${toolName || ""}`);
1423
1517
  }
@@ -1553,6 +1647,9 @@ export const __testables = {
1553
1647
  },
1554
1648
  setRunPipelineImplForTests(nextImpl) {
1555
1649
  runPipelineImpl = typeof nextImpl === "function" ? nextImpl : runRecommendPipeline;
1650
+ },
1651
+ setRunSelfHealImplForTests(nextImpl) {
1652
+ runSelfHealImpl = typeof nextImpl === "function" ? nextImpl : runRecommendSelfHeal;
1556
1653
  }
1557
1654
  };
1558
1655
 
package/src/parser.js CHANGED
@@ -82,9 +82,9 @@ const RECENT_NOT_VIEW_NEGATIVE_PATTERNS = [
82
82
  /保留[^。;;\n]{0,12}14天/i
83
83
  ];
84
84
  const TARGET_COUNT_PATTERNS = [
85
- /目标(?:处理|筛选)?(?:人数|数量)?(?:为|是|:|:)?\s*(\d+)/i,
86
- /至少(?:处理|筛选)\s*(\d+)\s*(?:位|人)/i,
87
- /(?:处理|筛选)\s*(\d+)\s*(?:位|人)/i
85
+ /目标(?:处理|筛选|通过)?(?:人数|数量)?(?:为|是|:|:)?\s*(\d+)/i,
86
+ /至少(?:处理|筛选|通过)\s*(\d+)\s*(?:位|人)/i,
87
+ /(?:处理|筛选|通过)\s*(\d+)\s*(?:位|人)/i
88
88
  ];
89
89
  const MAX_GREET_COUNT_PATTERNS = [
90
90
  /最多(?:打招呼|沟通|联系)\s*(\d+)\s*(?:位|人|个)?/i,
@@ -95,7 +95,7 @@ const FILTER_CLAUSE_PATTERNS = [
95
95
  /学历|学位|教育|初中及以下|中专|中技|高中|大专|专科|本科|硕士|研究生|博士/i,
96
96
  /性别|男生|女生|男性|女性|男\b|女\b/i,
97
97
  /近?14天(?:内)?没有|近?14天(?:内)?没看过|近?14天(?:内)?未查看|过滤[^。;;\n]{0,12}14天|排除[^。;;\n]{0,12}14天/i,
98
- /目标(?:处理|筛选)?(?:人数|数量)?|至少(?:处理|筛选)|(?:处理|筛选)\s*\d+\s*(?:位|人)/i,
98
+ /目标(?:处理|筛选|通过)?(?:人数|数量)?|至少(?:处理|筛选|通过)|(?:处理|筛选|通过)\s*\d+\s*(?:位|人)/i,
99
99
  /最多(?:打招呼|沟通|联系)|(?:打招呼|沟通|联系)(?:上限|最多|不超过|至多)/i,
100
100
  /收藏|打招呼|直接沟通|什么也不做|不做任何操作|不操作|仅筛选|只筛选/i
101
101
  ];
@@ -690,7 +690,7 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
690
690
  if (needs_target_count_confirmation) {
691
691
  pending_questions.push({
692
692
  field: "target_count",
693
- question: "本次目标筛选人数是多少?可留空表示不设上限。",
693
+ question: "本次目标通过人数是多少?可留空表示不设上限。",
694
694
  value: targetCountResolution.proposed_target_count
695
695
  });
696
696
  }
package/src/pipeline.js CHANGED
@@ -18,6 +18,17 @@ const FORCED_RECENT_NOT_VIEW_ON_SCREEN_RECOVERY = "近14天没有";
18
18
  const MAX_SCREEN_AUTO_RECOVERY_ATTEMPTS = 5;
19
19
  const MAX_SEARCH_NO_IFRAME_RETRY_ATTEMPTS = 1;
20
20
  const SEARCH_NO_IFRAME_RETRY_DELAY_MS = 1200;
21
+ const MAX_SEARCH_FILTER_AUTO_RETRY_ATTEMPTS = 2;
22
+ const SEARCH_FILTER_AUTO_RETRY_DELAY_MS = 1200;
23
+ const SEARCH_FILTER_RETRY_TOKENS = [
24
+ "FILTER_CONFIRM_FAILED",
25
+ "FILTER_DOM_CLASS_VERIFY_FAILED",
26
+ "RECOMMEND_FILTER_PANEL_UNAVAILABLE",
27
+ "RECOMMEND_FILTER_PANEL_NOT_READY",
28
+ "FILTER_PANEL_NOT_FOUND",
29
+ "FILTER_TRIGGER_NOT_FOUND",
30
+ "FILTER_PANEL_OPEN_FAILED"
31
+ ];
21
32
  const PAGE_SCOPE_TO_TAB_STATUS = {
22
33
  recommend: "0",
23
34
  latest: "1",
@@ -46,6 +57,20 @@ function sleep(ms) {
46
57
  return new Promise((resolve) => setTimeout(resolve, ms));
47
58
  }
48
59
 
60
+ function shouldAutoRetrySearchFilterFailure(errorCode, errorMessage) {
61
+ const normalizedCode = normalizeText(errorCode).toUpperCase();
62
+ const normalizedMessage = normalizeText(errorMessage).toUpperCase();
63
+ const combined = `${normalizedCode} ${normalizedMessage}`.trim();
64
+ if (!combined) return false;
65
+ if (combined.includes("LOGIN_REQUIRED") || combined.includes("NO_RECOMMEND_IFRAME")) {
66
+ return false;
67
+ }
68
+ if (SEARCH_FILTER_RETRY_TOKENS.some((token) => combined.includes(token))) {
69
+ return true;
70
+ }
71
+ return /^(RECOMMEND_)?FILTER_/.test(normalizedCode);
72
+ }
73
+
49
74
  function normalizePageScope(value) {
50
75
  const normalized = normalizeText(value).toLowerCase();
51
76
  if (!normalized) return null;
@@ -336,7 +361,7 @@ function buildNeedConfirmationResponse(parsedResult) {
336
361
  function buildFinalReviewQuestion({ searchParams, screenParams, selectedJob, selectedPage }) {
337
362
  return {
338
363
  field: "final_review",
339
- question: "开始执行搜索和筛选前,请最后确认全部参数(岗位/页面/筛选条件/筛选 criteria/目标人数/post_action/max_greet_count)无误。",
364
+ question: "开始执行搜索和筛选前,请最后确认全部参数(岗位/页面/筛选条件/筛选 criteria/目标通过人数/post_action/max_greet_count)无误。",
340
365
  value: {
341
366
  job: selectedJob?.title || selectedJob?.label || selectedJob?.value || null,
342
367
  page_scope: selectedPage || "recommend",
@@ -893,6 +918,7 @@ export async function runRecommendPipeline(
893
918
  let screenAutoRecoveryCount = 0;
894
919
  let lastAutoRecovery = null;
895
920
  let searchNoIframeRetryCount = 0;
921
+ let searchFilterRetryCount = 0;
896
922
  let activeTabStatus = null;
897
923
  let currentResumeConfig = {
898
924
  checkpoint_path: resume?.checkpoint_path || null,
@@ -1039,6 +1065,29 @@ export async function runRecommendPipeline(
1039
1065
  continue;
1040
1066
  }
1041
1067
  }
1068
+ if (
1069
+ shouldAutoRetrySearchFilterFailure(searchErrorCode, searchErrorMessage)
1070
+ && searchFilterRetryCount < MAX_SEARCH_FILTER_AUTO_RETRY_ATTEMPTS
1071
+ ) {
1072
+ searchFilterRetryCount += 1;
1073
+ const retryDelayMs = SEARCH_FILTER_AUTO_RETRY_DELAY_MS;
1074
+ lastAutoRecovery = {
1075
+ trigger: "SEARCH_FILTER_RETRY",
1076
+ attempt: searchFilterRetryCount,
1077
+ max_attempts: MAX_SEARCH_FILTER_AUTO_RETRY_ATTEMPTS,
1078
+ delay_ms: retryDelayMs,
1079
+ error_code: searchErrorCode || null,
1080
+ error_message: searchErrorMessage || null,
1081
+ action: "retry_search"
1082
+ };
1083
+ runtimeHooks.setStage(
1084
+ "search_recovery",
1085
+ `检测到筛选控件状态异常(${searchErrorCode || "UNKNOWN"}),等待 ${Math.round(retryDelayMs / 1000)} 秒后重试 search(第 ${searchFilterRetryCount}/${MAX_SEARCH_FILTER_AUTO_RETRY_ATTEMPTS} 次)。`
1086
+ );
1087
+ runtimeHooks.heartbeat("search_recovery", lastAutoRecovery);
1088
+ await sleep(retryDelayMs);
1089
+ continue;
1090
+ }
1042
1091
  return buildFailedResponse(
1043
1092
  searchResult.error?.code || "RECOMMEND_SEARCH_FAILED",
1044
1093
  searchResult.error?.message || "推荐页筛选执行失败。",
@@ -1057,6 +1106,7 @@ export async function runRecommendPipeline(
1057
1106
  );
1058
1107
  }
1059
1108
 
1109
+ searchFilterRetryCount = 0;
1060
1110
  searchSummary = searchResult.summary || {};
1061
1111
  if (isPauseRequested(runtimeHooks)) {
1062
1112
  return buildPausedResponse("已在 screen 阶段开始前暂停 Recommend 流水线。", {