@reconcrap/boss-recommend-mcp 2.1.24 → 2.1.25

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.
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  clickNodeCenter,
3
+ describeNode,
4
+ getFrameDocumentNodeId,
3
5
  getNodeBox,
4
6
  getOuterHTML,
5
7
  querySelectorAll,
@@ -8,12 +10,10 @@ import {
8
10
  import { htmlToText } from "../../core/screening/index.js";
9
11
 
10
12
  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
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";
14
+ const TAB_CANDIDATE_SELECTOR = ".tab-hd > span, .tab-hd > div, .tab-hd > button, .tab-hd > li";
15
+ const SECTION_ROW_SELECTOR = ".record-item.mate-log-item";
16
+ const SECTION_ROW_CONTENT_SELECTOR = ".record-item.mate-log-item .content";
17
17
  const DETAIL_PANE_SELECTOR = ".resume-item-detail";
18
18
 
19
19
  function normalizeText(value) {
@@ -60,6 +60,7 @@ export function parseColleagueContactDate(text, {
60
60
  const raw = normalizeText(text);
61
61
  if (!raw) return null;
62
62
  const today = dateOnly(referenceDate) || dateOnly(new Date());
63
+ if (/(?:刚刚|刚才)/.test(raw)) return today;
63
64
  const relativeDays = raw.match(/(\d+)\s*天前/);
64
65
  if (relativeDays) {
65
66
  const days = Number.parseInt(relativeDays[1], 10);
@@ -127,7 +128,7 @@ async function queryAcrossRoots(client, roots, selector) {
127
128
  const matches = [];
128
129
  for (const root of roots || []) {
129
130
  if (!root?.nodeId) continue;
130
- const nodeIds = await querySelectorAll(client, root.nodeId, selector).catch(() => []);
131
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
131
132
  for (const nodeId of nodeIds) {
132
133
  matches.push({
133
134
  root: root.name,
@@ -141,167 +142,794 @@ async function queryAcrossRoots(client, roots, selector) {
141
142
  }
142
143
 
143
144
  function tabIsColleague(text) {
144
- return /同事沟通进度/.test(normalizeText(text));
145
+ return normalizeText(text) === "同事沟通进度";
145
146
  }
146
147
 
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
- };
148
+ function isUsableVisibleRowBox(box) {
149
+ return Number(box?.rect?.width || 0) > 2 && Number(box?.rect?.height || 0) > 2;
150
+ }
151
+
152
+ async function readVisibleNodeEvidence(client, nodeId) {
153
+ let box = null;
154
+ try {
155
+ box = await getNodeBox(client, nodeId);
156
+ } catch {
157
+ return null;
158
+ }
159
+ if (!isUsableVisibleRowBox(box)) return null;
160
+ const node = await describeNode(client, nodeId, { depth: 0, pierce: true });
161
+ if (!Number.isInteger(node?.backendNodeId) || node.backendNodeId <= 0) return null;
162
+ return {
163
+ node_id: nodeId,
164
+ backend_node_id: node.backendNodeId,
165
+ box: {
166
+ x: box.rect.x,
167
+ y: box.rect.y,
168
+ width: box.rect.width,
169
+ height: box.rect.height
157
170
  }
171
+ };
172
+ }
173
+
174
+ async function readSelectedColleagueTab(client, sectionNodeId) {
175
+ const selectedIds = await querySelectorAll(client, sectionNodeId, SECTION_SELECTED_TAB_SELECTOR);
176
+ const visibleSelected = [];
177
+ for (const nodeId of selectedIds) {
178
+ const evidence = await readVisibleNodeEvidence(client, nodeId);
179
+ if (!evidence) continue;
180
+ visibleSelected.push({
181
+ ...evidence,
182
+ text: normalizeText(await textForNode(client, nodeId))
183
+ });
184
+ }
185
+ const selected = visibleSelected.length === 1 && tabIsColleague(visibleSelected[0].text);
186
+ return {
187
+ selected,
188
+ selected_text: visibleSelected[0]?.text || "",
189
+ selected_tab_count: visibleSelected.length,
190
+ node_id: visibleSelected[0]?.node_id || null,
191
+ backend_node_id: visibleSelected[0]?.backend_node_id || null,
192
+ box: visibleSelected[0]?.box || null
193
+ };
194
+ }
195
+
196
+ async function ensureColleagueTabSelected(client, sectionNodeId) {
197
+ const before = await readSelectedColleagueTab(client, sectionNodeId);
198
+ if (before.selected) {
199
+ return {
200
+ ...before,
201
+ changed: false
202
+ };
158
203
  }
159
204
 
160
- const candidateIds = await querySelectorAll(client, sectionNodeId, TAB_CANDIDATE_SELECTOR).catch(() => []);
205
+ const candidateIds = await querySelectorAll(client, sectionNodeId, TAB_CANDIDATE_SELECTOR);
161
206
  for (const nodeId of candidateIds) {
162
- const text = normalizeText(await textForNode(client, nodeId).catch(() => ""));
207
+ const evidence = await readVisibleNodeEvidence(client, nodeId);
208
+ if (!evidence) continue;
209
+ const text = normalizeText(await textForNode(client, nodeId));
163
210
  if (!tabIsColleague(text)) continue;
164
211
  const box = await clickNodeCenter(client, nodeId, { scrollIntoView: true });
165
212
  await sleep(500);
213
+ const after = await readSelectedColleagueTab(client, sectionNodeId);
166
214
  return {
167
- selected: true,
215
+ ...after,
168
216
  changed: true,
169
- selected_text: text,
170
217
  click_box: {
171
218
  rect: box.rect,
172
219
  center: box.center
173
- }
220
+ },
221
+ reason: after.selected ? null : "colleague_tab_selection_not_verified"
174
222
  };
175
223
  }
176
224
 
177
225
  return {
178
- selected: false,
226
+ ...before,
179
227
  changed: false,
180
- selected_text: selectedIds.length
181
- ? normalizeText(await textForNode(client, selectedIds[0]).catch(() => ""))
182
- : ""
228
+ reason: "colleague_tab_candidate_not_visible"
183
229
  };
184
230
  }
185
231
 
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;
232
+ async function readContactRows(client, sectionNodeId, sectionBackendNodeId, sectionRoot = "") {
233
+ const contentNodeIds = await querySelectorAll(client, sectionNodeId, SECTION_ROW_CONTENT_SELECTOR);
234
+ const fallbackNodeIds = contentNodeIds.length
235
+ ? []
236
+ : await querySelectorAll(client, sectionNodeId, SECTION_ROW_SELECTOR);
237
+ const nodeIds = contentNodeIds.length ? contentNodeIds : fallbackNodeIds;
190
238
  const rows = [];
239
+ const unreadableRows = [];
191
240
  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;
241
+ for (const nodeId of nodeIds) {
242
+ let box = null;
243
+ try {
244
+ box = await getNodeBox(client, nodeId);
245
+ } catch (error) {
246
+ unreadableRows.push({
247
+ node_id: nodeId,
248
+ stage: "box",
249
+ error: error?.message || String(error)
250
+ });
251
+ continue;
252
+ }
253
+ if (!isUsableVisibleRowBox(box)) {
254
+ unreadableRows.push({
255
+ node_id: nodeId,
256
+ stage: "visible_box"
257
+ });
258
+ continue;
259
+ }
260
+ const node = await describeNode(client, nodeId, { depth: 0, pierce: true });
261
+ if (!Number.isInteger(node?.backendNodeId) || node.backendNodeId <= 0) {
262
+ unreadableRows.push({
263
+ node_id: nodeId,
264
+ stage: "backend_identity"
265
+ });
266
+ continue;
267
+ }
268
+ const text = normalizeText(await textForNode(client, nodeId));
269
+ if (!text) {
270
+ unreadableRows.push({
271
+ node_id: nodeId,
272
+ backend_node_id: node.backendNodeId,
273
+ stage: "text"
274
+ });
275
+ continue;
276
+ }
277
+ if (seen.has(text)) continue;
195
278
  seen.add(text);
196
279
  rows.push({
197
280
  text,
198
- root: match.root,
199
- selector: match.selector,
200
- node_id: match.node_id
281
+ root: sectionRoot,
282
+ selector: contentNodeIds.length ? SECTION_ROW_CONTENT_SELECTOR : SECTION_ROW_SELECTOR,
283
+ node_id: nodeId,
284
+ backend_node_id: node.backendNodeId,
285
+ section_node_id: sectionNodeId,
286
+ section_backend_node_id: sectionBackendNodeId,
287
+ visible: true,
288
+ box: {
289
+ x: box.rect.x,
290
+ y: box.rect.y,
291
+ width: box.rect.width,
292
+ height: box.rect.height
293
+ }
294
+ });
295
+ }
296
+ return {
297
+ rows,
298
+ unreadable_rows: unreadableRows
299
+ };
300
+ }
301
+
302
+ function paneBindingMatches(sectionEvidence, selectedEvidence, section, tab) {
303
+ return Boolean(
304
+ sectionEvidence
305
+ && sectionEvidence.backend_node_id === section.backend_node_id
306
+ && selectedEvidence?.selected === true
307
+ && selectedEvidence.selected_tab_count === 1
308
+ && selectedEvidence.node_id === tab.node_id
309
+ && selectedEvidence.backend_node_id === tab.backend_node_id
310
+ );
311
+ }
312
+
313
+ async function readPaneBindingAtPosition(client, section, tab) {
314
+ const sectionEvidence = await readVisibleNodeEvidence(client, section.node_id);
315
+ const selectedEvidence = await readSelectedColleagueTab(client, section.node_id);
316
+ return {
317
+ verified: paneBindingMatches(sectionEvidence, selectedEvidence, section, tab),
318
+ section_visible: Boolean(sectionEvidence),
319
+ section_node_id: section.node_id,
320
+ section_backend_node_id: sectionEvidence?.backend_node_id || null,
321
+ selected_tab_node_id: selectedEvidence.node_id || null,
322
+ selected_tab_backend_node_id: selectedEvidence.backend_node_id || null,
323
+ selected_tab_text: selectedEvidence.selected_text,
324
+ selected_tab_count: selectedEvidence.selected_tab_count
325
+ };
326
+ }
327
+
328
+ async function resolveBoundScrollTarget(client, scopes, section) {
329
+ const sectionScope = (scopes || []).find((scope) => (
330
+ scope?.name === section.root
331
+ && scope?.nodeId === section.root_node_id
332
+ ));
333
+ const detailPanes = sectionScope
334
+ ? await queryAcrossRoots(client, [sectionScope], DETAIL_PANE_SELECTOR)
335
+ : [];
336
+ const visiblePanes = [];
337
+ for (const pane of detailPanes) {
338
+ const evidence = await readVisibleNodeEvidence(client, pane.node_id);
339
+ if (!evidence) continue;
340
+ visiblePanes.push({
341
+ ...pane,
342
+ ...evidence
201
343
  });
202
344
  }
203
- return rows;
345
+ if (visiblePanes.length > 1) {
346
+ return {
347
+ ok: false,
348
+ reason: "scroll_target_ambiguous",
349
+ visible_target_count: visiblePanes.length
350
+ };
351
+ }
352
+ if (visiblePanes.length === 1) {
353
+ return {
354
+ ok: true,
355
+ node_id: visiblePanes[0].node_id,
356
+ backend_node_id: visiblePanes[0].backend_node_id,
357
+ selector: visiblePanes[0].selector,
358
+ box: visiblePanes[0].box
359
+ };
360
+ }
361
+ const sectionEvidence = await readVisibleNodeEvidence(client, section.node_id);
362
+ if (!sectionEvidence || sectionEvidence.backend_node_id !== section.backend_node_id) {
363
+ return {
364
+ ok: false,
365
+ reason: "scroll_target_box_unavailable",
366
+ visible_target_count: 0
367
+ };
368
+ }
369
+ return {
370
+ ok: true,
371
+ node_id: section.node_id,
372
+ backend_node_id: section.backend_node_id,
373
+ selector: COLLEAGUE_SECTION_SELECTOR,
374
+ box: sectionEvidence.box
375
+ };
204
376
  }
205
377
 
206
- async function scrollDetailPaneForRows(client, roots, sectionNodeId, {
207
- maxScrolls = 4,
378
+ function addRowsFromPosition(byText, positionRows, positionIndex) {
379
+ for (const row of positionRows) {
380
+ const existing = byText.get(row.text);
381
+ if (existing) {
382
+ if (!existing.observed_at_positions.includes(positionIndex)) {
383
+ existing.observed_at_positions.push(positionIndex);
384
+ }
385
+ continue;
386
+ }
387
+ byText.set(row.text, {
388
+ ...row,
389
+ observed_at_positions: [positionIndex]
390
+ });
391
+ }
392
+ }
393
+
394
+ async function scanContactRowsAcrossScrollPositions(client, scopes, section, tab, {
395
+ enabled = true,
396
+ maxScrolls = 24,
208
397
  settleMs = 350
209
398
  } = {}) {
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 {
399
+ const stableEndSamplesRequired = 2;
400
+ const boundedMaxScrolls = enabled
401
+ ? Math.max(
402
+ stableEndSamplesRequired,
403
+ Math.min(48, Number.isFinite(Number(maxScrolls)) ? Math.floor(Number(maxScrolls)) : 24)
404
+ )
405
+ : 0;
406
+ const scrollTarget = enabled
407
+ ? await resolveBoundScrollTarget(client, scopes, section)
408
+ : null;
409
+ if (enabled && !scrollTarget?.ok) {
410
+ return {
411
+ completed: false,
412
+ coverage_verified: false,
413
+ reason: scrollTarget?.reason || "scroll_target_box_unavailable",
414
+ scrolls_requested: boundedMaxScrolls,
415
+ scrolls_completed: 0,
416
+ position_count: 0,
417
+ positions: [],
418
+ rows: []
419
+ };
420
+ }
421
+
422
+ const rowsByText = new Map();
423
+ const positions = [];
424
+ let scrollsCompleted = 0;
425
+ let previousRowSignature = null;
426
+ let previousRowLayoutSignature = null;
427
+ let stableSignatureCount = 0;
428
+ let effectiveScrollCount = 0;
429
+ let endProof = null;
430
+ const targetHeight = Number(scrollTarget?.box?.height || 0);
431
+ const stepDeltaY = enabled
432
+ ? Math.max(1, Math.min(480, Math.floor(targetHeight * 0.65)))
433
+ : 0;
434
+
435
+ for (let positionIndex = 0; positionIndex <= boundedMaxScrolls; positionIndex += 1) {
436
+ const bindingBefore = await readPaneBindingAtPosition(client, section, tab);
437
+ if (!bindingBefore.verified) {
438
+ return {
439
+ completed: false,
440
+ coverage_verified: false,
441
+ reason: "colleague_binding_lost",
442
+ scrolls_requested: boundedMaxScrolls,
443
+ scrolls_completed: scrollsCompleted,
444
+ position_count: positions.length,
445
+ positions,
446
+ rows: [],
447
+ failed_position: positionIndex,
448
+ failed_binding_phase: "before_rows",
449
+ binding: bindingBefore
450
+ };
451
+ }
452
+
453
+ const positionRead = await readContactRows(
454
+ client,
455
+ section.node_id,
456
+ section.backend_node_id,
457
+ section.root
458
+ );
459
+ const positionRows = positionRead.rows;
460
+ const bindingAfter = await readPaneBindingAtPosition(client, section, tab);
461
+ const rowIdentityKeys = positionRows
462
+ .map((row) => `${row.backend_node_id}:${row.text}`)
463
+ .sort();
464
+ const rowTexts = positionRows.map((row) => row.text).sort();
465
+ const rowSignature = JSON.stringify(rowIdentityKeys);
466
+ const orderedRows = positionRows
467
+ .map((row) => ({
468
+ backend_node_id: row.backend_node_id,
469
+ text: row.text,
470
+ x: Math.round(Number(row?.box?.x || 0)),
471
+ y: Math.round(Number(row?.box?.y || 0)),
472
+ width: Math.round(Number(row?.box?.width || 0)),
473
+ height: Math.round(Number(row?.box?.height || 0))
474
+ }))
475
+ .sort((left, right) => (
476
+ left.y - right.y
477
+ || left.x - right.x
478
+ || left.backend_node_id - right.backend_node_id
479
+ ));
480
+ const rowLayoutKeys = orderedRows.map((row) => (
481
+ `${row.text}:${row.x}:${row.y}:${row.width}:${row.height}`
482
+ ));
483
+ const rowLayoutSignature = JSON.stringify(rowLayoutKeys);
484
+ const newRowTexts = rowTexts.filter((text) => !rowsByText.has(text));
485
+ const scrollEffectObserved = Boolean(
486
+ scrollsCompleted > 0
487
+ && rowLayoutSignature !== previousRowLayoutSignature
488
+ );
489
+ if (scrollEffectObserved) effectiveScrollCount += 1;
490
+ if (
491
+ scrollsCompleted > 0
492
+ && rowSignature === previousRowSignature
493
+ && newRowTexts.length === 0
494
+ ) {
495
+ stableSignatureCount += 1;
496
+ } else {
497
+ stableSignatureCount = 0;
498
+ }
499
+ const positionEvidence = {
500
+ position_index: positionIndex,
501
+ sampled_after_scroll_count: scrollsCompleted,
502
+ row_count: positionRows.length,
503
+ unreadable_row_count: positionRead.unreadable_rows.length,
504
+ row_backend_node_ids: positionRows.map((row) => row.backend_node_id),
505
+ row_texts: rowTexts,
506
+ row_identity_keys: rowIdentityKeys,
507
+ row_signature: rowSignature,
508
+ ordered_row_layout: orderedRows,
509
+ ordered_row_layout_keys: rowLayoutKeys,
510
+ row_layout_signature: rowLayoutSignature,
511
+ scroll_effect_observed: scrollEffectObserved,
512
+ cumulative_effective_scroll_count: effectiveScrollCount,
513
+ new_row_count: newRowTexts.length,
514
+ new_row_texts: newRowTexts,
515
+ stable_signature_count: stableSignatureCount,
516
+ binding_before_verified: bindingBefore.verified,
517
+ binding_after_verified: bindingAfter.verified
518
+ };
519
+ positions.push(positionEvidence);
520
+ if (!bindingAfter.verified) {
521
+ return {
522
+ completed: false,
523
+ coverage_verified: false,
524
+ reason: "colleague_binding_lost",
525
+ scrolls_requested: boundedMaxScrolls,
526
+ scrolls_completed: scrollsCompleted,
527
+ position_count: positions.length,
528
+ positions,
529
+ rows: [],
530
+ failed_position: positionIndex,
531
+ failed_binding_phase: "after_rows",
532
+ binding: bindingAfter
533
+ };
534
+ }
535
+ if (positionRead.unreadable_rows.length) {
536
+ return {
537
+ completed: false,
538
+ coverage_verified: false,
539
+ reason: "colleague_row_evidence_unavailable",
540
+ scrolls_requested: boundedMaxScrolls,
541
+ scrolls_completed: scrollsCompleted,
542
+ position_count: positions.length,
543
+ positions,
544
+ rows: [],
545
+ failed_position: positionIndex,
546
+ unreadable_rows: positionRead.unreadable_rows
547
+ };
548
+ }
549
+ addRowsFromPosition(rowsByText, positionRows, positionIndex);
550
+ previousRowSignature = rowSignature;
551
+ previousRowLayoutSignature = rowLayoutSignature;
552
+
553
+ if (
554
+ enabled
555
+ && stableSignatureCount >= stableEndSamplesRequired
556
+ && effectiveScrollCount > 0
557
+ ) {
558
+ endProof = {
559
+ verified: true,
560
+ method: "effective_scroll_then_repeated_identical_rows",
561
+ stable_samples_required: stableEndSamplesRequired,
562
+ stable_samples_observed: stableSignatureCount,
563
+ effective_scroll_observed: true,
564
+ effective_scroll_count: effectiveScrollCount,
565
+ end_position_index: positionIndex,
566
+ end_scroll_count: scrollsCompleted,
567
+ row_signature: rowSignature,
568
+ additional_wheel_attempts_without_change: stableSignatureCount
569
+ };
570
+ break;
571
+ }
572
+
573
+ if (positionIndex === boundedMaxScrolls) break;
574
+
575
+ const freshTarget = await readVisibleNodeEvidence(client, scrollTarget.node_id);
576
+ if (!freshTarget || freshTarget.backend_node_id !== scrollTarget.backend_node_id) {
577
+ return {
578
+ completed: false,
579
+ coverage_verified: false,
580
+ reason: "scroll_target_binding_lost",
581
+ scrolls_requested: boundedMaxScrolls,
582
+ scrolls_completed: scrollsCompleted,
583
+ position_count: positions.length,
584
+ positions,
585
+ rows: [],
586
+ failed_position: positionIndex,
587
+ target_selector: scrollTarget.selector
588
+ };
589
+ }
216
590
  try {
217
- box = await getNodeBox(client, sectionNodeId);
218
- } catch {
219
- return { scrolls: 0, reason: "scroll_target_box_unavailable" };
591
+ await client.Input.dispatchMouseEvent({
592
+ type: "mouseWheel",
593
+ x: freshTarget.box.x + (freshTarget.box.width / 2),
594
+ y: freshTarget.box.y + (freshTarget.box.height / 2),
595
+ deltaY: stepDeltaY,
596
+ deltaX: 0
597
+ });
598
+ } catch (error) {
599
+ return {
600
+ completed: false,
601
+ coverage_verified: false,
602
+ reason: "scroll_dispatch_failed",
603
+ error: error?.message || String(error),
604
+ scrolls_requested: boundedMaxScrolls,
605
+ scrolls_completed: scrollsCompleted,
606
+ position_count: positions.length,
607
+ positions,
608
+ rows: [],
609
+ failed_position: positionIndex,
610
+ target_selector: scrollTarget.selector
611
+ };
220
612
  }
613
+ scrollsCompleted += 1;
614
+ await sleep(Math.max(0, Number(settleMs) || 0));
221
615
  }
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
- }
616
+
617
+ const allPositionEvidenceVerified = positions.every((position) => (
618
+ position.binding_before_verified === true
619
+ && position.binding_after_verified === true
620
+ ));
621
+ const completed = enabled
622
+ ? Boolean(endProof?.verified && allPositionEvidenceVerified)
623
+ : Boolean(positions.length === 1 && scrollsCompleted === 0 && allPositionEvidenceVerified);
624
+ const coverageVerified = Boolean(
625
+ enabled
626
+ && completed
627
+ && endProof?.verified === true
628
+ && positions.every((position) => position.row_count > 0)
629
+ );
630
+ const capReachedWithoutEnd = Boolean(
631
+ enabled
632
+ && scrollsCompleted >= boundedMaxScrolls
633
+ && endProof?.verified !== true
634
+ );
234
635
  return {
235
- scrolls,
236
- target_selector: detailPanes[0]?.selector || COLLEAGUE_SECTION_SELECTOR
636
+ completed,
637
+ coverage_verified: coverageVerified,
638
+ reason: completed
639
+ ? !enabled
640
+ ? "scroll_scan_disabled"
641
+ : coverageVerified
642
+ ? null
643
+ : "scroll_position_rows_missing"
644
+ : capReachedWithoutEnd
645
+ ? "scroll_end_not_verified_before_cap"
646
+ : "scroll_scan_incomplete",
647
+ scrolls_requested: boundedMaxScrolls,
648
+ scrolls_completed: scrollsCompleted,
649
+ position_count: positions.length,
650
+ positions,
651
+ target_selector: scrollTarget?.selector || null,
652
+ target_node_id: scrollTarget?.node_id || null,
653
+ target_backend_node_id: scrollTarget?.backend_node_id || null,
654
+ step_delta_y: stepDeltaY,
655
+ overlap_ratio: 0.35,
656
+ effective_scroll_count: effectiveScrollCount,
657
+ end_proof: endProof || {
658
+ verified: false,
659
+ method: "effective_scroll_then_repeated_identical_rows",
660
+ stable_samples_required: stableEndSamplesRequired,
661
+ stable_samples_observed: stableSignatureCount,
662
+ effective_scroll_observed: effectiveScrollCount > 0,
663
+ effective_scroll_count: effectiveScrollCount,
664
+ end_position_index: null,
665
+ end_scroll_count: null,
666
+ row_signature: null,
667
+ additional_wheel_attempts_without_change: stableSignatureCount
668
+ },
669
+ cap_reached_without_end: capReachedWithoutEnd,
670
+ coverage_gap_positions: positions
671
+ .filter((position) => position.row_count <= 0)
672
+ .map((position) => position.position_index),
673
+ rows: Array.from(rowsByText.values())
237
674
  };
238
675
  }
239
676
 
240
- async function waitForColleagueSections(client, roots, {
677
+ async function resolveColleagueDetailScopes(client, detailState) {
678
+ const scopes = [];
679
+ if (Number.isInteger(detailState?.popup?.node_id) && detailState.popup.node_id > 0) {
680
+ const popupEvidence = await readVisibleNodeEvidence(client, detailState.popup.node_id);
681
+ if (popupEvidence) {
682
+ scopes.push({
683
+ name: "popup",
684
+ nodeId: detailState.popup.node_id,
685
+ backendNodeId: popupEvidence.backend_node_id
686
+ });
687
+ }
688
+ }
689
+ if (Number.isInteger(detailState?.resumeIframe?.node_id) && detailState.resumeIframe.node_id > 0) {
690
+ try {
691
+ const documentNodeId = await getFrameDocumentNodeId(client, detailState.resumeIframe.node_id);
692
+ scopes.push({
693
+ name: "resume_iframe",
694
+ nodeId: documentNodeId,
695
+ backendNodeId: null
696
+ });
697
+ } catch {
698
+ // A stale or inaccessible iframe cannot be used as colleague-contact evidence.
699
+ }
700
+ }
701
+ return scopes;
702
+ }
703
+
704
+ async function waitForColleagueSections(client, scopes, {
241
705
  timeoutMs = 1000,
242
706
  intervalMs = 150
243
707
  } = {}) {
244
708
  const started = Date.now();
245
709
  let sections = [];
710
+ let pollCount = 0;
711
+ let stableScopeCount = 0;
712
+ let scopeBindingLost = false;
246
713
  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;
714
+ pollCount += 1;
715
+ stableScopeCount = 0;
716
+ for (const scope of scopes) {
717
+ if (!Number.isInteger(scope?.backendNodeId) || scope.backendNodeId <= 0) continue;
718
+ const evidence = await readVisibleNodeEvidence(client, scope.nodeId);
719
+ if (!evidence || evidence.backend_node_id !== scope.backendNodeId) {
720
+ scopeBindingLost = true;
721
+ break;
722
+ }
723
+ stableScopeCount += 1;
724
+ }
725
+ if (scopeBindingLost) break;
726
+ const matches = await queryAcrossRoots(client, scopes, COLLEAGUE_SECTION_SELECTOR);
727
+ sections = [];
728
+ for (const match of matches) {
729
+ const evidence = await readVisibleNodeEvidence(client, match.node_id);
730
+ if (!evidence) continue;
731
+ sections.push({
732
+ ...match,
733
+ backend_node_id: evidence.backend_node_id,
734
+ visible: true,
735
+ box: evidence.box
736
+ });
737
+ }
738
+ if (sections.length) {
739
+ return {
740
+ sections,
741
+ absence_probe: {
742
+ verified: false,
743
+ selector: COLLEAGUE_SECTION_SELECTOR,
744
+ scope_count: scopes.length,
745
+ stable_scope_count: stableScopeCount,
746
+ poll_count: pollCount,
747
+ elapsed_ms: Date.now() - started,
748
+ timeout_ms: Math.max(0, Number(timeoutMs) || 0),
749
+ full_window_elapsed: false,
750
+ query_error_count: 0,
751
+ scope_binding_lost: false,
752
+ scope_backend_node_ids: scopes
753
+ .map((scope) => Number(scope?.backendNodeId))
754
+ .filter((backendNodeId) => Number.isInteger(backendNodeId) && backendNodeId > 0)
755
+ }
756
+ };
757
+ }
758
+ const elapsedMs = Date.now() - started;
759
+ const normalizedTimeoutMs = Math.max(0, Number(timeoutMs) || 0);
760
+ if (
761
+ elapsedMs >= normalizedTimeoutMs
762
+ && (normalizedTimeoutMs === 0 || pollCount >= 2)
763
+ ) break;
764
+ await sleep(Math.max(1, Number(intervalMs) || 0));
765
+ } while (true);
766
+ const elapsedMs = Date.now() - started;
767
+ const normalizedTimeoutMs = Math.max(0, Number(timeoutMs) || 0);
768
+ const stableBackendNodeIds = scopes
769
+ .map((scope) => Number(scope?.backendNodeId))
770
+ .filter((backendNodeId) => Number.isInteger(backendNodeId) && backendNodeId > 0);
771
+ return {
772
+ sections,
773
+ absence_probe: {
774
+ verified: Boolean(
775
+ !scopeBindingLost
776
+ && scopes.length > 0
777
+ && stableScopeCount > 0
778
+ && stableBackendNodeIds.length === stableScopeCount
779
+ && pollCount >= 2
780
+ && elapsedMs >= normalizedTimeoutMs
781
+ ),
782
+ selector: COLLEAGUE_SECTION_SELECTOR,
783
+ scope_count: scopes.length,
784
+ stable_scope_count: stableScopeCount,
785
+ poll_count: pollCount,
786
+ elapsed_ms: elapsedMs,
787
+ timeout_ms: normalizedTimeoutMs,
788
+ full_window_elapsed: elapsedMs >= normalizedTimeoutMs,
789
+ query_error_count: 0,
790
+ scope_binding_lost: scopeBindingLost,
791
+ scope_backend_node_ids: stableBackendNodeIds
792
+ }
793
+ };
253
794
  }
254
795
 
255
796
  export async function inspectRecentColleagueContact(client, detailState, {
256
797
  referenceDate = new Date(),
257
798
  windowDays = 14,
258
799
  scroll = true,
800
+ scrollMaxSteps = 24,
801
+ scrollSettleMs = 350,
259
802
  sectionWaitMs = 1000,
260
803
  sectionPollMs = 150
261
804
  } = {}) {
262
- const roots = detailState?.roots || [];
263
- const sections = await waitForColleagueSections(client, roots, {
805
+ const scopes = await resolveColleagueDetailScopes(client, detailState);
806
+ if (!scopes.length) {
807
+ return {
808
+ checked: false,
809
+ panel_found: false,
810
+ recent: null,
811
+ indeterminate: true,
812
+ reason: "detail_scope_unavailable",
813
+ window_days: windowDays,
814
+ rows: []
815
+ };
816
+ }
817
+ const sectionWait = await waitForColleagueSections(client, scopes, {
264
818
  timeoutMs: sectionWaitMs,
265
819
  intervalMs: sectionPollMs
266
820
  });
821
+ const sections = sectionWait.sections;
267
822
  if (!sections.length) {
823
+ const absenceVerified = sectionWait.absence_probe?.verified === true;
268
824
  return {
269
- checked: true,
825
+ checked: absenceVerified,
270
826
  panel_found: false,
271
- recent: false,
827
+ recent: absenceVerified ? false : null,
828
+ indeterminate: !absenceVerified,
272
829
  reason: "panel_missing",
273
830
  window_days: windowDays,
831
+ absence_probe: sectionWait.absence_probe,
832
+ rows: []
833
+ };
834
+ }
835
+ if (sections.length !== 1) {
836
+ return {
837
+ checked: false,
838
+ panel_found: true,
839
+ recent: null,
840
+ indeterminate: true,
841
+ reason: "panel_ambiguous",
842
+ window_days: windowDays,
843
+ visible_section_count: sections.length,
274
844
  rows: []
275
845
  };
276
846
  }
277
847
 
278
848
  const section = sections[0];
279
- const tabHeader = await queryAcrossRoots(client, roots, COLLEAGUE_TAB_SELECTOR);
849
+ const tabHeaderIds = await querySelectorAll(client, section.node_id, ".tab-hd");
850
+ let visibleTabHeaderCount = 0;
851
+ for (const nodeId of tabHeaderIds) {
852
+ if (await readVisibleNodeEvidence(client, nodeId)) visibleTabHeaderCount += 1;
853
+ }
280
854
  const tab = await ensureColleagueTabSelected(client, section.node_id);
281
855
  if (!tab.selected) {
282
856
  return {
283
- checked: true,
857
+ checked: false,
284
858
  panel_found: true,
285
- recent: false,
859
+ recent: null,
860
+ indeterminate: true,
286
861
  reason: "colleague_tab_unavailable",
287
862
  window_days: windowDays,
288
863
  section_root: section.root,
289
- tab_header_found: tabHeader.length > 0,
864
+ section_node_id: section.node_id,
865
+ section_backend_node_id: section.backend_node_id,
866
+ tab_header_found: visibleTabHeaderCount === 1,
290
867
  selected_tab_text: tab.selected_text,
868
+ selected_tab_count: tab.selected_tab_count,
869
+ pane_binding_verified: false,
291
870
  rows: []
292
871
  };
293
872
  }
294
873
 
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);
874
+ const scrollScan = await scanContactRowsAcrossScrollPositions(
875
+ client,
876
+ scopes,
877
+ section,
878
+ tab,
879
+ {
880
+ enabled: scroll,
881
+ maxScrolls: scrollMaxSteps,
882
+ settleMs: scrollSettleMs
303
883
  }
304
- rows = Array.from(byText.values());
884
+ );
885
+ const {
886
+ rows = [],
887
+ ...scroll_probe
888
+ } = scrollScan;
889
+
890
+ const sectionAfter = await readVisibleNodeEvidence(client, section.node_id);
891
+ const selectedAfter = await readSelectedColleagueTab(client, section.node_id);
892
+ const binding = {
893
+ verified: Boolean(
894
+ sectionAfter
895
+ && sectionAfter.backend_node_id === section.backend_node_id
896
+ && tab.selected === true
897
+ && selectedAfter.selected === true
898
+ && selectedAfter.backend_node_id === tab.backend_node_id
899
+ && selectedAfter.selected_tab_count === 1
900
+ ),
901
+ detail_root_node_id: scopes[0]?.nodeId || null,
902
+ section_node_id: section.node_id,
903
+ section_backend_node_id: section.backend_node_id,
904
+ section_visible: Boolean(sectionAfter),
905
+ selected_tab_node_id: tab.node_id || null,
906
+ selected_tab_backend_node_id: tab.backend_node_id || null,
907
+ selected_tab_text: tab.selected_text,
908
+ selected_tab_visible: tab.selected === true,
909
+ selected_tab_count: tab.selected_tab_count,
910
+ selection_reverified_after_rows: selectedAfter.selected === true,
911
+ selected_tab_backend_node_id_after_rows: selectedAfter.backend_node_id || null,
912
+ row_scope: "selected_section_descendants"
913
+ };
914
+ if (!binding.verified) {
915
+ return {
916
+ checked: false,
917
+ panel_found: true,
918
+ recent: null,
919
+ indeterminate: true,
920
+ reason: "colleague_binding_lost",
921
+ window_days: windowDays,
922
+ section_root: section.root,
923
+ section_node_id: section.node_id,
924
+ section_backend_node_id: section.backend_node_id,
925
+ tab_header_found: visibleTabHeaderCount === 1,
926
+ selected_tab_text: selectedAfter.selected_text || tab.selected_text,
927
+ selected_tab_count: selectedAfter.selected_tab_count,
928
+ pane_binding_verified: false,
929
+ binding,
930
+ rows: [],
931
+ scroll_probe
932
+ };
305
933
  }
306
934
 
307
935
  const parsedRows = rows.map((row) => {
@@ -315,17 +943,135 @@ export async function inspectRecentColleagueContact(client, detailState, {
315
943
  };
316
944
  });
317
945
  const matched = parsedRows.find((row) => row.within_window) || null;
946
+ if (matched) {
947
+ return {
948
+ checked: true,
949
+ panel_found: true,
950
+ recent: true,
951
+ indeterminate: false,
952
+ reason: "recent_colleague_contact_found",
953
+ window_days: windowDays,
954
+ section_root: section.root,
955
+ section_node_id: section.node_id,
956
+ section_backend_node_id: section.backend_node_id,
957
+ tab_header_found: visibleTabHeaderCount === 1,
958
+ selected_tab_text: tab.selected_text,
959
+ selected_tab_count: tab.selected_tab_count,
960
+ pane_binding_verified: true,
961
+ binding,
962
+ tab_changed: tab.changed,
963
+ matched_row: matched,
964
+ row_count: parsedRows.length,
965
+ rows: parsedRows,
966
+ scroll_probe
967
+ };
968
+ }
969
+ if (!scroll_probe.completed) {
970
+ return {
971
+ checked: false,
972
+ panel_found: true,
973
+ recent: null,
974
+ indeterminate: true,
975
+ reason: scroll_probe.reason || "colleague_scroll_scan_incomplete",
976
+ window_days: windowDays,
977
+ section_root: section.root,
978
+ section_node_id: section.node_id,
979
+ section_backend_node_id: section.backend_node_id,
980
+ tab_header_found: visibleTabHeaderCount === 1,
981
+ selected_tab_text: tab.selected_text,
982
+ selected_tab_count: tab.selected_tab_count,
983
+ pane_binding_verified: true,
984
+ binding,
985
+ rows: parsedRows,
986
+ scroll_probe
987
+ };
988
+ }
989
+ if (!parsedRows.length) {
990
+ return {
991
+ checked: false,
992
+ panel_found: true,
993
+ recent: null,
994
+ indeterminate: true,
995
+ reason: "contact_rows_missing",
996
+ window_days: windowDays,
997
+ section_root: section.root,
998
+ section_node_id: section.node_id,
999
+ section_backend_node_id: section.backend_node_id,
1000
+ tab_header_found: visibleTabHeaderCount === 1,
1001
+ selected_tab_text: tab.selected_text,
1002
+ selected_tab_count: tab.selected_tab_count,
1003
+ pane_binding_verified: true,
1004
+ binding,
1005
+ tab_changed: tab.changed,
1006
+ row_count: 0,
1007
+ rows: [],
1008
+ scroll_probe
1009
+ };
1010
+ }
1011
+ const unparsedRows = parsedRows.filter((row) => !row.parsed_date);
1012
+ if (unparsedRows.length) {
1013
+ return {
1014
+ checked: false,
1015
+ panel_found: true,
1016
+ recent: null,
1017
+ indeterminate: true,
1018
+ reason: "contact_date_unparseable",
1019
+ window_days: windowDays,
1020
+ section_root: section.root,
1021
+ section_node_id: section.node_id,
1022
+ section_backend_node_id: section.backend_node_id,
1023
+ tab_header_found: visibleTabHeaderCount === 1,
1024
+ selected_tab_text: tab.selected_text,
1025
+ selected_tab_count: tab.selected_tab_count,
1026
+ pane_binding_verified: true,
1027
+ binding,
1028
+ tab_changed: tab.changed,
1029
+ row_count: parsedRows.length,
1030
+ unparsed_row_count: unparsedRows.length,
1031
+ unparsed_rows: unparsedRows,
1032
+ rows: parsedRows,
1033
+ scroll_probe
1034
+ };
1035
+ }
1036
+ if (!scroll_probe.coverage_verified) {
1037
+ return {
1038
+ checked: false,
1039
+ panel_found: true,
1040
+ recent: null,
1041
+ indeterminate: true,
1042
+ reason: scroll_probe.reason || "colleague_scroll_scan_incomplete",
1043
+ window_days: windowDays,
1044
+ section_root: section.root,
1045
+ section_node_id: section.node_id,
1046
+ section_backend_node_id: section.backend_node_id,
1047
+ tab_header_found: visibleTabHeaderCount === 1,
1048
+ selected_tab_text: tab.selected_text,
1049
+ selected_tab_count: tab.selected_tab_count,
1050
+ pane_binding_verified: true,
1051
+ binding,
1052
+ tab_changed: tab.changed,
1053
+ row_count: parsedRows.length,
1054
+ rows: parsedRows,
1055
+ scroll_probe
1056
+ };
1057
+ }
318
1058
  return {
319
1059
  checked: true,
320
1060
  panel_found: true,
321
- recent: Boolean(matched),
322
- reason: matched ? "recent_colleague_contact_found" : "no_recent_colleague_contact",
1061
+ recent: false,
1062
+ indeterminate: false,
1063
+ reason: "no_recent_colleague_contact",
323
1064
  window_days: windowDays,
324
1065
  section_root: section.root,
325
- tab_header_found: tabHeader.length > 0,
1066
+ section_node_id: section.node_id,
1067
+ section_backend_node_id: section.backend_node_id,
1068
+ tab_header_found: visibleTabHeaderCount === 1,
326
1069
  selected_tab_text: tab.selected_text,
1070
+ selected_tab_count: tab.selected_tab_count,
1071
+ pane_binding_verified: true,
1072
+ binding,
327
1073
  tab_changed: tab.changed,
328
- matched_row: matched,
1074
+ matched_row: null,
329
1075
  row_count: parsedRows.length,
330
1076
  rows: parsedRows,
331
1077
  scroll_probe