@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,6 @@
1
1
  import {
2
2
  clickPoint,
3
+ describeNode,
3
4
  getFrameDocumentNodeId,
4
5
  getAttributesMap,
5
6
  getNodeBox,
@@ -166,7 +167,7 @@ export function classifyGreetControl({
166
167
  const className = normalizeText(attributes.class || "");
167
168
  const title = normalizeText(attributes.title || attributes["aria-label"] || "");
168
169
  const combined = lowerText(className, title);
169
- const continueChat = labelText.length <= 40 && /继续沟通/.test(labelText);
170
+ const continueChat = labelText === "继续沟通";
170
171
  const greetQuota = parseGreetQuota(labelText || title);
171
172
  const greetEntry = (
172
173
  GREET_EXACT_LABEL_PATTERN.test(labelText)
@@ -196,9 +197,11 @@ async function readActionNode(client, {
196
197
  nodeId,
197
198
  kind
198
199
  }) {
199
- const [attributes, outerHTML] = await Promise.all([
200
+ const [attributes, outerHTML, described, describedRoot] = await Promise.all([
200
201
  getAttributesMap(client, nodeId),
201
- getOuterHTML(client, nodeId)
202
+ getOuterHTML(client, nodeId),
203
+ describeNode(client, nodeId),
204
+ describeNode(client, root.nodeId, { depth: 0, pierce: true })
202
205
  ]);
203
206
  let box = null;
204
207
  let visible = false;
@@ -213,8 +216,14 @@ async function readActionNode(client, {
213
216
  kind,
214
217
  root: root.name,
215
218
  root_node_id: root.nodeId,
219
+ root_backend_node_id: Number.isInteger(Number(describedRoot?.backendNodeId))
220
+ ? Number(describedRoot.backendNodeId)
221
+ : null,
216
222
  selector,
217
223
  node_id: nodeId,
224
+ backend_node_id: Number.isInteger(Number(described?.backendNodeId))
225
+ ? Number(described.backendNodeId)
226
+ : null,
218
227
  visible,
219
228
  center: box?.center || null,
220
229
  rect: box?.rect || null,
@@ -277,18 +286,24 @@ export function summarizeRecommendActionControls(controls = []) {
277
286
  return {
278
287
  favorite: favorite
279
288
  ? {
289
+ kind: "favorite",
280
290
  found: true,
281
291
  active: favorite.active,
282
292
  disabled: favorite.disabled,
283
293
  label: favorite.label,
284
294
  selector: favorite.selector,
285
295
  root: favorite.root,
296
+ root_node_id: favorite.root_node_id,
297
+ root_backend_node_id: favorite.root_backend_node_id,
286
298
  node_id: favorite.node_id,
287
- center: favorite.center
299
+ backend_node_id: favorite.backend_node_id,
300
+ center: favorite.center,
301
+ rect: favorite.rect
288
302
  }
289
303
  : { found: false },
290
304
  greet: greet
291
305
  ? {
306
+ kind: "greet",
292
307
  found: true,
293
308
  available: greet.available,
294
309
  continue_chat: greet.continue_chat,
@@ -297,8 +312,12 @@ export function summarizeRecommendActionControls(controls = []) {
297
312
  greet_quota: greet.greet_quota || null,
298
313
  selector: greet.selector,
299
314
  root: greet.root,
315
+ root_node_id: greet.root_node_id,
316
+ root_backend_node_id: greet.root_backend_node_id,
300
317
  node_id: greet.node_id,
301
- center: greet.center
318
+ backend_node_id: greet.backend_node_id,
319
+ center: greet.center,
320
+ rect: greet.rect
302
321
  }
303
322
  : { found: false },
304
323
  counts: {
@@ -384,7 +403,8 @@ export async function waitForRecommendDetailActionControls(client, {
384
403
  timeoutMs = 8000,
385
404
  intervalMs = 350,
386
405
  selectors = {},
387
- requireAny = true
406
+ requireAny = true,
407
+ requireContinueChat = false
388
408
  } = {}) {
389
409
  const started = Date.now();
390
410
  let lastDiscovery = null;
@@ -408,7 +428,11 @@ export async function waitForRecommendDetailActionControls(client, {
408
428
  lastDiscovery.summary.favorite.found
409
429
  || lastDiscovery.summary.greet.found
410
430
  );
411
- if (!requireAny || hasControl) {
431
+ const hasExactContinueChat = Boolean(
432
+ lastDiscovery.summary.greet?.continue_chat === true
433
+ && normalizeText(lastDiscovery.summary.greet?.label) === "继续沟通"
434
+ );
435
+ if (requireContinueChat ? hasExactContinueChat : (!requireAny || hasControl)) {
412
436
  return {
413
437
  ...lastDiscovery,
414
438
  elapsed_ms: Date.now() - started,
@@ -431,8 +455,514 @@ export async function waitForRecommendDetailActionControls(client, {
431
455
  };
432
456
  }
433
457
 
458
+ async function verifyRecommendActionControlScope(client, control, { maxDepth = 160 } = {}) {
459
+ const nodeId = Number(control?.node_id);
460
+ const expectedBackendNodeId = Number(control?.backend_node_id);
461
+ const rootNodeId = Number(control?.root_node_id);
462
+ const expectedRootBackendNodeId = Number(control?.root_backend_node_id);
463
+ if (
464
+ !Number.isInteger(nodeId)
465
+ || nodeId <= 0
466
+ || !Number.isInteger(expectedBackendNodeId)
467
+ || expectedBackendNodeId <= 0
468
+ || !Number.isInteger(rootNodeId)
469
+ || rootNodeId <= 0
470
+ || !Number.isInteger(expectedRootBackendNodeId)
471
+ || expectedRootBackendNodeId <= 0
472
+ || rootNodeId === nodeId
473
+ ) {
474
+ const error = new Error(
475
+ "Recommend action control exact detail-root frontend/backend identity is missing"
476
+ );
477
+ error.code = "RECOMMEND_ACTION_CONTROL_SCOPE_REQUIRED";
478
+ throw error;
479
+ }
480
+ const root = await describeNode(client, rootNodeId, { depth: 0, pierce: true });
481
+ if (Number(root?.backendNodeId) !== expectedRootBackendNodeId) {
482
+ const error = new Error("Recommend action control detail root identity changed");
483
+ error.code = "RECOMMEND_ACTION_CONTROL_SCOPE_MISMATCH";
484
+ error.expected_root_node_id = rootNodeId;
485
+ error.expected_root_backend_node_id = expectedRootBackendNodeId;
486
+ error.observed_root_backend_node_id = Number(root?.backendNodeId) || null;
487
+ throw error;
488
+ }
489
+ const seen = new Set();
490
+ let currentNodeId = nodeId;
491
+ for (let depth = 0; depth < maxDepth; depth += 1) {
492
+ if (seen.has(currentNodeId)) break;
493
+ seen.add(currentNodeId);
494
+ const described = await describeNode(client, currentNodeId, { depth: 0, pierce: true });
495
+ if (depth === 0 && Number(described?.backendNodeId) !== expectedBackendNodeId) {
496
+ const error = new Error("Recommend action control backend identity changed within detail scope");
497
+ error.code = "RECOMMEND_ACTION_CONTROL_IDENTITY_MISMATCH";
498
+ error.expected_backend_node_id = expectedBackendNodeId;
499
+ error.observed_backend_node_id = Number(described?.backendNodeId) || null;
500
+ throw error;
501
+ }
502
+ const parentNodeId = Number(described?.parentId);
503
+ if (parentNodeId === rootNodeId) {
504
+ return {
505
+ verified: true,
506
+ node_id: nodeId,
507
+ backend_node_id: expectedBackendNodeId,
508
+ root_node_id: rootNodeId,
509
+ root_backend_node_id: expectedRootBackendNodeId,
510
+ ancestry_depth: depth + 1
511
+ };
512
+ }
513
+ if (!Number.isInteger(parentNodeId) || parentNodeId <= 0) break;
514
+ currentNodeId = parentNodeId;
515
+ }
516
+
517
+ // DOM.pushNodesByBackendIdsToFrontend can restore the exact control/root
518
+ // frontend ids while describeNode still omits parentId for the pushed
519
+ // control. That is not evidence that the control left the detail root.
520
+ // Re-prove containment from the already backend-verified root itself. Prefer
521
+ // exact frontend membership, then tolerate a Chrome frontend alias only when
522
+ // exactly one descendant has the already verified immutable control backend.
523
+ // This is not selector/label rediscovery and cannot switch to another
524
+ // same-labelled control or a control outside the exact detail root.
525
+ const rootDescendantNodeIds = await querySelectorAll(client, rootNodeId, "*");
526
+ if (rootDescendantNodeIds.some((candidateNodeId) => Number(candidateNodeId) === nodeId)) {
527
+ return {
528
+ verified: true,
529
+ method: "root_scoped_exact_frontend_membership",
530
+ node_id: nodeId,
531
+ backend_node_id: expectedBackendNodeId,
532
+ root_node_id: rootNodeId,
533
+ root_backend_node_id: expectedRootBackendNodeId,
534
+ ancestry_depth: null,
535
+ root_scoped_descendant_count: rootDescendantNodeIds.length
536
+ };
537
+ }
538
+ const rootScopedBackendMatches = [];
539
+ for (const candidateNodeId of rootDescendantNodeIds) {
540
+ try {
541
+ const candidate = await describeNode(client, candidateNodeId, { depth: 0, pierce: true });
542
+ if (Number(candidate?.backendNodeId) === expectedBackendNodeId) {
543
+ rootScopedBackendMatches.push(Number(candidateNodeId));
544
+ }
545
+ } catch {
546
+ // Ignore unrelated stale descendants. The exact backend must still have
547
+ // one and only one readable member inside the exact root below.
548
+ }
549
+ }
550
+ if (rootScopedBackendMatches.length === 1) {
551
+ return {
552
+ verified: true,
553
+ method: "root_scoped_exact_backend_membership",
554
+ node_id: rootScopedBackendMatches[0],
555
+ backend_node_id: expectedBackendNodeId,
556
+ root_node_id: rootNodeId,
557
+ root_backend_node_id: expectedRootBackendNodeId,
558
+ ancestry_depth: null,
559
+ root_scoped_descendant_count: rootDescendantNodeIds.length,
560
+ root_scoped_backend_match_count: 1
561
+ };
562
+ }
563
+ const error = new Error(
564
+ "Recommend action control is no longer a descendant of the exact current detail root"
565
+ );
566
+ error.code = "RECOMMEND_ACTION_CONTROL_SCOPE_MISMATCH";
567
+ error.expected_root_node_id = rootNodeId;
568
+ error.expected_root_backend_node_id = expectedRootBackendNodeId;
569
+ error.observed_control_node_id = nodeId;
570
+ error.root_scoped_descendant_count = rootDescendantNodeIds.length;
571
+ error.root_scoped_backend_match_count = rootScopedBackendMatches.length;
572
+ error.cdp_method = "DOM.querySelectorAll";
573
+ error.cdp_node_id = rootNodeId;
574
+ error.cdp_param_keys = ["nodeId", "selector"];
575
+ throw error;
576
+ }
577
+
578
+ async function rebindRecommendActionControlFrontendIds(client, control) {
579
+ const expectedBackendNodeId = Number(control?.backend_node_id);
580
+ const expectedRootBackendNodeId = Number(control?.root_backend_node_id);
581
+ if (
582
+ !Number.isInteger(expectedBackendNodeId)
583
+ || expectedBackendNodeId <= 0
584
+ || !Number.isInteger(expectedRootBackendNodeId)
585
+ || expectedRootBackendNodeId <= 0
586
+ ) {
587
+ const error = new Error(
588
+ "Recommend action control requires exact control/root backend identities for frontend rebind"
589
+ );
590
+ error.code = "RECOMMEND_ACTION_CONTROL_REBIND_REQUIRED";
591
+ throw error;
592
+ }
593
+
594
+ // Candidate reproof deliberately calls DOM.getDocument. Chrome may then
595
+ // replace every frontend node id even though the immutable backend nodes are
596
+ // unchanged. Push only the two exact backend identities into the refreshed
597
+ // frontend namespace; never rediscover by label or reuse a cached center.
598
+ await client.DOM.getDocument({ depth: 0, pierce: true });
599
+ const pushed = await client.DOM.pushNodesByBackendIdsToFrontend({
600
+ backendNodeIds: [expectedBackendNodeId, expectedRootBackendNodeId]
601
+ });
602
+ const nodeId = Number(pushed?.nodeIds?.[0]);
603
+ const rootNodeId = Number(pushed?.nodeIds?.[1]);
604
+ if (
605
+ !Number.isInteger(nodeId)
606
+ || nodeId <= 0
607
+ || !Number.isInteger(rootNodeId)
608
+ || rootNodeId <= 0
609
+ || nodeId === rootNodeId
610
+ ) {
611
+ const error = new Error(
612
+ "Recommend action control/root backend identities could not be rebound to live frontend nodes"
613
+ );
614
+ error.code = "RECOMMEND_ACTION_CONTROL_REBIND_FAILED";
615
+ error.expected_backend_node_id = expectedBackendNodeId;
616
+ error.expected_root_backend_node_id = expectedRootBackendNodeId;
617
+ error.observed_node_id = Number.isInteger(nodeId) && nodeId > 0 ? nodeId : null;
618
+ error.observed_root_node_id = Number.isInteger(rootNodeId) && rootNodeId > 0
619
+ ? rootNodeId
620
+ : null;
621
+ error.cdp_method = "DOM.pushNodesByBackendIdsToFrontend";
622
+ error.cdp_param_keys = ["backendNodeIds"];
623
+ error.cdp_backend_node_id = expectedBackendNodeId;
624
+ throw error;
625
+ }
626
+ const [node, root] = await Promise.all([
627
+ describeNode(client, nodeId, { depth: 0, pierce: true }),
628
+ describeNode(client, rootNodeId, { depth: 0, pierce: true })
629
+ ]);
630
+ if (
631
+ Number(node?.backendNodeId) !== expectedBackendNodeId
632
+ || Number(root?.backendNodeId) !== expectedRootBackendNodeId
633
+ ) {
634
+ const error = new Error(
635
+ "Recommend action control/root backend identities changed during frontend rebind"
636
+ );
637
+ error.code = "RECOMMEND_ACTION_CONTROL_REBIND_MISMATCH";
638
+ error.expected_backend_node_id = expectedBackendNodeId;
639
+ error.observed_backend_node_id = Number(node?.backendNodeId) || null;
640
+ error.expected_root_backend_node_id = expectedRootBackendNodeId;
641
+ error.observed_root_backend_node_id = Number(root?.backendNodeId) || null;
642
+ error.cdp_method = "DOM.describeNode";
643
+ error.cdp_param_keys = ["nodeId"];
644
+ error.cdp_node_id = nodeId;
645
+ error.cdp_backend_node_id = expectedBackendNodeId;
646
+ throw error;
647
+ }
648
+ return {
649
+ ...control,
650
+ node_id: nodeId,
651
+ root_node_id: rootNodeId
652
+ };
653
+ }
654
+
655
+ export async function verifyRecommendActionControlIdentity(client, control, {
656
+ requireContinueChat = false,
657
+ requireGeometry = true,
658
+ allowScroll = true,
659
+ settleMs = 100
660
+ } = {}) {
661
+ const originalNodeId = Number(control?.node_id);
662
+ const expectedBackendNodeId = Number(control?.backend_node_id);
663
+ if (
664
+ !Number.isInteger(originalNodeId)
665
+ || originalNodeId <= 0
666
+ || !Number.isInteger(expectedBackendNodeId)
667
+ || expectedBackendNodeId <= 0
668
+ ) {
669
+ const error = new Error("Recommend action control exact frontend/backend identity is missing");
670
+ error.code = "RECOMMEND_ACTION_CONTROL_IDENTITY_REQUIRED";
671
+ throw error;
672
+ }
673
+ const activeControl = await rebindRecommendActionControlFrontendIds(client, control);
674
+ const nodeId = Number(activeControl.node_id);
675
+ const scopeBefore = await verifyRecommendActionControlScope(client, activeControl);
676
+ const described = await describeNode(client, nodeId);
677
+ if (Number(described?.backendNodeId) !== expectedBackendNodeId) {
678
+ const error = new Error("Recommend action control backend identity no longer matches discovery");
679
+ error.code = "RECOMMEND_ACTION_CONTROL_IDENTITY_MISMATCH";
680
+ error.expected_backend_node_id = expectedBackendNodeId;
681
+ error.observed_backend_node_id = Number(described?.backendNodeId) || null;
682
+ throw error;
683
+ }
684
+ if (allowScroll) {
685
+ await scrollNodeIntoView(client, nodeId);
686
+ if (settleMs > 0) await sleep(settleMs);
687
+ }
688
+ const rebound = await describeNode(client, nodeId);
689
+ if (Number(rebound?.backendNodeId) !== expectedBackendNodeId) {
690
+ const error = new Error(
691
+ allowScroll
692
+ ? "Recommend action control backend identity changed after fresh scroll"
693
+ : "Recommend action control backend identity changed during no-scroll refresh"
694
+ );
695
+ error.code = "RECOMMEND_ACTION_CONTROL_IDENTITY_MISMATCH";
696
+ error.expected_backend_node_id = expectedBackendNodeId;
697
+ error.observed_backend_node_id = Number(rebound?.backendNodeId) || null;
698
+ throw error;
699
+ }
700
+ const scopeAfter = await verifyRecommendActionControlScope(client, activeControl);
701
+ const [attributes, outerHTML] = await Promise.all([
702
+ getAttributesMap(client, nodeId),
703
+ getOuterHTML(client, nodeId)
704
+ ]);
705
+ const classification = activeControl?.kind === "favorite"
706
+ ? classifyFavoriteControl({ outerHTML, attributes })
707
+ : classifyGreetControl({ outerHTML, attributes });
708
+ const expectedLabel = normalizeText(activeControl?.label || "");
709
+ const observedLabel = normalizeText(classification?.label || "");
710
+ const exactContinueChat = Boolean(
711
+ classification?.continue_chat === true
712
+ && observedLabel === "继续沟通"
713
+ );
714
+ const exactExpectedState = requireContinueChat
715
+ ? exactContinueChat
716
+ : activeControl?.kind === "greet"
717
+ ? classification.available === true
718
+ && classification.continue_chat === false
719
+ && observedLabel === expectedLabel
720
+ : classification.matches === true && observedLabel === expectedLabel;
721
+ if (!expectedLabel || !exactExpectedState || classification.disabled === true) {
722
+ const error = new Error("Recommend action control exact visible label/state is not verified");
723
+ error.code = "RECOMMEND_ACTION_CONTROL_LABEL_MISMATCH";
724
+ error.expected_label = requireContinueChat ? "继续沟通" : expectedLabel || null;
725
+ error.observed_label = observedLabel || null;
726
+ throw error;
727
+ }
728
+ let geometry = null;
729
+ if (requireGeometry) {
730
+ const box = await getNodeBox(client, nodeId);
731
+ const valid = [box?.center?.x, box?.center?.y, box?.rect?.width, box?.rect?.height]
732
+ .every((value) => Number.isFinite(Number(value)))
733
+ && Number(box.rect.width) > 2
734
+ && Number(box.rect.height) > 2;
735
+ if (!valid) {
736
+ const error = new Error("Recommend action control fresh geometry is unreadable");
737
+ error.code = "RECOMMEND_ACTION_CONTROL_GEOMETRY_UNREADABLE";
738
+ throw error;
739
+ }
740
+ geometry = { center: box.center, rect: box.rect };
741
+ }
742
+ return {
743
+ verified: true,
744
+ node_id: nodeId,
745
+ backend_node_id: expectedBackendNodeId,
746
+ root: activeControl?.root || null,
747
+ root_node_id: scopeAfter.root_node_id,
748
+ root_backend_node_id: scopeAfter.root_backend_node_id,
749
+ root_ancestry_depth: scopeAfter.ancestry_depth,
750
+ root_scope_stable: scopeBefore.root_backend_node_id === scopeAfter.root_backend_node_id,
751
+ scroll_allowed: allowScroll === true,
752
+ settle_ms: allowScroll ? Math.max(0, Number(settleMs) || 0) : 0,
753
+ label: observedLabel,
754
+ continue_chat: exactContinueChat,
755
+ center: geometry?.center || null,
756
+ rect: geometry?.rect || null
757
+ };
758
+ }
759
+
760
+ function positiveActionNodeId(value) {
761
+ const parsed = Number(value);
762
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
763
+ }
764
+
765
+ function resolveRecommendActionClickPointCandidates(rect) {
766
+ const left = Number(rect?.x);
767
+ const top = Number(rect?.y);
768
+ const width = Number(rect?.width);
769
+ const height = Number(rect?.height);
770
+ if (
771
+ !Number.isFinite(left)
772
+ || !Number.isFinite(top)
773
+ || !Number.isFinite(width)
774
+ || !Number.isFinite(height)
775
+ || width <= 2
776
+ || height <= 2
777
+ ) return [];
778
+ const candidates = [
779
+ { name: "center", x: left + width * 0.5, y: top + height * 0.5 },
780
+ { name: "left_inset", x: left + width * 0.25, y: top + height * 0.5 },
781
+ { name: "right_inset", x: left + width * 0.75, y: top + height * 0.5 },
782
+ { name: "upper_inset", x: left + width * 0.5, y: top + height * 0.35 },
783
+ { name: "lower_inset", x: left + width * 0.5, y: top + height * 0.65 }
784
+ ];
785
+ const seen = new Set();
786
+ return candidates.flatMap((point) => {
787
+ const rounded = {
788
+ name: point.name,
789
+ x: Math.round(point.x),
790
+ y: Math.round(point.y)
791
+ };
792
+ const key = `${rounded.x}:${rounded.y}`;
793
+ if (seen.has(key)) return [];
794
+ seen.add(key);
795
+ return [rounded];
796
+ });
797
+ }
798
+
799
+ function createRecommendActionControlHitTestError(reason, evidence = null) {
800
+ const error = new Error(`Recommend action control hit-test proof failed: ${reason}`);
801
+ error.code = "RECOMMEND_ACTION_CONTROL_HIT_TEST_UNVERIFIED";
802
+ error.phase = "recommend:post-action-pre-input";
803
+ error.recommend_pre_input_aborted = true;
804
+ error.recommend_input_dispatched = false;
805
+ error.recommend_action_control_hit_test = evidence;
806
+ if (reason === "layout_metrics_unavailable" || reason === "layout_metrics_invalid") {
807
+ error.cdp_method = "Page.getLayoutMetrics";
808
+ error.cdp_param_keys = [];
809
+ } else if (reason === "control_descendants_unavailable") {
810
+ error.cdp_method = "DOM.querySelectorAll";
811
+ error.cdp_param_keys = ["nodeId", "selector"];
812
+ } else {
813
+ error.cdp_method = "DOM.getNodeForLocation";
814
+ error.cdp_param_keys = ["x", "y", "includeUserAgentShadowDOM"];
815
+ }
816
+ return error;
817
+ }
818
+
819
+ async function readRecommendActionControlHitTestEvidence(client, control) {
820
+ const controlNodeId = positiveActionNodeId(control?.node_id);
821
+ const expectedControlBackendNodeId = positiveActionNodeId(control?.backend_node_id);
822
+ const expectedRootBackendNodeId = positiveActionNodeId(control?.root_backend_node_id);
823
+ const baseEvidence = {
824
+ verified: false,
825
+ exact_control_hit_verified: false,
826
+ control_node_id: controlNodeId || null,
827
+ control_backend_node_id: expectedControlBackendNodeId || null,
828
+ root_backend_node_id: expectedRootBackendNodeId || null,
829
+ viewport: null,
830
+ control_descendant_backend_count: 0,
831
+ selected: null,
832
+ attempts: []
833
+ };
834
+ if (typeof client?.Page?.getLayoutMetrics !== "function") {
835
+ throw createRecommendActionControlHitTestError("layout_metrics_unavailable", baseEvidence);
836
+ }
837
+ if (typeof client?.DOM?.querySelectorAll !== "function") {
838
+ throw createRecommendActionControlHitTestError("control_descendants_unavailable", baseEvidence);
839
+ }
840
+ if (typeof client?.DOM?.getNodeForLocation !== "function") {
841
+ throw createRecommendActionControlHitTestError("node_location_unavailable", baseEvidence);
842
+ }
843
+
844
+ const metrics = await client.Page.getLayoutMetrics();
845
+ const viewport = metrics?.cssVisualViewport
846
+ || metrics?.visualViewport
847
+ || metrics?.cssLayoutViewport
848
+ || metrics?.layoutViewport
849
+ || null;
850
+ const viewportWidth = Number(viewport?.clientWidth || viewport?.width || 0);
851
+ const viewportHeight = Number(viewport?.clientHeight || viewport?.height || 0);
852
+ const viewportEvidence = {
853
+ width: viewportWidth,
854
+ height: viewportHeight,
855
+ page_x: Number(viewport?.pageX || 0),
856
+ page_y: Number(viewport?.pageY || 0),
857
+ scale: Number(viewport?.scale || 1),
858
+ source: metrics?.cssVisualViewport
859
+ ? "cssVisualViewport"
860
+ : metrics?.visualViewport
861
+ ? "visualViewport"
862
+ : metrics?.cssLayoutViewport
863
+ ? "cssLayoutViewport"
864
+ : metrics?.layoutViewport
865
+ ? "layoutViewport"
866
+ : null
867
+ };
868
+ baseEvidence.viewport = viewportEvidence;
869
+ if (
870
+ !Number.isFinite(viewportWidth)
871
+ || viewportWidth <= 4
872
+ || !Number.isFinite(viewportHeight)
873
+ || viewportHeight <= 4
874
+ ) {
875
+ throw createRecommendActionControlHitTestError("layout_metrics_invalid", baseEvidence);
876
+ }
877
+
878
+ const descendantNodeIds = await querySelectorAll(client, controlNodeId, "*");
879
+ if (descendantNodeIds.length > 256) {
880
+ const evidence = {
881
+ ...baseEvidence,
882
+ control_descendant_count: descendantNodeIds.length
883
+ };
884
+ throw createRecommendActionControlHitTestError("control_descendant_set_too_large", evidence);
885
+ }
886
+ const ownedBackendNodeIds = new Set([expectedControlBackendNodeId]);
887
+ for (const descendantNodeId of descendantNodeIds) {
888
+ const described = await describeNode(client, descendantNodeId, { depth: 0, pierce: true });
889
+ const backendNodeId = positiveActionNodeId(described?.backendNodeId);
890
+ if (backendNodeId) ownedBackendNodeIds.add(backendNodeId);
891
+ }
892
+ baseEvidence.control_descendant_backend_count = Math.max(0, ownedBackendNodeIds.size - 1);
893
+
894
+ const margin = 2;
895
+ const attempts = [];
896
+ for (const point of resolveRecommendActionClickPointCandidates(control?.rect)) {
897
+ const insideViewport = Boolean(
898
+ point.x >= margin
899
+ && point.x <= viewportWidth - margin
900
+ && point.y >= margin
901
+ && point.y <= viewportHeight - margin
902
+ );
903
+ if (!insideViewport) {
904
+ attempts.push({
905
+ point,
906
+ inside_viewport: false,
907
+ exact_control_hit: false,
908
+ hit_node_id: null,
909
+ hit_backend_node_id: null,
910
+ hit_frame_id: null,
911
+ reason: "action_click_point_outside_viewport"
912
+ });
913
+ continue;
914
+ }
915
+ // This must remain the last CDP read before Input whenever it succeeds.
916
+ // Exact ownership is established only from backend IDs proven inside the
917
+ // freshly rebound control subtree; root-only or same-label hits fail.
918
+ const hit = await client.DOM.getNodeForLocation({
919
+ x: point.x,
920
+ y: point.y,
921
+ includeUserAgentShadowDOM: true
922
+ });
923
+ const hitNodeId = positiveActionNodeId(hit?.nodeId);
924
+ const hitBackendNodeId = positiveActionNodeId(hit?.backendNodeId);
925
+ const exactControlHit = Boolean(
926
+ hitBackendNodeId
927
+ && ownedBackendNodeIds.has(hitBackendNodeId)
928
+ );
929
+ const attempt = {
930
+ point,
931
+ inside_viewport: true,
932
+ exact_control_hit: exactControlHit,
933
+ hit_node_id: hitNodeId || null,
934
+ hit_backend_node_id: hitBackendNodeId || null,
935
+ hit_frame_id: hit?.frameId || null,
936
+ reason: exactControlHit ? null : "action_click_point_not_owned_by_exact_control"
937
+ };
938
+ attempts.push(attempt);
939
+ if (exactControlHit) {
940
+ return {
941
+ ...baseEvidence,
942
+ verified: true,
943
+ exact_control_hit_verified: true,
944
+ selected: point,
945
+ attempts
946
+ };
947
+ }
948
+ }
949
+ const evidence = {
950
+ ...baseEvidence,
951
+ attempts
952
+ };
953
+ const reason = attempts.some((attempt) => attempt.inside_viewport)
954
+ ? "action_click_point_not_owned_by_exact_control"
955
+ : "action_click_point_outside_viewport";
956
+ throw createRecommendActionControlHitTestError(reason, evidence);
957
+ }
958
+
434
959
  export async function clickRecommendActionControl(client, control, {
435
- allowDisabled = false
960
+ allowDisabled = false,
961
+ beforeRefresh = null,
962
+ beforeFinalRefresh = null,
963
+ beforeClick = null,
964
+ beforeInput = null,
965
+ immediatelyBeforeInput = null
436
966
  } = {}) {
437
967
  const greetQuota = control?.kind === "greet"
438
968
  ? assertGreetQuotaAvailable(control.greet_quota || control.label || "")
@@ -441,15 +971,102 @@ export async function clickRecommendActionControl(client, control, {
441
971
  throw new Error(`Action control is disabled: ${control.kind}`);
442
972
  }
443
973
 
444
- let clickCenter = control?.center || null;
445
- let clickRect = control?.rect || null;
446
- if (control?.node_id) {
447
- let refreshStep = "scroll_into_view";
974
+ const originalNodeId = Number(control?.node_id);
975
+ const expectedBackendNodeId = Number(control?.backend_node_id);
976
+ if (
977
+ !Number.isInteger(originalNodeId)
978
+ || originalNodeId <= 0
979
+ || !Number.isInteger(expectedBackendNodeId)
980
+ || expectedBackendNodeId <= 0
981
+ ) {
982
+ const error = new Error(
983
+ "Recommend action control requires exact frontend/backend identity; cached centers are never clickable"
984
+ );
985
+ error.code = "RECOMMEND_ACTION_CONTROL_IDENTITY_REQUIRED";
986
+ error.phase = "recommend:post-action-control-refresh";
987
+ error.cached_center_ignored = Boolean(control?.center);
988
+ throw error;
989
+ }
990
+ if (beforeRefresh != null) {
991
+ if (typeof beforeRefresh !== "function") {
992
+ const error = new Error("Recommend action beforeRefresh must be a function");
993
+ error.code = "RECOMMEND_ACTION_BEFORE_REFRESH_INVALID";
994
+ throw error;
995
+ }
996
+ await beforeRefresh();
997
+ }
998
+
999
+ let clickCenter = null;
1000
+ let clickRect = null;
1001
+ let clickTargetProof = null;
1002
+ let activeControl = control;
1003
+ let nodeId = originalNodeId;
1004
+ {
1005
+ let refreshStep = "rebind_before_scroll";
448
1006
  try {
449
- await scrollNodeIntoView(client, control.node_id);
1007
+ activeControl = await rebindRecommendActionControlFrontendIds(client, activeControl);
1008
+ nodeId = Number(activeControl.node_id);
1009
+ refreshStep = "describe_before_scroll";
1010
+ await verifyRecommendActionControlScope(client, activeControl);
1011
+ const beforeNode = await describeNode(client, nodeId);
1012
+ if (Number(beforeNode?.backendNodeId) !== expectedBackendNodeId) {
1013
+ const error = new Error("Recommend action control backend identity changed before click");
1014
+ error.code = "RECOMMEND_ACTION_CONTROL_IDENTITY_MISMATCH";
1015
+ throw error;
1016
+ }
1017
+ refreshStep = "scroll_into_view";
1018
+ await scrollNodeIntoView(client, nodeId);
450
1019
  await sleep(150);
1020
+ if (beforeFinalRefresh != null) {
1021
+ if (typeof beforeFinalRefresh !== "function") {
1022
+ const error = new Error("Recommend action beforeFinalRefresh must be a function");
1023
+ error.code = "RECOMMEND_ACTION_BEFORE_FINAL_REFRESH_INVALID";
1024
+ throw error;
1025
+ }
1026
+ refreshStep = "before_final_refresh";
1027
+ await beforeFinalRefresh();
1028
+ }
1029
+ refreshStep = "rebind_after_final_refresh";
1030
+ activeControl = await rebindRecommendActionControlFrontendIds(client, activeControl);
1031
+ nodeId = Number(activeControl.node_id);
1032
+ refreshStep = "describe_after_scroll";
1033
+ const afterNode = await describeNode(client, nodeId);
1034
+ if (Number(afterNode?.backendNodeId) !== expectedBackendNodeId) {
1035
+ const error = new Error("Recommend action control backend identity changed after scroll");
1036
+ error.code = "RECOMMEND_ACTION_CONTROL_IDENTITY_MISMATCH";
1037
+ throw error;
1038
+ }
1039
+ refreshStep = "verify_detail_scope_after_scroll";
1040
+ await verifyRecommendActionControlScope(client, activeControl);
1041
+ refreshStep = "read_exact_label";
1042
+ const [freshAttributes, freshOuterHTML] = await Promise.all([
1043
+ getAttributesMap(client, nodeId),
1044
+ getOuterHTML(client, nodeId)
1045
+ ]);
1046
+ const freshClassification = activeControl?.kind === "favorite"
1047
+ ? classifyFavoriteControl({ outerHTML: freshOuterHTML, attributes: freshAttributes })
1048
+ : classifyGreetControl({ outerHTML: freshOuterHTML, attributes: freshAttributes });
1049
+ const expectedLabel = normalizeText(activeControl?.label || "");
1050
+ const freshLabel = normalizeText(freshClassification?.label || "");
1051
+ const exactKindState = activeControl?.kind === "greet"
1052
+ ? activeControl?.continue_chat === true
1053
+ ? freshClassification.continue_chat === true && freshLabel === "继续沟通"
1054
+ : freshClassification.available === true && freshClassification.continue_chat === false
1055
+ : freshClassification.matches === true;
1056
+ if (
1057
+ !expectedLabel
1058
+ || freshLabel !== expectedLabel
1059
+ || !exactKindState
1060
+ || freshClassification.disabled === true
1061
+ ) {
1062
+ const error = new Error("Recommend action control exact label/state changed before click");
1063
+ error.code = "RECOMMEND_ACTION_CONTROL_LABEL_MISMATCH";
1064
+ error.expected_label = expectedLabel || null;
1065
+ error.observed_label = freshLabel || null;
1066
+ throw error;
1067
+ }
451
1068
  refreshStep = "read_box_model";
452
- const box = await getNodeBox(client, control.node_id);
1069
+ const box = await getNodeBox(client, nodeId);
453
1070
  const centerX = Number(box?.center?.x);
454
1071
  const centerY = Number(box?.center?.y);
455
1072
  const width = Number(box?.rect?.width);
@@ -463,13 +1080,15 @@ export async function clickRecommendActionControl(client, control, {
463
1080
  || height <= 2
464
1081
  ) {
465
1082
  const error = new Error(
466
- `Could not compute box model for recommend action control nodeId=${control.node_id}`
1083
+ `Could not compute box model for recommend action control nodeId=${nodeId}`
467
1084
  );
468
1085
  error.code = "RECOMMEND_ACTION_CONTROL_GEOMETRY_UNREADABLE";
469
1086
  throw error;
470
1087
  }
471
1088
  clickCenter = box.center;
472
1089
  clickRect = box.rect;
1090
+ refreshStep = "verify_detail_scope_before_journal";
1091
+ await verifyRecommendActionControlScope(client, activeControl);
473
1092
  } catch (error) {
474
1093
  const failure = error instanceof Error
475
1094
  ? error
@@ -478,38 +1097,123 @@ export async function clickRecommendActionControl(client, control, {
478
1097
  failure.phase = failure.phase || "recommend:post-action-control-refresh";
479
1098
  failure.action_control_refresh_step = failure.action_control_refresh_step || refreshStep;
480
1099
  failure.action_control = failure.action_control || {
481
- kind: control?.kind || null,
482
- label: control?.label || null,
483
- selector: control?.selector || null,
484
- root: control?.root || null,
485
- node_id: control?.node_id || null
1100
+ kind: activeControl?.kind || null,
1101
+ label: activeControl?.label || null,
1102
+ selector: activeControl?.selector || null,
1103
+ root: activeControl?.root || null,
1104
+ root_node_id: Number(activeControl?.root_node_id) || null,
1105
+ root_backend_node_id: Number(activeControl?.root_backend_node_id) || null,
1106
+ node_id: nodeId
486
1107
  };
487
1108
  failure.cached_center_ignored = Boolean(control?.center);
488
- failure.cdp_method = failure.cdp_method || (
489
- refreshStep === "scroll_into_view"
490
- ? "DOM.scrollIntoViewIfNeeded"
491
- : "DOM.getBoxModel"
492
- );
493
- failure.cdp_node_id = failure.cdp_node_id || control.node_id;
1109
+ const refreshMethod = {
1110
+ rebind_before_scroll: "DOM.pushNodesByBackendIdsToFrontend",
1111
+ describe_before_scroll: "DOM.describeNode",
1112
+ scroll_into_view: "DOM.scrollIntoViewIfNeeded",
1113
+ rebind_after_final_refresh: "DOM.pushNodesByBackendIdsToFrontend",
1114
+ describe_after_scroll: "DOM.describeNode",
1115
+ verify_detail_scope_after_scroll: "DOM.describeNode",
1116
+ read_exact_label: "DOM.getOuterHTML",
1117
+ read_box_model: "DOM.getBoxModel",
1118
+ verify_detail_scope_before_journal: "DOM.describeNode"
1119
+ }[refreshStep] || null;
1120
+ if (refreshMethod) failure.cdp_method = failure.cdp_method || refreshMethod;
1121
+ failure.cdp_node_id = failure.cdp_node_id || nodeId;
494
1122
  failure.cdp_param_keys = Array.isArray(failure.cdp_param_keys)
495
1123
  ? failure.cdp_param_keys
496
- : ["nodeId"];
1124
+ : refreshStep.startsWith("rebind_")
1125
+ ? ["backendNodeIds"]
1126
+ : ["nodeId"];
497
1127
  throw failure;
498
1128
  }
499
1129
  }
500
- if (!clickCenter) {
501
- throw new Error("Action control has no clickable center");
1130
+ try {
1131
+ if (beforeClick != null) {
1132
+ if (typeof beforeClick !== "function") {
1133
+ const error = new Error("Recommend action beforeClick must be a function");
1134
+ error.code = "RECOMMEND_ACTION_BEFORE_CLICK_INVALID";
1135
+ throw error;
1136
+ }
1137
+ await beforeClick({
1138
+ kind: activeControl.kind,
1139
+ label: activeControl.label,
1140
+ selector: activeControl.selector,
1141
+ root: activeControl.root,
1142
+ root_node_id: Number(activeControl?.root_node_id) || null,
1143
+ root_backend_node_id: Number(activeControl?.root_backend_node_id) || null,
1144
+ node_id: nodeId,
1145
+ backend_node_id: expectedBackendNodeId,
1146
+ center: clickCenter,
1147
+ rect: clickRect
1148
+ });
1149
+ }
1150
+ if (beforeInput != null) {
1151
+ if (typeof beforeInput !== "function") {
1152
+ const error = new Error("Recommend action beforeInput must be a function");
1153
+ error.code = "RECOMMEND_ACTION_BEFORE_INPUT_INVALID";
1154
+ throw error;
1155
+ }
1156
+ await beforeInput();
1157
+ }
1158
+ const controlAfterFinalScroll = await verifyRecommendActionControlIdentity(client, activeControl, {
1159
+ requireContinueChat: activeControl?.continue_chat === true,
1160
+ requireGeometry: false,
1161
+ allowScroll: true,
1162
+ settleMs: 100
1163
+ });
1164
+ activeControl = { ...activeControl, ...controlAfterFinalScroll };
1165
+ if (immediatelyBeforeInput != null) {
1166
+ if (typeof immediatelyBeforeInput !== "function") {
1167
+ const error = new Error("Recommend action immediatelyBeforeInput must be a function");
1168
+ error.code = "RECOMMEND_ACTION_IMMEDIATELY_BEFORE_INPUT_INVALID";
1169
+ throw error;
1170
+ }
1171
+ await immediatelyBeforeInput(controlAfterFinalScroll);
1172
+ }
1173
+ // The candidate/root hook above must be non-scrolling. Re-read the exact
1174
+ // control/root/label/box after it with no scroll and no settle, then prove
1175
+ // a bounded point's topmost native hit belongs to this exact control.
1176
+ // A successful DOM.getNodeForLocation is the last CDP read before Input.
1177
+ const finalControl = await verifyRecommendActionControlIdentity(client, activeControl, {
1178
+ requireContinueChat: activeControl?.continue_chat === true,
1179
+ requireGeometry: true,
1180
+ allowScroll: false,
1181
+ settleMs: 0
1182
+ });
1183
+ clickCenter = finalControl.center;
1184
+ clickRect = finalControl.rect;
1185
+ activeControl = { ...activeControl, ...finalControl };
1186
+ clickTargetProof = await readRecommendActionControlHitTestEvidence(client, activeControl);
1187
+ clickCenter = {
1188
+ x: clickTargetProof.selected.x,
1189
+ y: clickTargetProof.selected.y
1190
+ };
1191
+ } catch (error) {
1192
+ const failure = error instanceof Error
1193
+ ? error
1194
+ : new Error(String(error || "Recommend action pre-Input verification failed"));
1195
+ failure.recommend_pre_input_aborted = true;
1196
+ failure.recommend_input_dispatched = false;
1197
+ failure.phase = failure.phase || "recommend:post-action-pre-input";
1198
+ throw failure;
502
1199
  }
503
- await clickPoint(client, clickCenter.x, clickCenter.y);
1200
+ await clickPoint(client, clickCenter.x, clickCenter.y, {
1201
+ humanRestEnabled: false,
1202
+ moveBeforePress: false
1203
+ });
504
1204
  return {
505
1205
  clicked: true,
506
- kind: control.kind,
507
- label: control.label,
1206
+ kind: activeControl.kind,
1207
+ label: activeControl.label,
508
1208
  greet_quota: greetQuota?.found ? greetQuota : null,
509
- selector: control.selector,
510
- root: control.root,
511
- node_id: control.node_id,
1209
+ selector: activeControl.selector,
1210
+ root: activeControl.root,
1211
+ root_node_id: Number(activeControl?.root_node_id) || null,
1212
+ root_backend_node_id: Number(activeControl?.root_backend_node_id) || null,
1213
+ node_id: Number(activeControl?.node_id) || null,
1214
+ backend_node_id: expectedBackendNodeId,
512
1215
  center: clickCenter,
513
- rect: clickRect
1216
+ rect: clickRect,
1217
+ click_target_proof: clickTargetProof
514
1218
  };
515
1219
  }