@reconcrap/boss-recommend-mcp 2.0.30 → 2.0.32

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
@@ -227,10 +227,35 @@ config/screening-config.example.json
227
227
  - `apiKey`
228
228
  - `model`
229
229
 
230
+ 也可以用 `llmModels` 配置多个 OpenAI-compatible 模型。运行时会先调用第一个模型;当该模型请求失败、超时、返回非 JSON、或没有返回 `{"passed": true/false}` 决策时,会自动切到下一个模型。未配置 `llmModels` 或数组为空时,继续使用上面的单模型字段,旧配置无需迁移。
231
+
232
+ ```json
233
+ {
234
+ "llmModels": [
235
+ {
236
+ "name": "primary",
237
+ "baseUrl": "https://api.openai.com/v1",
238
+ "apiKey": "sk-primary",
239
+ "model": "gpt-4.1-mini"
240
+ },
241
+ {
242
+ "name": "backup",
243
+ "baseUrl": "https://api.openai.com/v1",
244
+ "apiKey": "sk-backup",
245
+ "model": "gpt-4.1-nano"
246
+ }
247
+ ],
248
+ "greetingMessage": "Hi同学,能麻烦发下简历吗?",
249
+ "llmThinkingLevel": "low",
250
+ "llmMaxRetries": 1
251
+ }
252
+ ```
253
+
230
254
  可选字段:
231
255
 
232
256
  - `openaiOrganization`
233
257
  - `openaiProject`
258
+ - `greetingMessage`:chat 求简历流程发送的招呼语。兼容 `greetingText` / `greeting_text`;本次 run 显式传入的 `greeting_text` 优先级最高。
234
259
  - `debugPort`:未显式传 `port` 时,recommend / search / chat CDP-only MCP run 和健康检查默认连接这个 Chrome 调试端口。
235
260
  - `outputDir`:recommend / search / chat 完成后的最终 CSV 与 report JSON 会写入这里;run state / checkpoint 仍保留在各自状态目录,方便 pause/resume/cancel。
236
261
  - `llmThinkingLevel`:默认 `low`。可设为 `off/minimal/low/medium/high/auto/current`,用于控制 OpenAI-compatible LLM 的 thinking/reasoning 强度。
@@ -243,7 +268,7 @@ npm 包安装后可直接使用可执行命令 `boss-recommend-mcp`。以下示
243
268
  ```bash
244
269
  node src/cli.js install --agent trae-cn
245
270
  node src/cli.js init-config
246
- node src/cli.js config set --base-url https://api.openai.com/v1 --api-key <your-key> --model gpt-4o-mini --thinking-level off
271
+ node src/cli.js config set --base-url https://api.openai.com/v1 --api-key <your-key> --model gpt-4o-mini --thinking-level off --greeting-message "您好,方便发下简历吗?"
247
272
  node src/cli.js set-port --port 9222
248
273
  node src/cli.js doctor --agent trae-cn
249
274
  node src/cli.js launch-chrome --port 9222
@@ -283,7 +308,7 @@ node src/cli.js chat prepare-run --slow-live --port 9222
283
308
  - `profile` 可选,默认 `default`
284
309
  - `job` 与 `port` 继承 recommend run 已选岗位和调试端口
285
310
  - `baseUrl` / `apiKey` / `model` 不再单独传入,固定复用 recommend 的 `screening-config.json`
286
- - `greeting_text` 默认优先级:本次显式值 > profile 历史值 > 内置默认招呼语(`Hi同学,能麻烦发下简历吗?`)
311
+ - `greeting_text` 默认优先级:本次显式值 > `screening-config.json.greetingMessage` > 内置默认招呼语(`Hi同学,能麻烦发下简历吗?`)
287
312
  - 若缺少 `follow_up.chat` 必填项,pipeline 会返回 `NEED_INPUT`
288
313
  - 如需聊天页筛选,请调用 `prepare_boss_chat_run` 获取岗位列表,再调用 `start_boss_chat_run`。
289
314
  - `boss-chat` 状态统一写入 `~/.boss-recommend-mcp/boss-chat`(或 `BOSS_CHAT_HOME` 指定目录),不再依赖工作区 `cwd`
@@ -313,7 +338,7 @@ chat-only 交互建议:
313
338
 
314
339
  - 先调用一次 `prepare_boss_chat_run`(可不带参数),服务会先导航到 `https://www.zhipin.com/web/chat/index` 并返回 `NEED_INPUT`,其中包含岗位 `job_options` 与待补字段。
315
340
  - 然后基于 `job_options` 让用户选择 `job`,并补齐 `start_from`、`target_count`、`criteria` 后调用 `start_boss_chat_run` 启动任务。
316
- - `greeting_text` 可选;未传时会自动沿用 profile 上次输入,若无历史值则使用默认招呼语(`Hi同学,能麻烦发下简历吗?`)。
341
+ - `greeting_text` 可选;未传时使用 `screening-config.json.greetingMessage`,若未配置则使用默认招呼语(`Hi同学,能麻烦发下简历吗?`)。
317
342
  - `target_count` 支持正整数、`all`、`-1`;若用户给出 `全部候选人` / `所有候选人`,会自动按不限(扫到底)处理。
318
343
 
319
344
  Trae-CN / 长对话防循环建议:
@@ -1,7 +1,9 @@
1
1
  {
2
- "baseUrl": "https://api.openai.com/v1",
3
- "apiKey": "replace-with-openai-api-key",
2
+ "baseUrl": "https://api.openai.com/v1",
3
+ "apiKey": "replace-with-openai-api-key",
4
4
  "model": "gpt-4.1-mini",
5
+ "llmModels": [],
6
+ "greetingMessage": "Hi同学,能麻烦发下简历吗?",
5
7
  "llmThinkingLevel": "low",
6
8
  "llmTimeoutMs": 60000,
7
9
  "llmMaxTokens": 512,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.30",
3
+ "version": "2.0.32",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -24,6 +24,7 @@
24
24
  "test:run-state": "node src/test-run-state.js",
25
25
  "test:cdp-browser": "node src/test-cdp-browser.js",
26
26
  "test:core-capture": "node src/test-core-capture.js",
27
+ "test:core-cv-capture-target": "node src/test-core-cv-capture-target.js",
27
28
  "test:core-cv-acquisition": "node src/test-core-cv-acquisition.js",
28
29
  "test:core-greet-quota": "node src/test-core-greet-quota.js",
29
30
  "test:core-infinite-list": "node src/test-core-infinite-list.js",
@@ -72,6 +73,7 @@
72
73
  "live:chat-domain": "node scripts/live-chat-domain-smoke.js",
73
74
  "live:chat-run-service": "node scripts/live-chat-run-service-smoke.js",
74
75
  "live:chat-mcp": "node scripts/live-chat-mcp-smoke.js",
76
+ "live:cv-capture-target": "node scripts/live-cv-capture-target-smoke.js",
75
77
  "live:chat-phase10-full": "node scripts/live-chat-phase10-full.js",
76
78
  "live:chat-image-screening": "node scripts/live-chat-image-screening-smoke.js"
77
79
  },
package/src/chat-mcp.js CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  resolveBossChatRuntimeLayout,
51
51
  resolveBossScreeningConfig
52
52
  } from "./chat-runtime-config.js";
53
+ import { DEFAULT_MAX_IMAGE_PAGES } from "./core/cv-acquisition/index.js";
53
54
 
54
55
  const DEFAULT_CHAT_HOST = "127.0.0.1";
55
56
  const DEFAULT_CHAT_PORT = 9222;
@@ -874,12 +875,14 @@ async function readChatJobOptionsFromSession(session) {
874
875
 
875
876
  function normalizeChatStartInput(args = {}, configResolution = null) {
876
877
  const target = normalizeTargetCountInput(getBossChatTargetCountValue(args));
878
+ const explicitGreetingText = normalizeText(args.greeting_text || args.greetingText || args.greeting);
879
+ const configuredGreetingText = normalizeText(configResolution?.config?.greetingMessage || configResolution?.config?.greetingText);
877
880
  return {
878
881
  profile: normalizeText(args.profile) || "default",
879
882
  job: normalizeText(args.job),
880
883
  startFrom: normalizeText(args.start_from).toLowerCase(),
881
884
  criteria: normalizeText(args.criteria),
882
- greetingText: normalizeText(args.greeting_text || args.greetingText || args.greeting),
885
+ greetingText: explicitGreetingText || configuredGreetingText,
883
886
  target,
884
887
  targetCount: target.targetCount,
885
888
  publicTargetCount: target.publicValue,
@@ -1098,7 +1101,7 @@ function getRunOptions(args, normalized, session, { workspaceRoot = "", configRe
1098
1101
  slowLive ? 30000 : 15000
1099
1102
  ),
1100
1103
  resumeDomTimeoutMs: slowLive ? 120000 : 60000,
1101
- maxImagePages: parsePositiveInteger(args.max_image_pages, 8),
1104
+ maxImagePages: parsePositiveInteger(args.max_image_pages, DEFAULT_MAX_IMAGE_PAGES),
1102
1105
  imageWheelDeltaY: parsePositiveInteger(args.image_wheel_delta_y, 650),
1103
1106
  llmConfig: resolvedConfig.ok ? {
1104
1107
  ...resolvedConfig.config
@@ -243,6 +243,55 @@ function normalizeLlmThinkingLevel(raw, fallback = "low") {
243
243
  return LLM_THINKING_LEVELS.has(normalized) ? normalized : fallback;
244
244
  }
245
245
 
246
+ function firstConfiguredValue(...values) {
247
+ for (const value of values) {
248
+ if (value === undefined || value === null) continue;
249
+ if (typeof value === "string" && !value.trim()) continue;
250
+ return value;
251
+ }
252
+ return "";
253
+ }
254
+
255
+ function resolveRawLlmModelEntries(config = {}) {
256
+ if (Array.isArray(config.llmModels) && config.llmModels.length > 0) return config.llmModels;
257
+ if (Array.isArray(config.models) && config.models.length > 0) return config.models;
258
+ return [config];
259
+ }
260
+
261
+ function normalizeScreeningLlmModel(config = {}, rawEntry = {}, index = 0) {
262
+ const entry = typeof rawEntry === "string"
263
+ ? { model: rawEntry }
264
+ : (rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {});
265
+ return {
266
+ name: normalizeText(firstConfiguredValue(entry.name, entry.label, entry.id, entry.providerName, entry.provider)),
267
+ baseUrl: normalizeText(firstConfiguredValue(entry.baseUrl, entry.base_url, config.baseUrl, config.base_url)).replace(/\/+$/, ""),
268
+ apiKey: normalizeText(firstConfiguredValue(entry.apiKey, entry.api_key, config.apiKey, config.api_key)),
269
+ model: normalizeText(firstConfiguredValue(entry.model, entry.modelName, entry.model_name, typeof rawEntry === "string" ? rawEntry : "", config.model)),
270
+ openaiOrganization: normalizeText(firstConfiguredValue(entry.openaiOrganization, entry.organization, config.openaiOrganization, config.organization)),
271
+ openaiProject: normalizeText(firstConfiguredValue(entry.openaiProject, entry.project, config.openaiProject, config.project)),
272
+ llmThinkingLevel: normalizeLlmThinkingLevel(
273
+ firstConfiguredValue(entry.llmThinkingLevel, entry.thinkingLevel, entry.reasoningEffort, config.llmThinkingLevel, config.thinkingLevel, config.reasoningEffort),
274
+ "low"
275
+ ),
276
+ llmTimeoutMs: parsePositiveInteger(firstConfiguredValue(entry.llmTimeoutMs, entry.timeoutMs, config.llmTimeoutMs, config.timeoutMs), null),
277
+ llmMaxRetries: parsePositiveInteger(firstConfiguredValue(entry.llmMaxRetries, entry.maxRetries, config.llmMaxRetries, config.maxRetries), null),
278
+ llmMaxTokens: parsePositiveInteger(firstConfiguredValue(entry.llmMaxTokens, entry.maxTokens, config.llmMaxTokens, config.maxTokens), null),
279
+ llmMaxCompletionTokens: parsePositiveInteger(
280
+ firstConfiguredValue(entry.llmMaxCompletionTokens, entry.maxCompletionTokens, config.llmMaxCompletionTokens, config.maxCompletionTokens),
281
+ null
282
+ ),
283
+ llmImageLimit: parsePositiveInteger(firstConfiguredValue(entry.llmImageLimit, entry.imageLimit, config.llmImageLimit, config.imageLimit), null),
284
+ llmImageDetail: normalizeText(firstConfiguredValue(entry.llmImageDetail, entry.imageDetail, config.llmImageDetail, config.imageDetail)),
285
+ temperature: parseConfigNumber(firstConfiguredValue(entry.temperature, config.temperature), null),
286
+ topP: parseConfigNumber(firstConfiguredValue(entry.topP, entry.top_p, config.topP, config.top_p), null),
287
+ llmProviderIndex: index
288
+ };
289
+ }
290
+
291
+ function normalizeScreeningLlmModels(config = {}) {
292
+ return resolveRawLlmModelEntries(config).map((entry, index) => normalizeScreeningLlmModel(config, entry, index));
293
+ }
294
+
246
295
  function resolveConfigPathValue(raw, configDir) {
247
296
  const normalized = normalizeText(raw);
248
297
  if (!normalized) return "";
@@ -259,13 +308,14 @@ function validateScreeningConfig(config) {
259
308
  message: "screening-config.json 缺失或格式无效。请填写 baseUrl、apiKey、model。"
260
309
  };
261
310
  }
262
- const baseUrl = normalizeText(config.baseUrl).replace(/\/+$/, "");
263
- const apiKey = normalizeText(config.apiKey);
264
- const model = normalizeText(config.model);
311
+ const llmModels = normalizeScreeningLlmModels(config);
265
312
  const missing = [];
266
- if (!baseUrl) missing.push("baseUrl");
267
- if (!apiKey) missing.push("apiKey");
268
- if (!model) missing.push("model");
313
+ for (const [index, llmModel] of llmModels.entries()) {
314
+ const prefix = llmModels.length > 1 ? `llmModels[${index}]` : "";
315
+ if (!llmModel.baseUrl) missing.push(prefix ? `${prefix}.baseUrl` : "baseUrl");
316
+ if (!llmModel.apiKey) missing.push(prefix ? `${prefix}.apiKey` : "apiKey");
317
+ if (!llmModel.model) missing.push(prefix ? `${prefix}.model` : "model");
318
+ }
269
319
  if (missing.length > 0) {
270
320
  return {
271
321
  ok: false,
@@ -273,17 +323,20 @@ function validateScreeningConfig(config) {
273
323
  message: `screening-config.json 缺少必填字段:${missing.join(", ")}。`
274
324
  };
275
325
  }
276
- if (/^replace-with/i.test(apiKey) || apiKey === SCREEN_CONFIG_TEMPLATE_DEFAULTS.apiKey) {
326
+ const placeholderModel = llmModels.find((item) => /^replace-with/i.test(item.apiKey) || item.apiKey === SCREEN_CONFIG_TEMPLATE_DEFAULTS.apiKey);
327
+ if (placeholderModel) {
277
328
  return {
278
329
  ok: false,
279
330
  reason: "PLACEHOLDER_API_KEY",
280
331
  message: "screening-config.json 的 apiKey 仍是模板占位符,请填写真实 API Key。"
281
332
  };
282
333
  }
334
+ const firstModel = llmModels[0] || {};
283
335
  if (
284
- baseUrl === SCREEN_CONFIG_TEMPLATE_DEFAULTS.baseUrl
285
- && apiKey === SCREEN_CONFIG_TEMPLATE_DEFAULTS.apiKey
286
- && model === SCREEN_CONFIG_TEMPLATE_DEFAULTS.model
336
+ llmModels.length === 1
337
+ && firstModel.baseUrl === SCREEN_CONFIG_TEMPLATE_DEFAULTS.baseUrl
338
+ && firstModel.apiKey === SCREEN_CONFIG_TEMPLATE_DEFAULTS.apiKey
339
+ && firstModel.model === SCREEN_CONFIG_TEMPLATE_DEFAULTS.model
287
340
  ) {
288
341
  return {
289
342
  ok: false,
@@ -393,24 +446,28 @@ export function resolveBossScreeningConfig(workspaceRoot) {
393
446
  candidate_paths: candidatePaths
394
447
  };
395
448
  }
449
+ const llmModels = normalizeScreeningLlmModels(parsed);
450
+ const primaryLlmModel = llmModels[0] || {};
451
+ const greetingText = normalizeText(
452
+ parsed.greetingMessage
453
+ || parsed.greeting_message
454
+ || parsed.greetingText
455
+ || parsed.greeting_text
456
+ || parsed.greeting
457
+ );
396
458
  return {
397
459
  ok: true,
398
460
  config: {
399
- baseUrl: normalizeText(parsed.baseUrl).replace(/\/+$/, ""),
400
- apiKey: normalizeText(parsed.apiKey),
401
- model: normalizeText(parsed.model),
402
- openaiOrganization: normalizeText(parsed.openaiOrganization || parsed.organization),
403
- openaiProject: normalizeText(parsed.openaiProject || parsed.project),
461
+ ...primaryLlmModel,
462
+ baseUrl: primaryLlmModel.baseUrl,
463
+ apiKey: primaryLlmModel.apiKey,
464
+ model: primaryLlmModel.model,
465
+ openaiOrganization: primaryLlmModel.openaiOrganization,
466
+ openaiProject: primaryLlmModel.openaiProject,
467
+ llmModels,
468
+ greetingMessage: greetingText,
469
+ greetingText,
404
470
  debugPort: parsePositiveInteger(parsed.debugPort, 9222),
405
- llmThinkingLevel: normalizeLlmThinkingLevel(parsed.llmThinkingLevel || parsed.thinkingLevel || parsed.reasoningEffort, "low"),
406
- llmTimeoutMs: parsePositiveInteger(parsed.llmTimeoutMs || parsed.timeoutMs, null),
407
- llmMaxRetries: parsePositiveInteger(parsed.llmMaxRetries || parsed.maxRetries, null),
408
- llmMaxTokens: parsePositiveInteger(parsed.llmMaxTokens || parsed.maxTokens, null),
409
- llmMaxCompletionTokens: parsePositiveInteger(parsed.llmMaxCompletionTokens || parsed.maxCompletionTokens, null),
410
- llmImageLimit: parsePositiveInteger(parsed.llmImageLimit || parsed.imageLimit, null),
411
- llmImageDetail: normalizeText(parsed.llmImageDetail || parsed.imageDetail),
412
- temperature: parseConfigNumber(parsed.temperature, null),
413
- topP: parseConfigNumber(parsed.topP || parsed.top_p, null),
414
471
  outputDir: resolveConfigPathValue(parsed.outputDir, configDir),
415
472
  humanRestEnabled: parseConfigBoolean(parsed.humanRestEnabled, false)
416
473
  },
package/src/cli.js CHANGED
@@ -57,6 +57,7 @@ const autoSyncSkipCommands = new Set(["install", "install-skill", "where", "help
57
57
  const externalMcpTargetsEnv = "BOSS_RECOMMEND_MCP_CONFIG_TARGETS";
58
58
  const externalSkillDirsEnv = "BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS";
59
59
  const installConfigDefaults = Object.freeze({
60
+ greetingMessage: "Hi同学,能麻烦发下简历吗?",
60
61
  llmThinkingLevel: "low",
61
62
  llmMaxTokens: 512,
62
63
  llmMaxRetries: 3,
@@ -1431,6 +1432,8 @@ async function setScreeningConfig(options = {}) {
1431
1432
  apiKey,
1432
1433
  model
1433
1434
  };
1435
+ delete nextConfig.llmModels;
1436
+ delete nextConfig.models;
1434
1437
  if (typeof options["thinking-level"] === "string" && options["thinking-level"].trim()) {
1435
1438
  nextConfig.llmThinkingLevel = options["thinking-level"].trim();
1436
1439
  } else if (typeof options.llmThinkingLevel === "string" && options.llmThinkingLevel.trim()) {
@@ -1445,6 +1448,16 @@ async function setScreeningConfig(options = {}) {
1445
1448
  if (typeof options["output-dir"] === "string" && options["output-dir"].trim()) {
1446
1449
  nextConfig.outputDir = options["output-dir"].trim();
1447
1450
  }
1451
+ const greetingMessage = String(
1452
+ options["greeting-message"]
1453
+ ?? options.greetingMessage
1454
+ ?? options.greeting_text
1455
+ ?? options.greetingText
1456
+ ?? ""
1457
+ ).trim();
1458
+ if (greetingMessage) {
1459
+ nextConfig.greetingMessage = greetingMessage;
1460
+ }
1448
1461
  const debugPort = parsePositivePort(options.port || options["debug-port"]);
1449
1462
  if (debugPort) {
1450
1463
  nextConfig.debugPort = debugPort;
@@ -2538,7 +2551,7 @@ function printHelp() {
2538
2551
  console.log(" boss-recommend-mcp run --detached --instruction \"...\" --overrides-file overrides.json --confirmation-file confirmation.json");
2539
2552
  console.log(" boss-recommend-mcp list-jobs --slow-live --port 9222");
2540
2553
  console.log(" boss-recommend-mcp chat prepare-run --slow-live --port 9222 # CDP-only preflight; start runs through MCP start_boss_chat_run");
2541
- console.log(" boss-recommend-mcp config set --base-url <url> --api-key <key> --model <model> [--thinking-level off|low|medium|high|current] [--openai-organization <id>] [--openai-project <id>]");
2554
+ console.log(" boss-recommend-mcp config set --base-url <url> --api-key <key> --model <model> [--thinking-level off|low|medium|high|current] [--greeting-message <text>] [--openai-organization <id>] [--openai-project <id>]");
2542
2555
  console.log(" boss-recommend-mcp install --agent trae-cn");
2543
2556
  console.log(" boss-recommend-mcp install --agent qclaw # updates ~/.qclaw/openclaw.json mcp.servers and mirrors skills");
2544
2557
  console.log(" boss-recommend-mcp doctor --agent trae-cn --page-scope featured");
@@ -5,6 +5,7 @@ export const CV_ACQUISITION_MODE_IMAGE = "image";
5
5
  export const NETWORK_RESUME_WAIT_MS = 4200;
6
6
  export const NETWORK_RESUME_RETRY_WAIT_MS = 2000;
7
7
  export const NETWORK_RESUME_IMAGE_MODE_GRACE_MS = 1000;
8
+ export const DEFAULT_MAX_IMAGE_PAGES = 24;
8
9
 
9
10
  const VALID_MODES = new Set([
10
11
  CV_ACQUISITION_MODE_UNKNOWN,
@@ -0,0 +1,299 @@
1
+ import {
2
+ getFrameDocumentNodeId,
3
+ getNodeBox,
4
+ querySelector,
5
+ querySelectorAll,
6
+ sleep
7
+ } from "../browser/index.js";
8
+
9
+ export const CV_CAPTURE_TARGET_SELECTORS = Object.freeze([
10
+ ".resume-center-side .resume-detail-wrap",
11
+ ".resume-container .resume-detail-wrap",
12
+ ".resume-container .resume-content-wrap",
13
+ ".resume-item-detail",
14
+ ".resume-detail-wrap",
15
+ ".resume-content-wrap",
16
+ ".resume-common-wrap",
17
+ ".new-resume-online-main-ui",
18
+ ".resume-detail",
19
+ ".resume-recommend",
20
+ "canvas#resume",
21
+ ".resume-container"
22
+ ]);
23
+
24
+ const IFRAME_BODY_SELECTORS = Object.freeze(["body", "html"]);
25
+
26
+ function slotNodeId(slot = null) {
27
+ return Number(slot?.node_id || slot?.nodeId || 0) || 0;
28
+ }
29
+
30
+ function rootNodeId(root = null) {
31
+ return Number(root?.nodeId || root?.node_id || root?.root_node_id || 0) || 0;
32
+ }
33
+
34
+ function normalizeRootName(root = null, fallback = "") {
35
+ return String(root?.name || root?.root || fallback || "").trim() || null;
36
+ }
37
+
38
+ function uniqueRoots(roots = []) {
39
+ const seen = new Set();
40
+ const result = [];
41
+ for (const root of roots) {
42
+ const nodeId = rootNodeId(root);
43
+ if (!nodeId || seen.has(nodeId)) continue;
44
+ seen.add(nodeId);
45
+ result.push({
46
+ ...root,
47
+ name: normalizeRootName(root),
48
+ nodeId
49
+ });
50
+ }
51
+ return result;
52
+ }
53
+
54
+ function slotAsRoot(slot = null, fallbackName = "") {
55
+ const nodeId = slotNodeId(slot);
56
+ if (!nodeId) return null;
57
+ return {
58
+ name: normalizeRootName(slot, fallbackName),
59
+ nodeId,
60
+ selector: slot?.selector || null,
61
+ root_node_id: slot?.root_node_id || null
62
+ };
63
+ }
64
+
65
+ function isVisibleBox(box = null) {
66
+ return box?.rect?.width > 2 && box?.rect?.height > 2;
67
+ }
68
+
69
+ async function readVisibleBox(client, nodeId) {
70
+ if (!nodeId) return null;
71
+ try {
72
+ const box = await getNodeBox(client, nodeId);
73
+ return isVisibleBox(box) ? box : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function isCvScopedSelector(selector = "") {
80
+ const normalized = String(selector || "").trim();
81
+ if (!normalized) return false;
82
+ if (/boss-popup|boss-dialog|dialog-wrap|geek-detail-modal|\bmodal\b|new-chat-resume-dialog-main-ui/i.test(normalized)) {
83
+ return false;
84
+ }
85
+ return /resume-item-detail|resume-detail-wrap|resume-content-wrap|resume-common-wrap|new-resume-online-main-ui|resume-detail(?:\b|[.#:])|resume-recommend|canvas#resume|resume-container/i.test(normalized);
86
+ }
87
+
88
+ function buildTarget({
89
+ domain = "",
90
+ nodeId,
91
+ source,
92
+ selector = null,
93
+ root = null,
94
+ rootNodeId = null,
95
+ box = null,
96
+ iframeNodeId = null,
97
+ iframeDocumentNodeId = null,
98
+ fallback = false
99
+ } = {}) {
100
+ return {
101
+ schema_version: 1,
102
+ domain: domain || null,
103
+ node_id: nodeId,
104
+ source,
105
+ selector,
106
+ root,
107
+ root_node_id: rootNodeId || null,
108
+ iframe_node_id: iframeNodeId || null,
109
+ iframe_document_node_id: iframeDocumentNodeId || null,
110
+ cv_only: !fallback,
111
+ fallback: Boolean(fallback),
112
+ rect: box?.rect || null,
113
+ center: box?.center || null
114
+ };
115
+ }
116
+
117
+ async function firstVisibleSelectorTarget(client, roots = [], selectors = CV_CAPTURE_TARGET_SELECTORS, {
118
+ domain = "",
119
+ source = "cv_selector",
120
+ iframeNodeId = null,
121
+ iframeDocumentNodeId = null
122
+ } = {}) {
123
+ for (const root of uniqueRoots(roots)) {
124
+ for (const selector of selectors) {
125
+ let nodeIds = [];
126
+ try {
127
+ nodeIds = await querySelectorAll(client, root.nodeId, selector);
128
+ } catch {
129
+ nodeIds = [];
130
+ }
131
+ for (const nodeId of nodeIds) {
132
+ const box = await readVisibleBox(client, nodeId);
133
+ if (!box) continue;
134
+ return buildTarget({
135
+ domain,
136
+ nodeId,
137
+ source,
138
+ selector,
139
+ root: root.name,
140
+ rootNodeId: root.nodeId,
141
+ box,
142
+ iframeNodeId,
143
+ iframeDocumentNodeId
144
+ });
145
+ }
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ async function visibleSlotTarget(client, slot = null, {
152
+ domain = "",
153
+ source = "cv_slot",
154
+ fallback = false
155
+ } = {}) {
156
+ const nodeId = slotNodeId(slot);
157
+ if (!nodeId) return null;
158
+ if (!fallback && !isCvScopedSelector(slot?.selector)) return null;
159
+ const box = await readVisibleBox(client, nodeId);
160
+ if (!box) return null;
161
+ return buildTarget({
162
+ domain,
163
+ nodeId,
164
+ source,
165
+ selector: slot?.selector || null,
166
+ root: slot?.root || null,
167
+ rootNodeId: slot?.root_node_id || null,
168
+ box,
169
+ fallback
170
+ });
171
+ }
172
+
173
+ async function resolveIframeCaptureTarget(client, resumeIframe = null, {
174
+ domain = "",
175
+ selectors = CV_CAPTURE_TARGET_SELECTORS
176
+ } = {}) {
177
+ const iframeNodeId = slotNodeId(resumeIframe);
178
+ if (!iframeNodeId) return null;
179
+
180
+ try {
181
+ const documentNodeId = await getFrameDocumentNodeId(client, iframeNodeId);
182
+ const selectorTarget = await firstVisibleSelectorTarget(client, [{
183
+ name: "resume-iframe-document",
184
+ nodeId: documentNodeId
185
+ }], selectors, {
186
+ domain,
187
+ source: "resume_iframe_cv_selector",
188
+ iframeNodeId,
189
+ iframeDocumentNodeId: documentNodeId
190
+ });
191
+ if (selectorTarget) return selectorTarget;
192
+
193
+ for (const selector of IFRAME_BODY_SELECTORS) {
194
+ const nodeId = await querySelector(client, documentNodeId, selector).catch(() => 0);
195
+ const box = await readVisibleBox(client, nodeId);
196
+ if (!box) continue;
197
+ return buildTarget({
198
+ domain,
199
+ nodeId,
200
+ source: "resume_iframe_body",
201
+ selector,
202
+ root: "resume-iframe-document",
203
+ rootNodeId: documentNodeId,
204
+ box,
205
+ iframeNodeId,
206
+ iframeDocumentNodeId: documentNodeId
207
+ });
208
+ }
209
+ } catch {}
210
+
211
+ const iframeBox = await readVisibleBox(client, iframeNodeId);
212
+ if (!iframeBox) return null;
213
+ return buildTarget({
214
+ domain,
215
+ nodeId: iframeNodeId,
216
+ source: "resume_iframe_element",
217
+ selector: resumeIframe?.selector || null,
218
+ root: resumeIframe?.root || null,
219
+ rootNodeId: resumeIframe?.root_node_id || null,
220
+ box: iframeBox,
221
+ iframeNodeId,
222
+ fallback: false
223
+ });
224
+ }
225
+
226
+ async function resolveSlotCaptureTarget(client, slot = null, {
227
+ domain = "",
228
+ slotName = "content",
229
+ selectors = CV_CAPTURE_TARGET_SELECTORS
230
+ } = {}) {
231
+ const root = slotAsRoot(slot, slotName);
232
+ const selectorTarget = root
233
+ ? await firstVisibleSelectorTarget(client, [root], selectors, {
234
+ domain,
235
+ source: `${slotName}_cv_selector`
236
+ })
237
+ : null;
238
+ if (selectorTarget) return selectorTarget;
239
+ return visibleSlotTarget(client, slot, {
240
+ domain,
241
+ source: `${slotName}_cv_slot`
242
+ });
243
+ }
244
+
245
+ export async function resolveCvCaptureTarget(client, detailState = null, {
246
+ domain = "",
247
+ selectors = CV_CAPTURE_TARGET_SELECTORS
248
+ } = {}) {
249
+ const iframeTarget = await resolveIframeCaptureTarget(client, detailState?.resumeIframe, {
250
+ domain,
251
+ selectors
252
+ });
253
+ if (iframeTarget) return iframeTarget;
254
+
255
+ const contentTarget = await resolveSlotCaptureTarget(client, detailState?.content, {
256
+ domain,
257
+ slotName: "content",
258
+ selectors
259
+ });
260
+ if (contentTarget) return contentTarget;
261
+
262
+ const popupTarget = await resolveSlotCaptureTarget(client, detailState?.popup, {
263
+ domain,
264
+ slotName: "popup",
265
+ selectors
266
+ });
267
+ if (popupTarget) return popupTarget;
268
+
269
+ return firstVisibleSelectorTarget(client, detailState?.roots || [], selectors, {
270
+ domain,
271
+ source: "root_cv_selector"
272
+ });
273
+ }
274
+
275
+ export async function waitForCvCaptureTarget(client, detailState = null, {
276
+ timeoutMs = 6000,
277
+ intervalMs = 250,
278
+ ...options
279
+ } = {}) {
280
+ const started = Date.now();
281
+ let target = null;
282
+ while (Date.now() - started <= Math.max(0, Number(timeoutMs) || 0)) {
283
+ target = await resolveCvCaptureTarget(client, detailState, options);
284
+ if (target?.node_id) {
285
+ return {
286
+ ok: true,
287
+ elapsed_ms: Date.now() - started,
288
+ target
289
+ };
290
+ }
291
+ await sleep(Math.max(50, Number(intervalMs) || 250));
292
+ }
293
+ target = await resolveCvCaptureTarget(client, detailState, options);
294
+ return {
295
+ ok: Boolean(target?.node_id),
296
+ elapsed_ms: Date.now() - started,
297
+ target: target || null
298
+ };
299
+ }