@reconcrap/boss-recommend-mcp 2.1.15 → 2.1.17
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 +29 -0
- package/package.json +3 -2
- package/src/chat-mcp.js +57 -15
- package/src/core/greet-quota/index.js +17 -0
- package/src/core/reporting/legacy-csv.js +5 -1
- package/src/domains/recommend/actions.js +19 -3
- package/src/domains/recommend/colleague-contact.js +333 -0
- package/src/domains/recommend/index.js +1 -0
- package/src/domains/recommend/run-service.js +166 -77
- package/src/domains/recruit/constants.js +4 -0
- package/src/domains/recruit/instruction-parser.js +42 -1
- package/src/domains/recruit/run-service.js +42 -4
- package/src/domains/recruit/search.js +40 -6
- package/src/index.js +20 -11
- package/src/parser.js +45 -2
- package/src/recommend-mcp.js +15 -10
- package/src/recruit-mcp.js +8 -0
package/README.md
CHANGED
|
@@ -391,6 +391,35 @@ config/screening-config.example.json
|
|
|
391
391
|
- recommend / search / chat 图片简历 fallback 与主列表滚动都会在启用 `listScrollJitter` 时使用 coverage-safe scroll jitter:每次 wheel delta 在安全范围内变化,并保留截图重叠、重复检测、bottom-marker / stop-boundary 逻辑,实际 delta 和 settle 时间会写入 artifact metadata。
|
|
392
392
|
- chat/recommend/search run 也兼容显式参数 `safe_pacing`、`batch_rest_enabled` 与 `human_behavior.restLevel`:run 参数优先于配置文件。AI harness/skill 启动每次 run 前必须让用户明确选择 `low/medium/high`,再把选择写入 `human_behavior.restLevel`。
|
|
393
393
|
|
|
394
|
+
### 离线筛选延迟 benchmark
|
|
395
|
+
|
|
396
|
+
可以用已保存的 recommend run JSON 和截图证据离线评估筛选策略,不打开 Boss、不重新点击候选人,也不改变 `简历来源`。benchmark 会读取每个 saved run 自己的 `result.screen_params.criteria`,缺失时再回退到 run context / instruction,避免用同一套 criteria 误测不同岗位。
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
npm run benchmark:screening -- --dry-run
|
|
400
|
+
npm run benchmark:screening -- --max-candidates 20
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
默认会自动选择 `~/.boss-recommend-mcp/runs` 下最近 4 个仍有 `detail.image_evidence.llm_file_paths` 的 recommend run,并把输出写到 `.live-artifacts/screening-benchmark/<timestamp>/`。真实 LLM replay 会复用当前 `screening-config.json`;如需指定配置或 run:
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
npm run benchmark:screening -- --config C:/Users/yaolin/.boss-recommend-mcp/screening-config.json --run mcp_recommend_mq92ltt5_3x9liodw
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
内置策略:
|
|
410
|
+
|
|
411
|
+
- `oracle_full_image_high`:完整图片筛选,`llmThinkingLevel=high`,作为 pass/fail 质量基准。
|
|
412
|
+
- `baseline_full_image_reasoning`:完整图片筛选,`llmThinkingLevel=low`,用于衡量当前低思考 baseline。
|
|
413
|
+
- `extract_then_reason`:先从图片抽取结构化简历事实,再用文本 reasoning 对每位候选人做最终判断;抽取/判断异常会升级到完整图片筛选。
|
|
414
|
+
- `extract_hard_gate_then_reason`:抽取后先做 cheap hard-fail gate;只有明确违反硬条件/排除项时直接 `passed=false`,否则继续文本 reasoning。
|
|
415
|
+
- `batch_extract_then_reason`:批量抽取多个候选人的结构化事实,再逐个文本 reasoning。
|
|
416
|
+
- `batch_extract_hard_gate_then_reason`:批量抽取后逐个执行 hard-fail gate,明显不合适的候选人不再进入后续 reasoning。
|
|
417
|
+
- `pipeline_simulation`:基于 saved timings 估算浏览器采集和 LLM 重叠后的理论下界;只用于判断是否值得后续改 workflow。
|
|
418
|
+
|
|
419
|
+
为避免把“输出解释”本身的耗时算进策略,benchmark-native 的抽取/判断提示词不会要求模型输出筛选理由、summary 或 CoT;hard-fail gate 只要求 `{"hard_fail": true/false, "continue_reasoning": true/false, "uncertain": true/false}`,判断阶段只要求 `{"passed": true/false, "uncertain": true/false}`。
|
|
420
|
+
|
|
421
|
+
输出文件包括 `benchmark-summary.json`、`benchmark-results.json`、`benchmark-results.csv`、`benchmark-disagreements.csv` 和 `benchmark-manifest.json`。所有非 oracle 策略都会和 `oracle_full_image_high` 对比 false negative / false positive / pass-rate drift,并用 `saved_non_llm_ms + benchmark_llm_ms` 计算 projected total time;hard-gate 策略会额外报告 `gate_ms` 和 `early_exit_count`;默认 eligibility 目标是平均 `<30000ms` 且 false negative 为 0。
|
|
422
|
+
|
|
394
423
|
## 常用命令
|
|
395
424
|
|
|
396
425
|
npm 包安装后可直接使用可执行命令 `boss-recommend-mcp`。以下示例展示源码模式(`node src/cli.js`):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reconcrap/boss-recommend-mcp",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.17",
|
|
4
4
|
"description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"boss",
|
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
"live:chat-mcp": "node scripts/live-chat-mcp-smoke.js",
|
|
77
77
|
"live:cv-capture-target": "node scripts/live-cv-capture-target-smoke.js",
|
|
78
78
|
"live:chat-phase10-full": "node scripts/live-chat-phase10-full.js",
|
|
79
|
-
"live:chat-image-screening": "node scripts/live-chat-image-screening-smoke.js"
|
|
79
|
+
"live:chat-image-screening": "node scripts/live-chat-image-screening-smoke.js",
|
|
80
|
+
"benchmark:screening": "node scripts/benchmark-screening-strategies.js"
|
|
80
81
|
},
|
|
81
82
|
"files": [
|
|
82
83
|
"bin",
|
package/src/chat-mcp.js
CHANGED
|
@@ -756,8 +756,8 @@ function buildLegacyChatResult(snapshot) {
|
|
|
756
756
|
};
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
-
function normalizeRunSnapshot(snapshot) {
|
|
760
|
-
if (!snapshot) return null;
|
|
759
|
+
function normalizeRunSnapshot(snapshot) {
|
|
760
|
+
if (!snapshot) return null;
|
|
761
761
|
const meta = getChatRunMeta(snapshot.runId);
|
|
762
762
|
const artifacts = getChatRunArtifacts(snapshot.runId);
|
|
763
763
|
const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
|
|
@@ -812,19 +812,61 @@ function normalizeRunSnapshot(snapshot) {
|
|
|
812
812
|
},
|
|
813
813
|
result: legacyResult,
|
|
814
814
|
artifacts
|
|
815
|
-
};
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
function
|
|
819
|
-
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function plainRecord(value) {
|
|
819
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function mergePersistedChatControlRequest(normalized, existing) {
|
|
823
|
+
const control = {
|
|
824
|
+
...(normalized?.control || {})
|
|
825
|
+
};
|
|
826
|
+
if (!normalized) return control;
|
|
827
|
+
const existingControl = plainRecord(existing?.control);
|
|
828
|
+
if (TERMINAL_STATUSES.has(normalized.state)) return control;
|
|
829
|
+
if (existingControl.cancel_requested === true) {
|
|
830
|
+
return {
|
|
831
|
+
...control,
|
|
832
|
+
pause_requested: true,
|
|
833
|
+
pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
|
|
834
|
+
pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "cancel_boss_chat_run",
|
|
835
|
+
cancel_requested: true
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
if (existingControl.pause_requested === true && normalized.state !== RUN_STATUS_PAUSED) {
|
|
839
|
+
return {
|
|
840
|
+
...control,
|
|
841
|
+
pause_requested: true,
|
|
842
|
+
pause_requested_at: existingControl.pause_requested_at || control.pause_requested_at || new Date().toISOString(),
|
|
843
|
+
pause_requested_by: existingControl.pause_requested_by || control.pause_requested_by || "pause_boss_chat_run"
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
if (existingControl.pause_requested === false && normalized.state === RUN_STATUS_PAUSED) {
|
|
847
|
+
return {
|
|
848
|
+
...control,
|
|
849
|
+
pause_requested: false,
|
|
850
|
+
pause_requested_at: null,
|
|
851
|
+
pause_requested_by: null,
|
|
852
|
+
cancel_requested: false
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
return control;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function persistChatRunSnapshot(snapshot, {
|
|
859
|
+
persistActiveCheckpoint = false
|
|
860
|
+
} = {}) {
|
|
861
|
+
const normalized = normalizeRunSnapshot(snapshot);
|
|
862
|
+
if (!normalized?.run_id) return normalized;
|
|
863
|
+
const artifacts = getChatRunArtifacts(normalized.run_id);
|
|
864
|
+
if (!artifacts) return normalized;
|
|
865
|
+
const existing = readJsonFile(artifacts.run_state_path);
|
|
866
|
+
normalized.control = mergePersistedChatControlRequest(normalized, existing);
|
|
867
|
+
if (persistActiveCheckpoint) {
|
|
868
|
+
persistChatCheckpointSnapshot(normalized);
|
|
869
|
+
}
|
|
828
870
|
const payload = {
|
|
829
871
|
run_id: normalized.run_id,
|
|
830
872
|
mode: normalized.mode,
|
|
@@ -52,3 +52,20 @@ export function assertGreetQuotaAvailable(source = "") {
|
|
|
52
52
|
}
|
|
53
53
|
return quota;
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
export function describeGreetQuotaAfterSpend(source = "") {
|
|
57
|
+
const quota = normalizeGreetQuotaSource(source);
|
|
58
|
+
if (!quota.found || quota.numerator === null || quota.denominator === null) {
|
|
59
|
+
return {
|
|
60
|
+
...quota,
|
|
61
|
+
remaining_after_spend: null,
|
|
62
|
+
exhausted_after_spend: false
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const remaining = quota.denominator - quota.numerator;
|
|
66
|
+
return {
|
|
67
|
+
...quota,
|
|
68
|
+
remaining_after_spend: remaining,
|
|
69
|
+
exhausted_after_spend: remaining < quota.numerator
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -50,6 +50,7 @@ const SEARCH_PARAM_ORDER = [
|
|
|
50
50
|
"schools",
|
|
51
51
|
"keyword",
|
|
52
52
|
"filter_recent_viewed",
|
|
53
|
+
"skip_recent_colleague_contacted",
|
|
53
54
|
"job",
|
|
54
55
|
"start_from",
|
|
55
56
|
"target_count",
|
|
@@ -60,7 +61,10 @@ const SCREEN_PARAM_ORDER = [
|
|
|
60
61
|
"criteria",
|
|
61
62
|
"target_count",
|
|
62
63
|
"post_action",
|
|
63
|
-
"max_greet_count"
|
|
64
|
+
"max_greet_count",
|
|
65
|
+
"skip_recent_colleague_contacted",
|
|
66
|
+
"colleague_contact_window_days",
|
|
67
|
+
"search_exchange_resume_filter_days"
|
|
64
68
|
];
|
|
65
69
|
|
|
66
70
|
function normalizeText(value) {
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getNodeBox,
|
|
6
6
|
getOuterHTML,
|
|
7
7
|
querySelectorAll,
|
|
8
|
+
scrollNodeIntoView,
|
|
8
9
|
sleep
|
|
9
10
|
} from "../../core/browser/index.js";
|
|
10
11
|
import {
|
|
@@ -433,7 +434,21 @@ export async function waitForRecommendDetailActionControls(client, {
|
|
|
433
434
|
export async function clickRecommendActionControl(client, control, {
|
|
434
435
|
allowDisabled = false
|
|
435
436
|
} = {}) {
|
|
436
|
-
|
|
437
|
+
let clickCenter = control?.center || null;
|
|
438
|
+
let clickRect = control?.rect || null;
|
|
439
|
+
if (control?.node_id) {
|
|
440
|
+
try {
|
|
441
|
+
await scrollNodeIntoView(client, control.node_id);
|
|
442
|
+
await sleep(150);
|
|
443
|
+
const box = await getNodeBox(client, control.node_id);
|
|
444
|
+
clickCenter = box.center;
|
|
445
|
+
clickRect = box.rect;
|
|
446
|
+
} catch {
|
|
447
|
+
// Fall back to the discovered center below; callers still get a clear
|
|
448
|
+
// error if no usable click point exists.
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (!clickCenter) {
|
|
437
452
|
throw new Error("Action control has no clickable center");
|
|
438
453
|
}
|
|
439
454
|
const greetQuota = control.kind === "greet"
|
|
@@ -442,7 +457,7 @@ export async function clickRecommendActionControl(client, control, {
|
|
|
442
457
|
if (control.disabled && !allowDisabled) {
|
|
443
458
|
throw new Error(`Action control is disabled: ${control.kind}`);
|
|
444
459
|
}
|
|
445
|
-
await clickPoint(client,
|
|
460
|
+
await clickPoint(client, clickCenter.x, clickCenter.y);
|
|
446
461
|
return {
|
|
447
462
|
clicked: true,
|
|
448
463
|
kind: control.kind,
|
|
@@ -451,6 +466,7 @@ export async function clickRecommendActionControl(client, control, {
|
|
|
451
466
|
selector: control.selector,
|
|
452
467
|
root: control.root,
|
|
453
468
|
node_id: control.node_id,
|
|
454
|
-
center:
|
|
469
|
+
center: clickCenter,
|
|
470
|
+
rect: clickRect
|
|
455
471
|
};
|
|
456
472
|
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clickNodeCenter,
|
|
3
|
+
getNodeBox,
|
|
4
|
+
getOuterHTML,
|
|
5
|
+
querySelectorAll,
|
|
6
|
+
sleep
|
|
7
|
+
} from "../../core/browser/index.js";
|
|
8
|
+
import { htmlToText } from "../../core/screening/index.js";
|
|
9
|
+
|
|
10
|
+
const COLLEAGUE_SECTION_SELECTOR = ".colleague-collaboration";
|
|
11
|
+
const COLLEAGUE_TAB_SELECTOR = ".colleague-collaboration .tab-hd";
|
|
12
|
+
const SELECTED_TAB_SELECTOR = ".colleague-collaboration .tab-hd .selected";
|
|
13
|
+
const SECTION_SELECTED_TAB_SELECTOR = ".tab-hd .selected";
|
|
14
|
+
const TAB_CANDIDATE_SELECTOR = ".tab-hd span, .tab-hd div, .tab-hd *";
|
|
15
|
+
const ROW_SELECTOR = ".colleague-collaboration .record-item.mate-log-item";
|
|
16
|
+
const ROW_CONTENT_SELECTOR = ".colleague-collaboration .record-item.mate-log-item .content";
|
|
17
|
+
const DETAIL_PANE_SELECTOR = ".resume-item-detail";
|
|
18
|
+
|
|
19
|
+
function normalizeText(value) {
|
|
20
|
+
return String(value || "").replace(/\s+/g, " ").trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function dateOnly(value) {
|
|
24
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
25
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
26
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function daysBetween(left, right) {
|
|
30
|
+
const leftDate = dateOnly(left);
|
|
31
|
+
const rightDate = dateOnly(right);
|
|
32
|
+
if (!leftDate || !rightDate) return null;
|
|
33
|
+
return Math.floor((leftDate.getTime() - rightDate.getTime()) / 86400000);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatLocalDate(date) {
|
|
37
|
+
const parsed = dateOnly(date);
|
|
38
|
+
if (!parsed) return null;
|
|
39
|
+
const year = parsed.getFullYear();
|
|
40
|
+
const month = String(parsed.getMonth() + 1).padStart(2, "0");
|
|
41
|
+
const day = String(parsed.getDate()).padStart(2, "0");
|
|
42
|
+
return `${year}-${month}-${day}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeDate(year, month, day) {
|
|
46
|
+
const parsed = new Date(year, month - 1, day);
|
|
47
|
+
if (
|
|
48
|
+
parsed.getFullYear() !== year
|
|
49
|
+
|| parsed.getMonth() !== month - 1
|
|
50
|
+
|| parsed.getDate() !== day
|
|
51
|
+
) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseColleagueContactDate(text, {
|
|
58
|
+
referenceDate = new Date()
|
|
59
|
+
} = {}) {
|
|
60
|
+
const raw = normalizeText(text);
|
|
61
|
+
if (!raw) return null;
|
|
62
|
+
const today = dateOnly(referenceDate) || dateOnly(new Date());
|
|
63
|
+
const relativeDays = raw.match(/(\d+)\s*天前/);
|
|
64
|
+
if (relativeDays) {
|
|
65
|
+
const days = Number.parseInt(relativeDays[1], 10);
|
|
66
|
+
if (Number.isFinite(days) && days >= 0) {
|
|
67
|
+
const date = new Date(today);
|
|
68
|
+
date.setDate(date.getDate() - days);
|
|
69
|
+
return date;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (/今天/.test(raw)) return today;
|
|
73
|
+
if (/昨天/.test(raw)) {
|
|
74
|
+
const date = new Date(today);
|
|
75
|
+
date.setDate(date.getDate() - 1);
|
|
76
|
+
return date;
|
|
77
|
+
}
|
|
78
|
+
if (/前天/.test(raw)) {
|
|
79
|
+
const date = new Date(today);
|
|
80
|
+
date.setDate(date.getDate() - 2);
|
|
81
|
+
return date;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const full = raw.match(/(20\d{2})[.\-\/](\d{1,2})[.\-\/](\d{1,2})/);
|
|
85
|
+
if (full) {
|
|
86
|
+
return makeDate(
|
|
87
|
+
Number.parseInt(full[1], 10),
|
|
88
|
+
Number.parseInt(full[2], 10),
|
|
89
|
+
Number.parseInt(full[3], 10)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const partial = raw.match(/(?:^|\D)(\d{1,2})[.\-\/](\d{1,2})(?:\D|$)/);
|
|
94
|
+
if (partial) {
|
|
95
|
+
const reference = dateOnly(referenceDate) || new Date();
|
|
96
|
+
let date = makeDate(
|
|
97
|
+
reference.getFullYear(),
|
|
98
|
+
Number.parseInt(partial[1], 10),
|
|
99
|
+
Number.parseInt(partial[2], 10)
|
|
100
|
+
);
|
|
101
|
+
if (date && daysBetween(date, reference) > 7) {
|
|
102
|
+
date = makeDate(
|
|
103
|
+
reference.getFullYear() - 1,
|
|
104
|
+
Number.parseInt(partial[1], 10),
|
|
105
|
+
Number.parseInt(partial[2], 10)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return date;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function isDateWithinWindow(date, {
|
|
115
|
+
referenceDate = new Date(),
|
|
116
|
+
windowDays = 14
|
|
117
|
+
} = {}) {
|
|
118
|
+
const diff = daysBetween(referenceDate, date);
|
|
119
|
+
return Number.isFinite(diff) && diff >= 0 && diff <= windowDays;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function textForNode(client, nodeId) {
|
|
123
|
+
return htmlToText(await getOuterHTML(client, nodeId));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function queryAcrossRoots(client, roots, selector) {
|
|
127
|
+
const matches = [];
|
|
128
|
+
for (const root of roots || []) {
|
|
129
|
+
if (!root?.nodeId) continue;
|
|
130
|
+
const nodeIds = await querySelectorAll(client, root.nodeId, selector).catch(() => []);
|
|
131
|
+
for (const nodeId of nodeIds) {
|
|
132
|
+
matches.push({
|
|
133
|
+
root: root.name,
|
|
134
|
+
root_node_id: root.nodeId,
|
|
135
|
+
selector,
|
|
136
|
+
node_id: nodeId
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return matches;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function tabIsColleague(text) {
|
|
144
|
+
return /同事沟通进度/.test(normalizeText(text));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function ensureColleagueTabSelected(client, sectionNodeId) {
|
|
148
|
+
const selectedIds = await querySelectorAll(client, sectionNodeId, SECTION_SELECTED_TAB_SELECTOR).catch(() => []);
|
|
149
|
+
for (const nodeId of selectedIds) {
|
|
150
|
+
const text = normalizeText(await textForNode(client, nodeId).catch(() => ""));
|
|
151
|
+
if (tabIsColleague(text)) {
|
|
152
|
+
return {
|
|
153
|
+
selected: true,
|
|
154
|
+
changed: false,
|
|
155
|
+
selected_text: text
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const candidateIds = await querySelectorAll(client, sectionNodeId, TAB_CANDIDATE_SELECTOR).catch(() => []);
|
|
161
|
+
for (const nodeId of candidateIds) {
|
|
162
|
+
const text = normalizeText(await textForNode(client, nodeId).catch(() => ""));
|
|
163
|
+
if (!tabIsColleague(text)) continue;
|
|
164
|
+
const box = await clickNodeCenter(client, nodeId, { scrollIntoView: true });
|
|
165
|
+
await sleep(500);
|
|
166
|
+
return {
|
|
167
|
+
selected: true,
|
|
168
|
+
changed: true,
|
|
169
|
+
selected_text: text,
|
|
170
|
+
click_box: {
|
|
171
|
+
rect: box.rect,
|
|
172
|
+
center: box.center
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
selected: false,
|
|
179
|
+
changed: false,
|
|
180
|
+
selected_text: selectedIds.length
|
|
181
|
+
? normalizeText(await textForNode(client, selectedIds[0]).catch(() => ""))
|
|
182
|
+
: ""
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function readContactRows(client, roots) {
|
|
187
|
+
const rowMatches = await queryAcrossRoots(client, roots, ROW_CONTENT_SELECTOR);
|
|
188
|
+
const fallbackMatches = rowMatches.length ? [] : await queryAcrossRoots(client, roots, ROW_SELECTOR);
|
|
189
|
+
const matches = rowMatches.length ? rowMatches : fallbackMatches;
|
|
190
|
+
const rows = [];
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
for (const match of matches) {
|
|
193
|
+
const text = normalizeText(await textForNode(client, match.node_id).catch(() => ""));
|
|
194
|
+
if (!text || seen.has(text)) continue;
|
|
195
|
+
seen.add(text);
|
|
196
|
+
rows.push({
|
|
197
|
+
text,
|
|
198
|
+
root: match.root,
|
|
199
|
+
selector: match.selector,
|
|
200
|
+
node_id: match.node_id
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return rows;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function scrollDetailPaneForRows(client, roots, sectionNodeId, {
|
|
207
|
+
maxScrolls = 4,
|
|
208
|
+
settleMs = 350
|
|
209
|
+
} = {}) {
|
|
210
|
+
const detailPanes = await queryAcrossRoots(client, roots, DETAIL_PANE_SELECTOR);
|
|
211
|
+
const targetNodeId = detailPanes[0]?.node_id || sectionNodeId;
|
|
212
|
+
let box = null;
|
|
213
|
+
try {
|
|
214
|
+
box = await getNodeBox(client, targetNodeId);
|
|
215
|
+
} catch {
|
|
216
|
+
try {
|
|
217
|
+
box = await getNodeBox(client, sectionNodeId);
|
|
218
|
+
} catch {
|
|
219
|
+
return { scrolls: 0, reason: "scroll_target_box_unavailable" };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
let scrolls = 0;
|
|
223
|
+
for (let index = 0; index < maxScrolls; index += 1) {
|
|
224
|
+
await client.Input.dispatchMouseEvent({
|
|
225
|
+
type: "mouseWheel",
|
|
226
|
+
x: box.center.x,
|
|
227
|
+
y: box.center.y,
|
|
228
|
+
deltaY: 680,
|
|
229
|
+
deltaX: 0
|
|
230
|
+
});
|
|
231
|
+
scrolls += 1;
|
|
232
|
+
await sleep(settleMs);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
scrolls,
|
|
236
|
+
target_selector: detailPanes[0]?.selector || COLLEAGUE_SECTION_SELECTOR
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function waitForColleagueSections(client, roots, {
|
|
241
|
+
timeoutMs = 1000,
|
|
242
|
+
intervalMs = 150
|
|
243
|
+
} = {}) {
|
|
244
|
+
const started = Date.now();
|
|
245
|
+
let sections = [];
|
|
246
|
+
do {
|
|
247
|
+
sections = await queryAcrossRoots(client, roots, COLLEAGUE_SECTION_SELECTOR);
|
|
248
|
+
if (sections.length) return sections;
|
|
249
|
+
if (Date.now() - started >= timeoutMs) break;
|
|
250
|
+
await sleep(intervalMs);
|
|
251
|
+
} while (Date.now() - started <= timeoutMs);
|
|
252
|
+
return sections;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function inspectRecentColleagueContact(client, detailState, {
|
|
256
|
+
referenceDate = new Date(),
|
|
257
|
+
windowDays = 14,
|
|
258
|
+
scroll = true,
|
|
259
|
+
sectionWaitMs = 1000,
|
|
260
|
+
sectionPollMs = 150
|
|
261
|
+
} = {}) {
|
|
262
|
+
const roots = detailState?.roots || [];
|
|
263
|
+
const sections = await waitForColleagueSections(client, roots, {
|
|
264
|
+
timeoutMs: sectionWaitMs,
|
|
265
|
+
intervalMs: sectionPollMs
|
|
266
|
+
});
|
|
267
|
+
if (!sections.length) {
|
|
268
|
+
return {
|
|
269
|
+
checked: true,
|
|
270
|
+
panel_found: false,
|
|
271
|
+
recent: false,
|
|
272
|
+
reason: "panel_missing",
|
|
273
|
+
window_days: windowDays,
|
|
274
|
+
rows: []
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const section = sections[0];
|
|
279
|
+
const tabHeader = await queryAcrossRoots(client, roots, COLLEAGUE_TAB_SELECTOR);
|
|
280
|
+
const tab = await ensureColleagueTabSelected(client, section.node_id);
|
|
281
|
+
if (!tab.selected) {
|
|
282
|
+
return {
|
|
283
|
+
checked: true,
|
|
284
|
+
panel_found: true,
|
|
285
|
+
recent: false,
|
|
286
|
+
reason: "colleague_tab_unavailable",
|
|
287
|
+
window_days: windowDays,
|
|
288
|
+
section_root: section.root,
|
|
289
|
+
tab_header_found: tabHeader.length > 0,
|
|
290
|
+
selected_tab_text: tab.selected_text,
|
|
291
|
+
rows: []
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let rows = await readContactRows(client, roots);
|
|
296
|
+
let scroll_probe = null;
|
|
297
|
+
if (scroll) {
|
|
298
|
+
scroll_probe = await scrollDetailPaneForRows(client, roots, section.node_id);
|
|
299
|
+
const afterScrollRows = await readContactRows(client, roots);
|
|
300
|
+
const byText = new Map(rows.map((row) => [row.text, row]));
|
|
301
|
+
for (const row of afterScrollRows) {
|
|
302
|
+
if (!byText.has(row.text)) byText.set(row.text, row);
|
|
303
|
+
}
|
|
304
|
+
rows = Array.from(byText.values());
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const parsedRows = rows.map((row) => {
|
|
308
|
+
const parsedDate = parseColleagueContactDate(row.text, { referenceDate });
|
|
309
|
+
return {
|
|
310
|
+
...row,
|
|
311
|
+
parsed_date: parsedDate ? formatLocalDate(parsedDate) : null,
|
|
312
|
+
within_window: parsedDate
|
|
313
|
+
? isDateWithinWindow(parsedDate, { referenceDate, windowDays })
|
|
314
|
+
: false
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
const matched = parsedRows.find((row) => row.within_window) || null;
|
|
318
|
+
return {
|
|
319
|
+
checked: true,
|
|
320
|
+
panel_found: true,
|
|
321
|
+
recent: Boolean(matched),
|
|
322
|
+
reason: matched ? "recent_colleague_contact_found" : "no_recent_colleague_contact",
|
|
323
|
+
window_days: windowDays,
|
|
324
|
+
section_root: section.root,
|
|
325
|
+
tab_header_found: tabHeader.length > 0,
|
|
326
|
+
selected_tab_text: tab.selected_text,
|
|
327
|
+
tab_changed: tab.changed,
|
|
328
|
+
matched_row: matched,
|
|
329
|
+
row_count: parsedRows.length,
|
|
330
|
+
rows: parsedRows,
|
|
331
|
+
scroll_probe
|
|
332
|
+
};
|
|
333
|
+
}
|