@reconcrap/boss-recommend-mcp 2.0.25 → 2.0.27

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
@@ -132,9 +132,9 @@ node src/cli.js start
132
132
 
133
133
  ### 迁移 legacy MCP / skills
134
134
 
135
- 全局 npm 安装会自动运行 `boss-recommend-mcp install`。该安装器会在 Windows 和 macOS 上自动检测 Trae / Trae CN / OpenClaw 的常见配置目录:
135
+ 全局 npm 安装会自动运行 `boss-recommend-mcp install`。该安装器会在 Windows 和 macOS 上自动检测 Trae / Trae CN / OpenClaw / QClaw 的常见配置目录:
136
136
 
137
- - Windows: `%APPDATA%\Trae*\User\mcp.json`、`%USERPROFILE%\.trae*\mcp.json`、`%USERPROFILE%\.openclaw\mcp.json`、`%APPDATA%\OpenClaw\User\mcp.json`
137
+ - Windows: `%APPDATA%\Trae*\User\mcp.json`、`%USERPROFILE%\.trae*\mcp.json`、`%USERPROFILE%\.openclaw\mcp.json`、`%APPDATA%\OpenClaw\User\mcp.json`、`%USERPROFILE%\.qclaw\openclaw.json`
138
138
  - macOS: `~/Library/Application Support/Trae*/User/mcp.json`、`~/.trae*/mcp.json`、`~/.openclaw/mcp.json`、`~/Library/Application Support/OpenClaw/User/mcp.json`
139
139
 
140
140
  如果检测到 legacy Boss server entries,installer 会:
@@ -150,8 +150,10 @@ node src/cli.js start
150
150
  ```bash
151
151
  boss-recommend-mcp install --agent trae-cn
152
152
  boss-recommend-mcp install --agent openclaw
153
+ boss-recommend-mcp install --agent qclaw
153
154
  boss-recommend-mcp doctor --agent trae-cn
154
155
  boss-recommend-mcp doctor --agent openclaw
156
+ boss-recommend-mcp doctor --agent qclaw
155
157
  ```
156
158
 
157
159
  自定义路径:
@@ -171,7 +173,13 @@ BOSS_RECOMMEND_MCP_CONFIG_TARGETS # JSON 数组或系统 path 分隔路径列
171
173
  BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS # JSON 数组或系统 path 分隔路径列表,指定额外 skills 根目录
172
174
  ```
173
175
 
174
- 推荐运行入口是 MCP 工具。旧版 one-shot recommend CLI `run` 在 CDP-only 重写期间已 fenced;请使用 `start_recommend_pipeline_run` live harness。岗位发现可以使用新的只读 CLI
176
+ 推荐运行入口是 MCP 工具 `start_recommend_pipeline_run`。如果宿主是 QClaw 这类 shell-only agent,没有把 MCP tools 暴露给模型,可以使用 CDP-only CLI fallback
177
+
178
+ ```bash
179
+ npx -y @reconcrap/boss-recommend-mcp@latest run --detached --instruction-file boss-recommend-instruction.txt --overrides-file boss-recommend-overrides.json --confirmation-file boss-recommend-confirmation.json --slow-live --port 9222
180
+ ```
181
+
182
+ `--detached` 会让父进程输出 `ACCEPTED + run_id` 后退出,子进程继续持有 Chrome DevTools 会话并执行长任务。岗位发现可以使用只读 CLI:
175
183
 
176
184
  ```bash
177
185
  npx -y @reconcrap/boss-recommend-mcp@latest list-jobs --slow-live --port 9222
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.25",
3
+ "version": "2.0.27",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -139,11 +139,29 @@ description: "Use when users want Boss recommend-page filtering/screening via bo
139
139
  - `featured` 且缺少校准文件:先 `boss-recommend-mcp calibrate`。
140
140
  - `latest`:流程同 `recommend`,但使用最新 tab 结构(`data-status=1`)。
141
141
 
142
- ## Fallback CLI
142
+ ## QClaw / Shell-Only Fallback
143
143
 
144
- MCP 不可用时:
144
+ 当 QClaw/OpenClaw 变体只暴露 shell、没有原生 MCP tool list 时,禁止停在“请使用 `start_recommend_pipeline_run` 工具”。必须用 shell 继续启动。
145
145
 
146
- `npx -y @reconcrap/boss-recommend-mcp@latest run --instruction "..." [--confirmation-json '{...}'] [--overrides-json '{...}']`
146
+ 推荐做法:
147
+
148
+ 1. 将锁定的用户原文写入 instruction 文件,将已确认参数写入 `overrides` 与 `confirmation` JSON 文件。
149
+ 2. 用 detached CLI 启动,让父命令返回启动证据,子进程继续持有 CDP 会话:
150
+
151
+ ```powershell
152
+ npx -y @reconcrap/boss-recommend-mcp@latest run --detached --instruction-file .\boss-recommend-instruction.txt --overrides-file .\boss-recommend-overrides.json --confirmation-file .\boss-recommend-confirmation.json --slow-live --port 9222
153
+ ```
154
+
155
+ 3. 若返回 `ACCEPTED + run_id`,即任务已启动;记录 `run_id/stdout_path/stderr_path`。若返回 `NEED_INPUT` 或 `NEED_CONFIRMATION`,只补 `pending_questions`,不要重写已锁定的用户原文。
156
+
157
+ 兼容路径:
158
+
159
+ - 若 `--detached` 不可用,或返回 `RECOMMEND_CLI_RUN_UNSUPPORTED_CDP_ONLY`,说明 npm/QClaw 仍在使用旧包;先运行 `npx -y @reconcrap/boss-recommend-mcp@latest install --agent qclaw` 并重启 QClaw。
160
+ - 在包更新未生效时,可以使用已验证过的直接 MCP stdio JSON-RPC 方式调用 `start_recommend_pipeline_run`;该方式等价于原生 MCP tool 调用,不能改用 recruit/search 路径。
161
+
162
+ 普通 MCP 可用时:
163
+
164
+ `start_recommend_pipeline_run` 仍是首选。
147
165
 
148
166
  禁止错误回退:
149
167
 
package/src/cli.js CHANGED
@@ -23,7 +23,10 @@ import {
23
23
  prepareBossChatRunTool,
24
24
  resumeBossChatRunTool
25
25
  } from "./chat-mcp.js";
26
- import { listRecommendJobsTool } from "./recommend-mcp.js";
26
+ import {
27
+ listRecommendJobsTool,
28
+ startRecommendPipelineRunTool
29
+ } from "./recommend-mcp.js";
27
30
  import {
28
31
  getBossScreenConfigResolution,
29
32
  resolveBossChatRuntimeLayout as resolveCdpBossChatRuntimeLayout,
@@ -45,7 +48,7 @@ const bossLoginUrl = "https://www.zhipin.com/web/user/?ka=bticket";
45
48
  const chromeOnboardingUrlPattern = /^chrome:\/\/(welcome|intro|newtab|signin|history-sync|settings\/syncSetup)/i;
46
49
  const bossLoginUrlPattern = /(?:zhipin\.com\/web\/user(?:\/|\?|$)|passport\.zhipin\.com)/i;
47
50
  const bossLoginTitlePattern = /登录|signin|扫码登录|BOSS直聘登录/i;
48
- const supportedMcpClients = ["generic", "cursor", "trae", "claudecode", "openclaw"];
51
+ const supportedMcpClients = ["generic", "cursor", "trae", "claudecode", "openclaw", "qclaw"];
49
52
  const defaultMcpServerName = "boss-recommend";
50
53
  const defaultMcpCommand = "npx";
51
54
  const recommendMcpPackageName = "@reconcrap/boss-recommend-mcp";
@@ -64,7 +67,7 @@ const installConfigDefaults = Object.freeze({
64
67
  const bossChatRuntimeChildDirs = ["logs", "runs", "profiles", "reports", "artifacts", "state"];
65
68
  const bossChatCliUnsupportedStartCode = "CHAT_CLI_ASYNC_UNSUPPORTED_CDP_ONLY";
66
69
  const calibrateUnsupportedCode = "CALIBRATE_UNSUPPORTED_CDP_ONLY";
67
- const recommendCliRunUnsupportedCode = "RECOMMEND_CLI_RUN_UNSUPPORTED_CDP_ONLY";
70
+ const detachedRecommendRunChildEnv = "BOSS_RECOMMEND_DETACHED_RUN_CHILD";
68
71
 
69
72
  function getSkillSourceDir(name = skillName) {
70
73
  return path.join(packageRoot, "skills", name);
@@ -673,22 +676,25 @@ function getRunInstruction(options) {
673
676
  }
674
677
 
675
678
  function getRunConfirmation(options) {
676
- if (typeof options["confirmation-file"] === "string" && options["confirmation-file"].trim()) {
677
- return parseJsonOption(readTextFile(options["confirmation-file"], "confirmation"), "confirmation");
679
+ const filePath = options["confirmation-file"] || options["confirmation-json-path"];
680
+ if (typeof filePath === "string" && filePath.trim()) {
681
+ return parseJsonOption(readTextFile(filePath, "confirmation"), "confirmation");
678
682
  }
679
683
  return parseJsonOption(options["confirmation-json"], "confirmation");
680
684
  }
681
685
 
682
686
  function getRunOverrides(options) {
683
- if (typeof options["overrides-file"] === "string" && options["overrides-file"].trim()) {
684
- return parseJsonOption(readTextFile(options["overrides-file"], "overrides"), "overrides");
687
+ const filePath = options["overrides-file"] || options["overrides-json-path"];
688
+ if (typeof filePath === "string" && filePath.trim()) {
689
+ return parseJsonOption(readTextFile(filePath, "overrides"), "overrides");
685
690
  }
686
691
  return parseJsonOption(options["overrides-json"], "overrides");
687
692
  }
688
693
 
689
694
  function getRunFollowUp(options) {
690
- if (typeof options["follow-up-file"] === "string" && options["follow-up-file"].trim()) {
691
- return parseJsonOption(readTextFile(options["follow-up-file"], "follow_up"), "follow_up");
695
+ const filePath = options["follow-up-file"] || options["follow-up-json-path"];
696
+ if (typeof filePath === "string" && filePath.trim()) {
697
+ return parseJsonOption(readTextFile(filePath, "follow_up"), "follow_up");
692
698
  }
693
699
  return parseJsonOption(options["follow-up-json"], "follow_up");
694
700
  }
@@ -698,6 +704,7 @@ function normalizeMcpClientName(value) {
698
704
  if (!raw) return "";
699
705
  if (raw === "claude-code") return "claudecode";
700
706
  if (raw === "trae-cn") return "trae";
707
+ if (raw === "q-claw" || raw === "qclaw-win" || raw === "qclaw_win") return "qclaw";
701
708
  return raw;
702
709
  }
703
710
 
@@ -743,9 +750,19 @@ function buildMcpConfigFileContent(options = {}) {
743
750
  const serverName = typeof options["server-name"] === "string" && options["server-name"].trim()
744
751
  ? options["server-name"].trim()
745
752
  : defaultMcpServerName;
753
+ const launchConfig = buildMcpLaunchConfig(options);
754
+ if (normalizeMcpClientName(options.client) === "qclaw") {
755
+ return {
756
+ mcp: {
757
+ servers: {
758
+ [serverName]: launchConfig
759
+ }
760
+ }
761
+ };
762
+ }
746
763
  return {
747
764
  mcpServers: {
748
- [serverName]: buildMcpLaunchConfig(options)
765
+ [serverName]: launchConfig
749
766
  }
750
767
  };
751
768
  }
@@ -757,7 +774,7 @@ function writeMcpConfigFiles(options = {}) {
757
774
  const files = [];
758
775
  for (const client of clients) {
759
776
  const filePath = path.join(outputDir, `mcp.${client}.json`);
760
- fs.writeFileSync(filePath, JSON.stringify(buildMcpConfigFileContent(options), null, 2), "utf8");
777
+ fs.writeFileSync(filePath, JSON.stringify(buildMcpConfigFileContent({ ...options, client }), null, 2), "utf8");
761
778
  files.push({ client, file: filePath });
762
779
  }
763
780
  return { outputDir, files };
@@ -778,12 +795,13 @@ function parsePathListFromEnv(raw) {
778
795
  return dedupePaths(text.split(path.delimiter).map((item) => item.trim()).filter(Boolean));
779
796
  }
780
797
 
781
- const supportedExternalAgents = ["cursor", "trae", "trae-cn", "claude", "openclaw"];
798
+ const supportedExternalAgents = ["cursor", "trae", "trae-cn", "claude", "openclaw", "qclaw"];
782
799
 
783
800
  function normalizeAgentName(value) {
784
801
  const raw = String(value || "").trim().toLowerCase();
785
802
  if (!raw) return "";
786
803
  if (raw === "claude-code") return "claude";
804
+ if (raw === "q-claw" || raw === "qclaw-win" || raw === "qclaw_win") return "qclaw";
787
805
  return raw;
788
806
  }
789
807
 
@@ -849,7 +867,8 @@ function getKnownExternalMcpConfigPathsByAgent() {
849
867
  trae: [...traeConfigPaths, path.join(home, ".trae", "mcp.json"), path.join(home, ".trae-cn", "mcp.json")],
850
868
  "trae-cn": [...traeConfigPaths, path.join(home, ".trae-cn", "mcp.json"), path.join(home, ".trae", "mcp.json")],
851
869
  claude: [path.join(home, ".claude", "mcp.json")],
852
- openclaw: [path.join(home, ".openclaw", "mcp.json"), ...openClawConfigPaths]
870
+ openclaw: [path.join(home, ".openclaw", "mcp.json"), ...openClawConfigPaths],
871
+ qclaw: [path.join(home, ".qclaw", "openclaw.json")]
853
872
  };
854
873
  }
855
874
 
@@ -866,15 +885,38 @@ function resolveExternalMcpConfigTargets(options = {}) {
866
885
  return dedupePaths([...fromEnv, ...known]);
867
886
  }
868
887
 
888
+ function isQClawMcpConfigTarget(filePath, options = {}, current = null) {
889
+ if (normalizeAgentName(options.agent) === "qclaw" || normalizeMcpClientName(options.client) === "qclaw") {
890
+ return true;
891
+ }
892
+ const normalized = path.resolve(String(filePath || "")).replace(/\\/g, "/").toLowerCase();
893
+ if (normalized.endsWith("/.qclaw/openclaw.json")) {
894
+ return true;
895
+ }
896
+ return Boolean(
897
+ current?.mcp?.servers
898
+ && typeof current.mcp.servers === "object"
899
+ && !Array.isArray(current.mcp.servers)
900
+ && !current?.mcpServers
901
+ );
902
+ }
903
+
904
+ function getMcpServersFromConfig(config = {}, useQClawShape = false) {
905
+ const servers = useQClawShape ? config?.mcp?.servers : config?.mcpServers;
906
+ if (servers && typeof servers === "object" && !Array.isArray(servers)) {
907
+ return servers;
908
+ }
909
+ return {};
910
+ }
911
+
869
912
  function mergeMcpServerConfigFile(filePath, options = {}) {
870
- const nextConfig = buildMcpConfigFileContent(options);
871
- const serverName = Object.keys(nextConfig.mcpServers || {})[0] || defaultMcpServerName;
872
- const launchConfig = nextConfig.mcpServers?.[serverName] || buildMcpLaunchConfig(options);
873
913
  const current = readJsonObjectFileSafe(filePath);
874
- const existingServers =
875
- current?.mcpServers && typeof current.mcpServers === "object" && !Array.isArray(current.mcpServers)
876
- ? current.mcpServers
877
- : {};
914
+ const useQClawShape = isQClawMcpConfigTarget(filePath, options, current);
915
+ const nextConfig = buildMcpConfigFileContent({ ...options, client: useQClawShape ? "qclaw" : options.client });
916
+ const nextServers = useQClawShape ? nextConfig.mcp?.servers : nextConfig.mcpServers;
917
+ const serverName = Object.keys(nextServers || {})[0] || defaultMcpServerName;
918
+ const launchConfig = nextServers?.[serverName] || buildMcpLaunchConfig(options);
919
+ const existingServers = getMcpServersFromConfig(current, useQClawShape);
878
920
  const existingEntry = existingServers[serverName];
879
921
  const retainedServers = {};
880
922
  const migratedLegacyServers = [];
@@ -886,13 +928,24 @@ function mergeMcpServerConfigFile(filePath, options = {}) {
886
928
  }
887
929
  retainedServers[name] = config;
888
930
  }
889
- const merged = {
890
- ...current,
891
- mcpServers: {
892
- ...retainedServers,
893
- [serverName]: launchConfig
894
- }
895
- };
931
+ const merged = useQClawShape
932
+ ? {
933
+ ...current,
934
+ mcp: {
935
+ ...(current?.mcp && typeof current.mcp === "object" && !Array.isArray(current.mcp) ? current.mcp : {}),
936
+ servers: {
937
+ ...retainedServers,
938
+ [serverName]: launchConfig
939
+ }
940
+ }
941
+ }
942
+ : {
943
+ ...current,
944
+ mcpServers: {
945
+ ...retainedServers,
946
+ [serverName]: launchConfig
947
+ }
948
+ };
896
949
 
897
950
  ensureDir(path.dirname(filePath));
898
951
  const before = pathExists(filePath) ? fs.readFileSync(filePath, "utf8") : "";
@@ -907,6 +960,7 @@ function mergeMcpServerConfigFile(filePath, options = {}) {
907
960
  return {
908
961
  file: filePath,
909
962
  server: serverName,
963
+ config_shape: useQClawShape ? "qclaw" : "mcpServers",
910
964
  updated,
911
965
  migrated_legacy_servers: migratedLegacyServers,
912
966
  backup_file: backupFile
@@ -957,7 +1011,8 @@ function getKnownExternalSkillBaseDirsByAgent() {
957
1011
  trae: [path.join(home, ".trae", "skills"), path.join(home, ".trae-cn", "skills"), ...traeSkillDirs],
958
1012
  "trae-cn": [path.join(home, ".trae-cn", "skills"), path.join(home, ".trae", "skills"), ...traeSkillDirs],
959
1013
  claude: [path.join(home, ".claude", "skills")],
960
- openclaw: [path.join(home, ".openclaw", "skills"), ...openClawSkillDirs]
1014
+ openclaw: [path.join(home, ".openclaw", "skills"), ...openClawSkillDirs],
1015
+ qclaw: [path.join(home, ".qclaw", "skills")]
961
1016
  };
962
1017
  }
963
1018
 
@@ -1008,9 +1063,9 @@ function inspectMcpServerEntries(filePath) {
1008
1063
  };
1009
1064
  }
1010
1065
  const parsed = readJsonObjectFileSafe(filePath);
1011
- const servers = parsed?.mcpServers && typeof parsed.mcpServers === "object" && !Array.isArray(parsed.mcpServers)
1012
- ? parsed.mcpServers
1013
- : {};
1066
+ const rootServers = getMcpServersFromConfig(parsed, false);
1067
+ const qclawServers = getMcpServersFromConfig(parsed, true);
1068
+ const servers = { ...rootServers, ...qclawServers };
1014
1069
  const recommendNames = [];
1015
1070
  const recruitNames = [];
1016
1071
  const chatNames = [];
@@ -1402,6 +1457,177 @@ function printJson(value) {
1402
1457
  console.log(JSON.stringify(value, null, 2));
1403
1458
  }
1404
1459
 
1460
+ function stripDetachedRunArgs(args = []) {
1461
+ const booleanKeys = new Set(["--detached", "--background"]);
1462
+ const valueKeys = new Set(["--detached-start-timeout-ms"]);
1463
+ const nextArgs = [];
1464
+ for (let index = 0; index < args.length; index += 1) {
1465
+ const token = args[index];
1466
+ if (booleanKeys.has(token)) continue;
1467
+ if (valueKeys.has(token)) {
1468
+ index += 1;
1469
+ continue;
1470
+ }
1471
+ nextArgs.push(token);
1472
+ }
1473
+ return nextArgs;
1474
+ }
1475
+
1476
+ function extractFirstJsonObject(text = "") {
1477
+ const start = text.indexOf("{");
1478
+ if (start < 0) return null;
1479
+ let depth = 0;
1480
+ let inString = false;
1481
+ let escaped = false;
1482
+ for (let index = start; index < text.length; index += 1) {
1483
+ const char = text[index];
1484
+ if (inString) {
1485
+ if (escaped) {
1486
+ escaped = false;
1487
+ } else if (char === "\\") {
1488
+ escaped = true;
1489
+ } else if (char === "\"") {
1490
+ inString = false;
1491
+ }
1492
+ continue;
1493
+ }
1494
+ if (char === "\"") {
1495
+ inString = true;
1496
+ continue;
1497
+ }
1498
+ if (char === "{") depth += 1;
1499
+ if (char === "}") {
1500
+ depth -= 1;
1501
+ if (depth === 0) return text.slice(start, index + 1);
1502
+ }
1503
+ }
1504
+ return null;
1505
+ }
1506
+
1507
+ function readFirstJsonObjectFromFile(filePath) {
1508
+ try {
1509
+ const text = fs.readFileSync(filePath, "utf8");
1510
+ const jsonText = extractFirstJsonObject(text);
1511
+ return jsonText ? JSON.parse(jsonText) : null;
1512
+ } catch {
1513
+ return null;
1514
+ }
1515
+ }
1516
+
1517
+ function createDetachedRecommendRunPaths() {
1518
+ const dir = path.join(getStateHome(), "detached-runs");
1519
+ ensureDir(dir);
1520
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1521
+ const nonce = Math.random().toString(36).slice(2, 8);
1522
+ const base = `recommend-run-${stamp}-${nonce}`;
1523
+ return {
1524
+ dir,
1525
+ stdoutPath: path.join(dir, `${base}.stdout.json`),
1526
+ stderrPath: path.join(dir, `${base}.stderr.log`)
1527
+ };
1528
+ }
1529
+
1530
+ function appendDetachedCliMeta(payload, meta) {
1531
+ return {
1532
+ ...payload,
1533
+ cli: {
1534
+ ...(payload?.cli || {}),
1535
+ command: "run",
1536
+ cdp_only: true,
1537
+ detached: true,
1538
+ detached_parent: true,
1539
+ child_pid: meta.childPid || null,
1540
+ stdout_path: meta.stdoutPath,
1541
+ stderr_path: meta.stderrPath
1542
+ }
1543
+ };
1544
+ }
1545
+
1546
+ async function waitForDetachedRecommendRunStart({
1547
+ child,
1548
+ stdoutPath,
1549
+ stderrPath,
1550
+ timeoutMs
1551
+ }) {
1552
+ const deadline = Date.now() + timeoutMs;
1553
+ let childExit = null;
1554
+ child.once("exit", (code, signal) => {
1555
+ childExit = { code, signal };
1556
+ });
1557
+
1558
+ while (Date.now() <= deadline) {
1559
+ const parsed = readFirstJsonObjectFromFile(stdoutPath);
1560
+ if (parsed) return appendDetachedCliMeta(parsed, {
1561
+ childPid: child.pid,
1562
+ stdoutPath,
1563
+ stderrPath
1564
+ });
1565
+ if (childExit) break;
1566
+ await sleepMs(500);
1567
+ }
1568
+
1569
+ const stderrPreview = (() => {
1570
+ try {
1571
+ return fs.readFileSync(stderrPath, "utf8").slice(-2000);
1572
+ } catch {
1573
+ return "";
1574
+ }
1575
+ })();
1576
+
1577
+ return appendDetachedCliMeta({
1578
+ status: "FAILED",
1579
+ error: {
1580
+ code: childExit ? "DETACHED_RECOMMEND_RUN_CHILD_EXITED" : "DETACHED_RECOMMEND_RUN_START_TIMEOUT",
1581
+ message: childExit
1582
+ ? `Detached recommend run child exited before producing a JSON result (code=${childExit.code ?? "null"}, signal=${childExit.signal ?? "null"}).`
1583
+ : `Timed out waiting ${timeoutMs}ms for detached recommend run start output.`,
1584
+ retryable: true,
1585
+ child_exit: childExit,
1586
+ stderr_preview: stderrPreview || null
1587
+ }
1588
+ }, {
1589
+ childPid: child.pid,
1590
+ stdoutPath,
1591
+ stderrPath
1592
+ });
1593
+ }
1594
+
1595
+ async function runPipelineDetached(rawArgs = [], options = {}) {
1596
+ const timeoutMs = parseNonNegativeInteger(options["detached-start-timeout-ms"], 180000);
1597
+ const childArgs = stripDetachedRunArgs(rawArgs);
1598
+ const { stdoutPath, stderrPath } = createDetachedRecommendRunPaths();
1599
+ const stdoutFd = fs.openSync(stdoutPath, "a");
1600
+ const stderrFd = fs.openSync(stderrPath, "a");
1601
+ let child;
1602
+ try {
1603
+ child = spawn(process.execPath, [currentFilePath, "run", ...childArgs], {
1604
+ cwd: process.cwd(),
1605
+ detached: true,
1606
+ env: {
1607
+ ...process.env,
1608
+ [detachedRecommendRunChildEnv]: "1"
1609
+ },
1610
+ stdio: ["ignore", stdoutFd, stderrFd],
1611
+ windowsHide: true
1612
+ });
1613
+ } finally {
1614
+ fs.closeSync(stdoutFd);
1615
+ fs.closeSync(stderrFd);
1616
+ }
1617
+ child.unref();
1618
+
1619
+ const result = await waitForDetachedRecommendRunStart({
1620
+ child,
1621
+ stdoutPath,
1622
+ stderrPath,
1623
+ timeoutMs
1624
+ });
1625
+ printJson(result);
1626
+ if (result.status !== "ACCEPTED") {
1627
+ process.exitCode = 1;
1628
+ }
1629
+ }
1630
+
1405
1631
  async function listChromeTabs(port) {
1406
1632
  const response = await fetch(`http://127.0.0.1:${port}/json/list`);
1407
1633
  if (!response.ok) {
@@ -2293,26 +2519,28 @@ function printHelp() {
2293
2519
  console.log("Usage:");
2294
2520
  console.log(" boss-recommend-mcp Start the MCP server");
2295
2521
  console.log(" boss-recommend-mcp start Start the MCP server");
2296
- console.log(" boss-recommend-mcp run Disabled until the one-shot CLI has a CDP-only async replacement");
2522
+ console.log(" boss-recommend-mcp run Start a CDP-only recommend run through the shared run service");
2297
2523
  console.log(" boss-recommend-mcp list-jobs CDP-only list of exact recommend job names for cron/one-shot inputs");
2298
2524
  console.log(" boss-recommend-mcp chat <subcommand> Run CDP-only boss-chat health/prepare/status commands");
2299
- console.log(" boss-recommend-mcp install Install/migrate skills and MCP configs; replaces legacy Boss MCP routes (supports --agent trae-cn/openclaw/...)");
2525
+ console.log(" boss-recommend-mcp install Install/migrate skills and MCP configs; replaces legacy Boss MCP routes (supports --agent trae-cn/openclaw/qclaw/...)");
2300
2526
  console.log(" boss-recommend-mcp install-skill Install bundled Codex skills (recommend/recruit/chat)");
2301
2527
  console.log(" boss-recommend-mcp init-config Create screening-config.json if missing (prefer workspace config/, fallback ~/.boss-recommend-mcp)");
2302
2528
  console.log(" boss-recommend-mcp config set Write baseUrl/apiKey/model (prefer workspace config/, fallback ~/.boss-recommend-mcp)");
2303
2529
  console.log(" boss-recommend-mcp set-port Persist preferred Chrome debug port to screening-config.json");
2304
- console.log(" boss-recommend-mcp mcp-config Generate MCP config JSON for Cursor/Trae(含 trae-cn)/Claude Code/OpenClaw");
2305
- console.log(" boss-recommend-mcp doctor Check config/runtime/calibration prerequisites (supports --agent trae-cn/cursor/...)");
2530
+ console.log(" boss-recommend-mcp mcp-config Generate MCP config JSON for Cursor/Trae(含 trae-cn)/Claude Code/OpenClaw/QClaw");
2531
+ console.log(" boss-recommend-mcp doctor Check config/runtime/calibration prerequisites (supports --agent trae-cn/qclaw/cursor/...)");
2306
2532
  console.log(" boss-recommend-mcp calibrate Disabled until CDP-only featured calibration is live-verified");
2307
2533
  console.log(" boss-recommend-mcp launch-chrome Launch or reuse Chrome debug instance and open Boss recommend page");
2308
2534
  console.log(" boss-recommend-mcp where Print installed package, skill, and config paths");
2309
2535
  console.log("");
2310
2536
  console.log("Run command:");
2311
- console.log(" boss-recommend-mcp run --instruction \"推荐页上筛选211男生,近14天没有,有大模型平台经验\" # returns RECOMMEND_CLI_RUN_UNSUPPORTED_CDP_ONLY during rewrite; use MCP start_recommend_pipeline_run");
2537
+ console.log(" boss-recommend-mcp run --instruction \"推荐页上筛选211男生,近14天没有,有大模型平台经验\" --overrides-file overrides.json --confirmation-file confirmation.json");
2538
+ console.log(" boss-recommend-mcp run --detached --instruction \"...\" --overrides-file overrides.json --confirmation-file confirmation.json");
2312
2539
  console.log(" boss-recommend-mcp list-jobs --slow-live --port 9222");
2313
2540
  console.log(" boss-recommend-mcp chat prepare-run --slow-live --port 9222 # CDP-only preflight; start runs through MCP start_boss_chat_run");
2314
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>]");
2315
2542
  console.log(" boss-recommend-mcp install --agent trae-cn");
2543
+ console.log(" boss-recommend-mcp install --agent qclaw # updates ~/.qclaw/openclaw.json mcp.servers and mirrors skills");
2316
2544
  console.log(" boss-recommend-mcp doctor --agent trae-cn --page-scope featured");
2317
2545
  console.log(" boss-recommend-mcp calibrate --port 9222 # returns CALIBRATE_UNSUPPORTED_CDP_ONLY during rewrite");
2318
2546
  }
@@ -2401,42 +2629,6 @@ async function installAll(options = {}) {
2401
2629
  }
2402
2630
  }
2403
2631
 
2404
- function buildUnsupportedRecommendCliRunResponse({
2405
- instruction,
2406
- confirmation,
2407
- overrides,
2408
- followUp,
2409
- workspaceRoot,
2410
- port
2411
- } = {}) {
2412
- return {
2413
- status: "FAILED",
2414
- error: {
2415
- code: recommendCliRunUnsupportedCode,
2416
- message: "boss-recommend-mcp run is fenced during the CDP-only rewrite because the old one-shot CLI route can reach page-JS/Runtime-based orchestration. Use the MCP tool start_recommend_pipeline_run for CDP-only recommend runs until a live-verified one-shot CLI replacement exists.",
2417
- retryable: false
2418
- },
2419
- cdp_only: true,
2420
- runtime_evaluate_used: false,
2421
- method_summary: {},
2422
- method_log: [],
2423
- run_mode: "mcp_async_required",
2424
- port,
2425
- target_url: bossUrl,
2426
- input: {
2427
- workspace_root: workspaceRoot,
2428
- instruction,
2429
- confirmation: confirmation ?? null,
2430
- overrides: overrides ?? null,
2431
- follow_up: followUp ?? null
2432
- },
2433
- guidance: {
2434
- recommended_tool: "start_recommend_pipeline_run",
2435
- next_development_task: "Implement a CDP-only CLI wrapper that starts a durable shared run-service session, persists run state, and exits only after its live gate proves no Runtime.* methods are reachable."
2436
- }
2437
- };
2438
- }
2439
-
2440
2632
  async function runPipelineOnce(options = {}) {
2441
2633
  const instruction = getRunInstruction(options);
2442
2634
  const confirmation = getRunConfirmation(options);
@@ -2445,15 +2637,70 @@ async function runPipelineOnce(options = {}) {
2445
2637
  const workspaceRoot = getWorkspaceRoot(options);
2446
2638
  const port = parsePositivePort(options.port) || parsePositivePort(process.env.BOSS_RECOMMEND_CHROME_PORT) || 9222;
2447
2639
 
2448
- printJson(buildUnsupportedRecommendCliRunResponse({
2449
- workspaceRoot,
2640
+ const args = {
2450
2641
  instruction,
2451
- confirmation,
2452
- overrides,
2453
- followUp,
2454
- port
2455
- }));
2456
- process.exitCode = 1;
2642
+ confirmation: confirmation ?? undefined,
2643
+ overrides: overrides ?? undefined,
2644
+ follow_up: followUp ?? undefined,
2645
+ host: typeof options.host === "string" && options.host.trim() ? options.host.trim() : undefined,
2646
+ port,
2647
+ target_url_includes: typeof options["target-url-includes"] === "string" && options["target-url-includes"].trim()
2648
+ ? options["target-url-includes"].trim()
2649
+ : undefined,
2650
+ allow_navigate: !(options["no-navigate"] === true || options.noNavigate === true || options.allow_navigate === false),
2651
+ slow_live: options["slow-live"] === true || options.slowLive === true || options.slow_live === true
2652
+ };
2653
+
2654
+ const optionalPassthrough = [
2655
+ "detail_limit",
2656
+ "allow_card_only_screening",
2657
+ "debug_test_mode",
2658
+ "screening_mode",
2659
+ "use_llm",
2660
+ "delay_ms",
2661
+ "max_image_pages",
2662
+ "image_wheel_delta_y",
2663
+ "cv_acquisition_mode",
2664
+ "list_max_scrolls",
2665
+ "list_stable_signature_limit",
2666
+ "list_wheel_delta_y",
2667
+ "list_settle_ms",
2668
+ "refresh_on_end",
2669
+ "max_refresh_rounds",
2670
+ "refresh_button_settle_ms",
2671
+ "refresh_reload_settle_ms",
2672
+ "dry_run_post_action",
2673
+ "execute_post_action",
2674
+ "action_timeout_ms",
2675
+ "action_interval_ms",
2676
+ "action_after_click_delay_ms",
2677
+ "llm_timeout_ms",
2678
+ "llm_image_limit",
2679
+ "llm_image_detail"
2680
+ ];
2681
+ for (const key of optionalPassthrough) {
2682
+ const kebab = key.replace(/_/g, "-");
2683
+ if (options[key] !== undefined) args[key] = options[key];
2684
+ else if (options[kebab] !== undefined) args[key] = options[kebab];
2685
+ }
2686
+
2687
+ const result = await startRecommendPipelineRunTool({
2688
+ workspaceRoot,
2689
+ args
2690
+ });
2691
+ printJson({
2692
+ ...result,
2693
+ cli: {
2694
+ command: "run",
2695
+ cdp_only: true,
2696
+ shared_run_service: true,
2697
+ workspace_root: workspaceRoot,
2698
+ port
2699
+ }
2700
+ });
2701
+ if (result.status !== "ACCEPTED") {
2702
+ process.exitCode = 1;
2703
+ }
2457
2704
  }
2458
2705
 
2459
2706
  function buildRecommendJobListCliInput(options = {}) {
@@ -2590,11 +2837,19 @@ export async function runCli(argv = process.argv) {
2590
2837
 
2591
2838
  switch (command) {
2592
2839
  case "start":
2840
+ case "mcp":
2593
2841
  startServer();
2594
2842
  break;
2595
2843
  case "run":
2596
2844
  try {
2597
- await runPipelineOnce(options);
2845
+ if (
2846
+ (options.detached === true || options.background === true)
2847
+ && process.env[detachedRecommendRunChildEnv] !== "1"
2848
+ ) {
2849
+ await runPipelineDetached(argv.slice(3), options);
2850
+ } else {
2851
+ await runPipelineOnce(options);
2852
+ }
2598
2853
  } catch (error) {
2599
2854
  printJson({
2600
2855
  status: "FAILED",
@@ -2754,7 +3009,6 @@ export const __testables = {
2754
3009
  buildBossChatCliInput,
2755
3010
  buildDefaultMcpArgs,
2756
3011
  buildMcpLaunchConfig,
2757
- buildUnsupportedRecommendCliRunResponse,
2758
3012
  collectRuntimeDirectories,
2759
3013
  ensureBossChatRuntimeReady: ensureBossChatRuntimeReadyLocal,
2760
3014
  ensureRuntimeDirectories,
@@ -30,6 +30,7 @@ export const DEFAULT_LOAD_MORE_HINT_KEYWORDS = Object.freeze([
30
30
 
31
31
  export const DEFAULT_BOTTOM_MARKER_SELECTORS = Object.freeze([
32
32
  ".finished-wrap",
33
+ ".loadmore",
33
34
  ".load-tips",
34
35
  "div[role=\"tfoot\"] .load-tips",
35
36
  ".no-data-refresh",
@@ -38,6 +39,7 @@ export const DEFAULT_BOTTOM_MARKER_SELECTORS = Object.freeze([
38
39
  ".no-data",
39
40
  ".tip-nodata",
40
41
  "[class*=\"finished\"]",
42
+ "[class*=\"loadmore\"]",
41
43
  "[class*=\"load-tips\"]",
42
44
  "[class*=\"no-more\"]",
43
45
  "[class*=\"no_more\"]"
@@ -96,6 +98,345 @@ function isUsableBox(box) {
96
98
  return Number(box?.rect?.width || 0) > 2 && Number(box?.rect?.height || 0) > 2;
97
99
  }
98
100
 
101
+ function isUsableRect(rect) {
102
+ return Number(rect?.width || 0) > 2 && Number(rect?.height || 0) > 2;
103
+ }
104
+
105
+ function pointFromRect(rect, {
106
+ xRatio = 0.5,
107
+ yRatio = 0.75,
108
+ inset = 8
109
+ } = {}) {
110
+ if (!isUsableRect(rect)) return null;
111
+ const safeInsetX = Math.min(Math.max(0, Number(inset) || 0), Math.max(0, rect.width / 2 - 1));
112
+ const safeInsetY = Math.min(Math.max(0, Number(inset) || 0), Math.max(0, rect.height / 2 - 1));
113
+ const minX = rect.x + safeInsetX;
114
+ const maxX = rect.x + rect.width - safeInsetX;
115
+ const minY = rect.y + safeInsetY;
116
+ const maxY = rect.y + rect.height - safeInsetY;
117
+ return {
118
+ x: Math.min(maxX, Math.max(minX, rect.x + rect.width * (Number(xRatio) || 0.5))),
119
+ y: Math.min(maxY, Math.max(minY, rect.y + rect.height * (Number(yRatio) || 0.75)))
120
+ };
121
+ }
122
+
123
+ function unionRects(rects = []) {
124
+ const usable = rects.filter(isUsableRect);
125
+ if (!usable.length) return null;
126
+ const left = Math.min(...usable.map((rect) => rect.x));
127
+ const top = Math.min(...usable.map((rect) => rect.y));
128
+ const right = Math.max(...usable.map((rect) => rect.x + rect.width));
129
+ const bottom = Math.max(...usable.map((rect) => rect.y + rect.height));
130
+ return {
131
+ x: left,
132
+ y: top,
133
+ width: right - left,
134
+ height: bottom - top
135
+ };
136
+ }
137
+
138
+ function pointInsideRect(point, rect, {
139
+ padding = 0
140
+ } = {}) {
141
+ if (!point || !isUsableRect(rect)) return false;
142
+ const pad = Math.max(0, Number(padding) || 0);
143
+ return Number(point.x) >= rect.x + pad
144
+ && Number(point.x) <= rect.x + rect.width - pad
145
+ && Number(point.y) >= rect.y + pad
146
+ && Number(point.y) <= rect.y + rect.height - pad;
147
+ }
148
+
149
+ function rectsIntersect(a, b, {
150
+ padding = 0
151
+ } = {}) {
152
+ if (!isUsableRect(a) || !isUsableRect(b)) return false;
153
+ const pad = Math.max(0, Number(padding) || 0);
154
+ return a.x + a.width >= b.x + pad
155
+ && b.x + b.width >= a.x + pad
156
+ && a.y + a.height >= b.y + pad
157
+ && b.y + b.height >= a.y + pad;
158
+ }
159
+
160
+ function intersectRects(a, b) {
161
+ if (!isUsableRect(a) || !isUsableRect(b)) return null;
162
+ const left = Math.max(a.x, b.x);
163
+ const top = Math.max(a.y, b.y);
164
+ const right = Math.min(a.x + a.width, b.x + b.width);
165
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
166
+ if (right - left <= 2 || bottom - top <= 2) return null;
167
+ return {
168
+ x: left,
169
+ y: top,
170
+ width: right - left,
171
+ height: bottom - top
172
+ };
173
+ }
174
+
175
+ function normalizePoint(point) {
176
+ if (!point) return null;
177
+ const x = Number(point.x);
178
+ const y = Number(point.y);
179
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
180
+ return { x, y };
181
+ }
182
+
183
+ function resolveViewportPoint(viewportPoint, viewport) {
184
+ if (!viewportPoint) return null;
185
+ if (viewport && ("xRatio" in viewportPoint || "yRatio" in viewportPoint)) {
186
+ const xRatio = Number(viewportPoint.xRatio ?? 0);
187
+ const yRatio = Number(viewportPoint.yRatio ?? 0);
188
+ if (Number.isFinite(xRatio) && Number.isFinite(yRatio)) {
189
+ return {
190
+ x: viewport.x + viewport.width * xRatio,
191
+ y: viewport.y + viewport.height * yRatio
192
+ };
193
+ }
194
+ }
195
+ return normalizePoint(viewportPoint);
196
+ }
197
+
198
+ async function getViewportRect(client) {
199
+ try {
200
+ const metrics = await client.Page.getLayoutMetrics();
201
+ const viewport = metrics.visualViewport || metrics.layoutViewport || metrics.cssVisualViewport || {};
202
+ const width = Number(viewport.clientWidth || viewport.width || metrics.layoutViewport?.clientWidth || 0);
203
+ const height = Number(viewport.clientHeight || viewport.height || metrics.layoutViewport?.clientHeight || 0);
204
+ const x = Number(viewport.pageX || viewport.x || 0);
205
+ const y = Number(viewport.pageY || viewport.y || 0);
206
+ if (width > 0 && height > 0) {
207
+ return { x, y, width, height };
208
+ }
209
+ } catch {
210
+ // Page.getLayoutMetrics is optional for fallback only.
211
+ }
212
+ return null;
213
+ }
214
+
215
+ async function collectUsableNodeBoxes(client, nodeIds = [], {
216
+ maxNodes = 80
217
+ } = {}) {
218
+ const boxes = [];
219
+ const errors = [];
220
+ for (const nodeId of nodeIds.slice(0, Math.max(1, Number(maxNodes) || 80))) {
221
+ try {
222
+ const box = await getNodeBox(client, nodeId);
223
+ if (isUsableBox(box)) {
224
+ boxes.push({
225
+ node_id: nodeId,
226
+ box,
227
+ rect: box.rect
228
+ });
229
+ }
230
+ } catch (error) {
231
+ errors.push({
232
+ node_id: nodeId,
233
+ error: error?.message || String(error)
234
+ });
235
+ }
236
+ }
237
+ return { boxes, errors };
238
+ }
239
+
240
+ async function querySelectorBoxes(client, rootNodeId, selectors = [], {
241
+ maxNodes = 80
242
+ } = {}) {
243
+ const attempts = [];
244
+ if (!rootNodeId) return { boxes: [], attempts };
245
+ for (const selector of selectors.filter(Boolean)) {
246
+ let nodeIds = [];
247
+ try {
248
+ nodeIds = await querySelectorAll(client, rootNodeId, selector);
249
+ } catch (error) {
250
+ attempts.push({
251
+ selector,
252
+ error: error?.message || String(error),
253
+ node_count: 0,
254
+ box_count: 0
255
+ });
256
+ continue;
257
+ }
258
+ const measured = await collectUsableNodeBoxes(client, nodeIds, { maxNodes });
259
+ attempts.push({
260
+ selector,
261
+ node_count: nodeIds.length,
262
+ box_count: measured.boxes.length,
263
+ errors: measured.errors
264
+ });
265
+ if (measured.boxes.length) {
266
+ return {
267
+ boxes: measured.boxes,
268
+ selector,
269
+ attempts
270
+ };
271
+ }
272
+ }
273
+ return { boxes: [], attempts };
274
+ }
275
+
276
+ export async function resolveInfiniteListFallbackPoint(client, {
277
+ rootNodeId = 0,
278
+ containerSelectors = [],
279
+ itemNodeIds = [],
280
+ itemSelectors = [],
281
+ allowedSources = ["container", "item_union", "viewport_ratio"],
282
+ containerXRatio = 0.5,
283
+ containerYRatio = 0.5,
284
+ itemXRatio = 0.5,
285
+ itemYRatio = 0.5,
286
+ viewportPoint = null,
287
+ validateViewportPoint = true,
288
+ maxProbeNodes = 80
289
+ } = {}) {
290
+ const attempts = [];
291
+ const allowed = new Set(Array.isArray(allowedSources) && allowedSources.length
292
+ ? allowedSources.map((source) => String(source || ""))
293
+ : ["container", "item_union", "viewport_ratio"]);
294
+
295
+ const containerResult = await querySelectorBoxes(client, rootNodeId, containerSelectors, {
296
+ maxNodes: maxProbeNodes
297
+ });
298
+ attempts.push({
299
+ source: "container",
300
+ selector: containerResult.selector || null,
301
+ attempts: containerResult.attempts
302
+ });
303
+ const containerBox = containerResult.boxes
304
+ .slice()
305
+ .sort((a, b) => (b.rect.width * b.rect.height) - (a.rect.width * a.rect.height))[0];
306
+ const viewport = await getViewportRect(client);
307
+ const inputViewportRect = viewport
308
+ ? { x: 0, y: 0, width: viewport.width, height: viewport.height }
309
+ : null;
310
+ const visibleContainerRect = inputViewportRect && containerBox?.rect
311
+ ? intersectRects(containerBox.rect, inputViewportRect) || containerBox.rect
312
+ : containerBox?.rect;
313
+ const containerPoint = pointFromRect(visibleContainerRect, {
314
+ xRatio: containerXRatio,
315
+ yRatio: containerYRatio
316
+ });
317
+ if (containerPoint && allowed.has("container")) {
318
+ return {
319
+ ok: true,
320
+ source: "container",
321
+ point: containerPoint,
322
+ selector: containerResult.selector || null,
323
+ node_id: containerBox.node_id,
324
+ assist_node_id: itemNodeIds.slice(-1)[0] || null,
325
+ rect: visibleContainerRect,
326
+ full_rect: containerBox.rect,
327
+ attempts
328
+ };
329
+ }
330
+
331
+ let itemBoxes = [];
332
+ const itemProbeNodeIds = itemNodeIds.length > maxProbeNodes
333
+ ? itemNodeIds.slice(-maxProbeNodes)
334
+ : itemNodeIds;
335
+ const measuredItems = await collectUsableNodeBoxes(client, itemProbeNodeIds, { maxNodes: maxProbeNodes });
336
+ itemBoxes = measuredItems.boxes;
337
+ attempts.push({
338
+ source: "visible_items",
339
+ node_count: itemNodeIds.length,
340
+ box_count: measuredItems.boxes.length,
341
+ errors: measuredItems.errors
342
+ });
343
+ if (!itemBoxes.length) {
344
+ const queriedItems = await querySelectorBoxes(client, rootNodeId, itemSelectors, {
345
+ maxNodes: maxProbeNodes
346
+ });
347
+ itemBoxes = queriedItems.boxes;
348
+ attempts.push({
349
+ source: "item_selector",
350
+ selector: queriedItems.selector || null,
351
+ attempts: queriedItems.attempts
352
+ });
353
+ }
354
+ const itemValidationRects = [
355
+ inputViewportRect,
356
+ visibleContainerRect || containerBox?.rect || null
357
+ ].filter(isUsableRect);
358
+ const visibleItemBoxes = itemValidationRects.length
359
+ ? itemBoxes.filter((item) => itemValidationRects.every((rect) => rectsIntersect(item.rect, rect, { padding: 1 })))
360
+ : itemBoxes;
361
+ attempts.push({
362
+ source: "visible_item_filter",
363
+ input_box_count: itemBoxes.length,
364
+ output_box_count: visibleItemBoxes.length,
365
+ validation_rect_count: itemValidationRects.length
366
+ });
367
+ const unionSourceBoxes = visibleItemBoxes.length ? visibleItemBoxes : itemBoxes;
368
+ const rawItemUnion = unionRects(unionSourceBoxes.map((item) => item.rect));
369
+ const itemUnion = itemValidationRects.reduce(
370
+ (rect, limit) => intersectRects(rect, limit) || rect,
371
+ rawItemUnion
372
+ );
373
+ const itemPoint = pointFromRect(itemUnion, {
374
+ xRatio: itemXRatio,
375
+ yRatio: itemYRatio
376
+ });
377
+ if (itemPoint && allowed.has("item_union")) {
378
+ const assistItem = unionSourceBoxes
379
+ .slice()
380
+ .sort((a, b) => ((b.rect.y + b.rect.height) - (a.rect.y + a.rect.height)))[0];
381
+ return {
382
+ ok: true,
383
+ source: "item_union",
384
+ point: itemPoint,
385
+ rect: itemUnion,
386
+ full_rect: rawItemUnion,
387
+ item_box_count: unionSourceBoxes.length,
388
+ visible_item_box_count: visibleItemBoxes.length,
389
+ assist_node_id: assistItem?.node_id || itemNodeIds.slice(-1)[0] || null,
390
+ attempts
391
+ };
392
+ }
393
+
394
+ const viewportRatioPoint = resolveViewportPoint(viewportPoint, viewport);
395
+ const normalizedViewportPoint = normalizePoint(viewportRatioPoint);
396
+ if (normalizedViewportPoint && allowed.has("viewport_ratio")) {
397
+ if (!validateViewportPoint) {
398
+ return {
399
+ ok: true,
400
+ source: "viewport_ratio",
401
+ point: normalizedViewportPoint,
402
+ viewport,
403
+ validated: false,
404
+ attempts
405
+ };
406
+ }
407
+ const validationRects = [
408
+ ...containerResult.boxes.map((item) => item.rect),
409
+ ...itemBoxes.map((item) => item.rect)
410
+ ].filter(isUsableRect);
411
+ const validatedRect = validationRects.find((rect) => pointInsideRect(normalizedViewportPoint, rect, { padding: 4 }));
412
+ attempts.push({
413
+ source: "viewport_ratio",
414
+ point: normalizedViewportPoint,
415
+ viewport,
416
+ validation_rect_count: validationRects.length,
417
+ validated: Boolean(validatedRect)
418
+ });
419
+ if (validatedRect) {
420
+ return {
421
+ ok: true,
422
+ source: "viewport_ratio",
423
+ point: normalizedViewportPoint,
424
+ viewport,
425
+ rect: validatedRect,
426
+ validated: true,
427
+ assist_node_id: itemNodeIds.slice(-1)[0] || null,
428
+ attempts
429
+ };
430
+ }
431
+ }
432
+
433
+ return {
434
+ ok: false,
435
+ reason: "fallback_point_unavailable",
436
+ attempts
437
+ };
438
+ }
439
+
99
440
  function shortHash(value) {
100
441
  return crypto.createHash("sha256").update(String(value || "")).digest("hex").slice(0, 16);
101
442
  }
@@ -561,6 +902,29 @@ export async function scrollInfiniteListByVisibleItems(client, items = [], {
561
902
  }
562
903
 
563
904
  const errors = [];
905
+ const wheelDelta = Math.max(1, Number(wheelDeltaY) || 850);
906
+ async function synthesizeGesture(x, y) {
907
+ if (typeof client?.Input?.synthesizeScrollGesture !== "function") return null;
908
+ try {
909
+ const gestureDistance = -Math.min(1200, wheelDelta);
910
+ await client.Input.synthesizeScrollGesture({
911
+ x,
912
+ y,
913
+ yDistance: gestureDistance,
914
+ speed: 800,
915
+ repeatCount: 1
916
+ });
917
+ return {
918
+ ok: true,
919
+ y_distance: gestureDistance
920
+ };
921
+ } catch (error) {
922
+ return {
923
+ ok: false,
924
+ error: error?.message || String(error)
925
+ };
926
+ }
927
+ }
564
928
  for (const anchor of candidates.slice().reverse()) {
565
929
  try {
566
930
  await scrollNodeIntoView(client, anchor.node_id);
@@ -574,15 +938,17 @@ export async function scrollInfiniteListByVisibleItems(client, items = [], {
574
938
  x,
575
939
  y,
576
940
  deltaX: 0,
577
- deltaY: Math.max(1, Number(wheelDeltaY) || 850)
941
+ deltaY: wheelDelta
578
942
  });
943
+ const gesture = await synthesizeGesture(x, y);
579
944
  if (settleMs > 0) await sleep(settleMs);
580
945
  return {
581
946
  ok: true,
582
947
  anchor_key: anchor.key,
583
948
  anchor_node_id: anchor.node_id,
584
949
  point: { x, y },
585
- wheel_delta_y: Math.max(1, Number(wheelDeltaY) || 850),
950
+ wheel_delta_y: wheelDelta,
951
+ gesture,
586
952
  settle_ms: settleMs,
587
953
  skipped_stale_anchor_count: errors.length
588
954
  };
@@ -595,23 +961,56 @@ export async function scrollInfiniteListByVisibleItems(client, items = [], {
595
961
  }
596
962
  }
597
963
 
598
- if (fallbackPoint && Number.isFinite(Number(fallbackPoint.x)) && Number.isFinite(Number(fallbackPoint.y))) {
599
- const x = Number(fallbackPoint.x);
600
- const y = Number(fallbackPoint.y);
964
+ const resolvedFallback = typeof fallbackPoint === "function"
965
+ ? await fallbackPoint({ client, items, errors })
966
+ : (fallbackPoint ? { ok: true, source: "static", point: fallbackPoint } : null);
967
+ const resolvedPoint = normalizePoint(resolvedFallback?.point || resolvedFallback);
968
+ if (resolvedPoint) {
969
+ const x = resolvedPoint.x;
970
+ const y = resolvedPoint.y;
971
+ let assist = null;
972
+ if (resolvedFallback?.assist_node_id) {
973
+ try {
974
+ await scrollNodeIntoView(client, resolvedFallback.assist_node_id);
975
+ await sleep(150);
976
+ assist = {
977
+ ok: true,
978
+ node_id: resolvedFallback.assist_node_id
979
+ };
980
+ } catch (error) {
981
+ assist = {
982
+ ok: false,
983
+ node_id: resolvedFallback.assist_node_id,
984
+ error: error?.message || String(error)
985
+ };
986
+ }
987
+ }
601
988
  await client.Input.dispatchMouseEvent({ type: "mouseMoved", x, y, button: "none" });
602
989
  await client.Input.dispatchMouseEvent({
603
990
  type: "mouseWheel",
604
991
  x,
605
992
  y,
606
993
  deltaX: 0,
607
- deltaY: Math.max(1, Number(wheelDeltaY) || 850)
994
+ deltaY: wheelDelta
608
995
  });
996
+ const gesture = await synthesizeGesture(x, y);
609
997
  if (settleMs > 0) await sleep(settleMs);
610
998
  return {
611
999
  ok: true,
612
1000
  mode: "fallback_point",
1001
+ fallback: {
1002
+ source: resolvedFallback?.source || "static",
1003
+ selector: resolvedFallback?.selector || null,
1004
+ node_id: resolvedFallback?.node_id || null,
1005
+ assist_node_id: resolvedFallback?.assist_node_id || null,
1006
+ rect: resolvedFallback?.rect || null,
1007
+ validated: resolvedFallback?.validated ?? null,
1008
+ reason: resolvedFallback?.reason || null
1009
+ },
1010
+ assist,
613
1011
  point: { x, y },
614
- wheel_delta_y: Math.max(1, Number(wheelDeltaY) || 850),
1012
+ wheel_delta_y: wheelDelta,
1013
+ gesture,
615
1014
  settle_ms: settleMs,
616
1015
  skipped_stale_anchor_count: errors.length,
617
1016
  stale_anchor_errors: errors
@@ -621,7 +1020,8 @@ export async function scrollInfiniteListByVisibleItems(client, items = [], {
621
1020
  return {
622
1021
  ok: false,
623
1022
  reason: "scroll_anchor_unavailable",
624
- errors
1023
+ errors,
1024
+ fallback: resolvedFallback || null
625
1025
  };
626
1026
  }
627
1027
 
@@ -8,6 +8,21 @@ export const CHAT_CARD_SELECTORS = Object.freeze([
8
8
  "div[role=\"listitem\"]"
9
9
  ]);
10
10
 
11
+ export const CHAT_LIST_CONTAINER_SELECTORS = Object.freeze([
12
+ ".chat-list",
13
+ ".chat-list-content",
14
+ ".chat-left",
15
+ ".chat-left-main",
16
+ ".chat-message-list-left",
17
+ ".chat-conversation-list",
18
+ ".geek-list",
19
+ ".geek-list-wrap",
20
+ ".chat-list-wrap",
21
+ ".user-list",
22
+ ".conversation-list",
23
+ "div[role=\"list\"]"
24
+ ]);
25
+
11
26
  export const CHAT_BOTTOM_MARKER_SELECTORS = Object.freeze([
12
27
  "div[role=\"tfoot\"] .load-tips",
13
28
  "p.load-tips",
@@ -22,7 +22,8 @@ import {
22
22
  detectInfiniteListBottomMarker,
23
23
  getNextInfiniteListCandidate,
24
24
  markInfiniteListCandidateProcessed,
25
- resetInfiniteListForRefreshRound
25
+ resetInfiniteListForRefreshRound,
26
+ resolveInfiniteListFallbackPoint
26
27
  } from "../../core/infinite-list/index.js";
27
28
  import { createViewportRunGuard } from "../../core/self-heal/index.js";
28
29
  import { createRunLifecycleManager } from "../../core/run/index.js";
@@ -38,6 +39,8 @@ import {
38
39
  } from "../../core/screening/index.js";
39
40
  import {
40
41
  CHAT_BOTTOM_MARKER_SELECTORS,
42
+ CHAT_CARD_SELECTORS,
43
+ CHAT_LIST_CONTAINER_SELECTORS,
41
44
  CHAT_TARGET_URL
42
45
  } from "./constants.js";
43
46
  import {
@@ -682,6 +685,14 @@ export async function runChatWorkflow({
682
685
  const results = [];
683
686
  let cardNodeIds = [];
684
687
  let listEndReason = "";
688
+ const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
689
+ rootNodeId: rootState?.rootNodes?.top,
690
+ containerSelectors: CHAT_LIST_CONTAINER_SELECTORS,
691
+ itemNodeIds: items.map((item) => item.node_id).filter(Boolean),
692
+ itemSelectors: CHAT_CARD_SELECTORS,
693
+ viewportPoint: { xRatio: 0.16, yRatio: 0.4 },
694
+ validateViewportPoint: true
695
+ }));
685
696
  let requestedCount = 0;
686
697
  let requestSatisfiedCount = 0;
687
698
  let requestSkippedCount = 0;
@@ -921,7 +932,7 @@ export async function runChatWorkflow({
921
932
  stableSignatureLimit: listStableSignatureLimit,
922
933
  wheelDeltaY: listWheelDeltaY,
923
934
  settleMs: listSettleMs,
924
- fallbackPoint: listFallbackPoint,
935
+ fallbackPoint: listFallbackResolver,
925
936
  findNodeIds: async () => {
926
937
  const currentRootState = await ensureChatViewport(await getChatRoots(client), "candidate_find_nodes");
927
938
  rootState = currentRootState;
@@ -58,6 +58,19 @@ export const RECOMMEND_CARD_SELECTOR = [
58
58
  "a[data-geekid]"
59
59
  ].join(", ");
60
60
 
61
+ export const RECOMMEND_LIST_CONTAINER_SELECTORS = Object.freeze([
62
+ ".recommend-list",
63
+ ".recommend-list-wrap",
64
+ ".candidate-list",
65
+ ".candidate-card-list",
66
+ ".candidate-card-wrap-list",
67
+ ".geek-list",
68
+ ".geek-list-wrap",
69
+ ".card-list",
70
+ ".list-wrap",
71
+ ".content-list"
72
+ ]);
73
+
61
74
  export const RECOMMEND_END_REFRESH_SELECTOR = [
62
75
  ".btn",
63
76
  "button",
@@ -24,7 +24,8 @@ import {
24
24
  detectInfiniteListBottomMarker,
25
25
  getNextInfiniteListCandidate,
26
26
  markInfiniteListCandidateProcessed,
27
- resetInfiniteListForRefreshRound
27
+ resetInfiniteListForRefreshRound,
28
+ resolveInfiniteListFallbackPoint
28
29
  } from "../../core/infinite-list/index.js";
29
30
  import { createViewportRunGuard } from "../../core/self-heal/index.js";
30
31
  import {
@@ -57,7 +58,9 @@ import {
57
58
  } from "./scopes.js";
58
59
  import {
59
60
  RECOMMEND_BOTTOM_MARKER_SELECTORS,
60
- RECOMMEND_END_REFRESH_SELECTOR
61
+ RECOMMEND_CARD_SELECTOR,
62
+ RECOMMEND_END_REFRESH_SELECTOR,
63
+ RECOMMEND_LIST_CONTAINER_SELECTORS
61
64
  } from "./constants.js";
62
65
  import {
63
66
  clickRecommendActionControl,
@@ -451,6 +454,14 @@ export async function runRecommendWorkflow({
451
454
  let filterResult = null;
452
455
  let cardNodeIds = [];
453
456
  let listEndReason = "";
457
+ const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
458
+ rootNodeId: rootState?.iframe?.documentNodeId,
459
+ containerSelectors: RECOMMEND_LIST_CONTAINER_SELECTORS,
460
+ itemNodeIds: items.map((item) => item.node_id).filter(Boolean),
461
+ itemSelectors: [RECOMMEND_CARD_SELECTOR],
462
+ viewportPoint: { xRatio: 0.28, yRatio: 0.5 },
463
+ validateViewportPoint: true
464
+ }));
454
465
 
455
466
  runControl.setPhase("recommend:cleanup");
456
467
  await closeRecommendDetail(client, { attemptsLimit: 2 });
@@ -567,7 +578,7 @@ export async function runRecommendWorkflow({
567
578
  stableSignatureLimit: listStableSignatureLimit,
568
579
  wheelDeltaY: listWheelDeltaY,
569
580
  settleMs: listSettleMs,
570
- fallbackPoint: listFallbackPoint,
581
+ fallbackPoint: listFallbackResolver,
571
582
  findNodeIds: async () => {
572
583
  let currentRootState = await getRecommendRoots(client);
573
584
  currentRootState = await ensureRecommendViewport(currentRootState, "candidate_find_nodes");
@@ -16,6 +16,18 @@ export const RECRUIT_CARD_SELECTOR = [
16
16
  "a[data-geekid]"
17
17
  ].join(", ");
18
18
 
19
+ export const RECRUIT_LIST_CONTAINER_SELECTORS = Object.freeze([
20
+ ".search-list",
21
+ ".search-result-list",
22
+ ".candidate-list",
23
+ ".geek-list",
24
+ ".geek-list-wrap",
25
+ ".card-list",
26
+ ".list-wrap",
27
+ ".search-content",
28
+ ".search-container"
29
+ ]);
30
+
19
31
  export const RECRUIT_NO_DATA_SELECTORS = Object.freeze([
20
32
  "i.tip-nodata",
21
33
  ".tip-nodata",
@@ -26,12 +38,14 @@ export const RECRUIT_NO_DATA_SELECTORS = Object.freeze([
26
38
 
27
39
  export const RECRUIT_BOTTOM_MARKER_SELECTORS = Object.freeze([
28
40
  ".finished-wrap",
41
+ ".loadmore",
29
42
  ".load-tips",
30
43
  ".tip-nodata",
31
44
  ".empty-tip",
32
45
  ".empty-text",
33
46
  ".no-data",
34
47
  "[class*=\"finished\"]",
48
+ "[class*=\"loadmore\"]",
35
49
  "[class*=\"load-tips\"]",
36
50
  "[class*=\"empty\"]"
37
51
  ]);
@@ -22,7 +22,8 @@ import {
22
22
  detectInfiniteListBottomMarker,
23
23
  getNextInfiniteListCandidate,
24
24
  markInfiniteListCandidateProcessed,
25
- resetInfiniteListForRefreshRound
25
+ resetInfiniteListForRefreshRound,
26
+ resolveInfiniteListFallbackPoint
26
27
  } from "../../core/infinite-list/index.js";
27
28
  import { createViewportRunGuard } from "../../core/self-heal/index.js";
28
29
  import {
@@ -52,7 +53,9 @@ import { refreshRecruitSearchAtEnd } from "./refresh.js";
52
53
  import { getRecruitRoots } from "./roots.js";
53
54
  import {
54
55
  RECRUIT_BOTTOM_MARKER_SELECTORS,
55
- RECRUIT_BOTTOM_REFRESH_SELECTORS
56
+ RECRUIT_BOTTOM_REFRESH_SELECTORS,
57
+ RECRUIT_CARD_SELECTOR,
58
+ RECRUIT_LIST_CONTAINER_SELECTORS
56
59
  } from "./constants.js";
57
60
 
58
61
  function compactScreening(screening) {
@@ -194,6 +197,14 @@ export async function runRecruitWorkflow({
194
197
  let refreshRounds = 0;
195
198
  let cardNodeIds = [];
196
199
  let listEndReason = "";
200
+ const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
201
+ rootNodeId: rootState?.iframe?.documentNodeId,
202
+ containerSelectors: RECRUIT_LIST_CONTAINER_SELECTORS,
203
+ itemNodeIds: items.map((item) => item.node_id).filter(Boolean),
204
+ itemSelectors: [RECRUIT_CARD_SELECTOR],
205
+ viewportPoint: { xRatio: 0.28, yRatio: 0.5 },
206
+ validateViewportPoint: true
207
+ }));
197
208
 
198
209
  runControl.setPhase("recruit:cleanup");
199
210
  await closeRecruitDetail(client, { attemptsLimit: 2 });
@@ -283,7 +294,7 @@ export async function runRecruitWorkflow({
283
294
  stableSignatureLimit: listStableSignatureLimit,
284
295
  wheelDeltaY: listWheelDeltaY,
285
296
  settleMs: listSettleMs,
286
- fallbackPoint: listFallbackPoint,
297
+ fallbackPoint: listFallbackResolver,
287
298
  findNodeIds: async () => {
288
299
  let currentRootState = await getRecruitRoots(client);
289
300
  currentRootState = await ensureRecruitViewport(currentRootState, "candidate_find_nodes");