@reconcrap/boss-recommend-mcp 2.1.23 → 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.
Files changed (67) hide show
  1. package/README.md +5 -0
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/package.json +6 -1
  4. package/scripts/install-macos.sh +280 -280
  5. package/scripts/postinstall.cjs +44 -44
  6. package/skills/boss-chat/README.md +43 -42
  7. package/skills/boss-chat/SKILL.md +107 -106
  8. package/skills/boss-recommend-pipeline/README.md +13 -13
  9. package/skills/boss-recommend-pipeline/SKILL.md +47 -47
  10. package/skills/boss-recruit-pipeline/README.md +19 -19
  11. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  12. package/src/chat-mcp.js +301 -127
  13. package/src/chat-runtime-config.js +7 -5
  14. package/src/core/boss-cards/index.js +199 -199
  15. package/src/core/browser/index.js +302 -145
  16. package/src/core/capture/index.js +2930 -1201
  17. package/src/core/cv-acquisition/index.js +512 -238
  18. package/src/core/cv-capture-target/index.js +513 -299
  19. package/src/core/greet-quota/index.js +71 -71
  20. package/src/core/infinite-list/index.js +31 -31
  21. package/src/core/reporting/legacy-csv.js +12 -12
  22. package/src/core/run/detached-launcher.js +305 -0
  23. package/src/core/run/index.js +109 -55
  24. package/src/core/run/timing.js +33 -33
  25. package/src/core/run/windows-detached-worker.ps1 +106 -0
  26. package/src/core/screening/index.js +2400 -2135
  27. package/src/core/self-heal/index.js +989 -973
  28. package/src/core/self-heal/viewport.js +1505 -564
  29. package/src/detached-worker.js +99 -99
  30. package/src/domains/chat/action-journal.js +536 -0
  31. package/src/domains/chat/cards.js +137 -137
  32. package/src/domains/chat/constants.js +9 -9
  33. package/src/domains/chat/detail.js +1684 -401
  34. package/src/domains/chat/index.js +8 -7
  35. package/src/domains/chat/jobs.js +620 -620
  36. package/src/domains/chat/page-guard.js +157 -122
  37. package/src/domains/chat/roots.js +56 -56
  38. package/src/domains/chat/run-service.js +1801 -760
  39. package/src/domains/common/account-rights-panel.js +314 -314
  40. package/src/domains/common/recovery-settle.js +159 -159
  41. package/src/domains/recommend/actions.js +1219 -472
  42. package/src/domains/recommend/cards.js +243 -243
  43. package/src/domains/recommend/colleague-contact.js +1079 -333
  44. package/src/domains/recommend/constants.js +92 -92
  45. package/src/domains/recommend/detail.js +4037 -136
  46. package/src/domains/recommend/filters.js +608 -590
  47. package/src/domains/recommend/index.js +9 -9
  48. package/src/domains/recommend/jobs.js +571 -542
  49. package/src/domains/recommend/location.js +754 -707
  50. package/src/domains/recommend/refresh.js +677 -392
  51. package/src/domains/recommend/roots.js +80 -80
  52. package/src/domains/recommend/run-service.js +4048 -1447
  53. package/src/domains/recommend/scopes.js +246 -246
  54. package/src/domains/recruit/actions.js +277 -277
  55. package/src/domains/recruit/cards.js +74 -74
  56. package/src/domains/recruit/constants.js +236 -236
  57. package/src/domains/recruit/detail.js +588 -588
  58. package/src/domains/recruit/index.js +9 -9
  59. package/src/domains/recruit/instruction-parser.js +866 -866
  60. package/src/domains/recruit/refresh.js +45 -45
  61. package/src/domains/recruit/roots.js +68 -68
  62. package/src/domains/recruit/run-service.js +1817 -1620
  63. package/src/domains/recruit/search.js +3229 -3229
  64. package/src/index.js +16 -1
  65. package/src/parser.js +1296 -1296
  66. package/src/recommend-mcp.js +1061 -450
  67. package/src/recommend-scheduler.js +75 -75
@@ -1,472 +1,1219 @@
1
- import {
2
- clickPoint,
3
- getFrameDocumentNodeId,
4
- getAttributesMap,
5
- getNodeBox,
6
- getOuterHTML,
7
- querySelectorAll,
8
- scrollNodeIntoView,
9
- sleep
10
- } from "../../core/browser/index.js";
11
- import {
12
- htmlToText,
13
- normalizeText
14
- } from "../../core/screening/index.js";
15
- import {
16
- assertGreetQuotaAvailable,
17
- parseGreetQuota
18
- } from "../../core/greet-quota/index.js";
19
- import {
20
- FAVORITE_BUTTON_SELECTORS,
21
- GREET_BUTTON_RECOMMEND_SELECTORS
22
- } from "./constants.js";
23
- import { waitForRecommendDetail } from "./detail.js";
24
- import { getRecommendRoots } from "./roots.js";
25
-
26
- const POST_ACTIONS = new Set(["none", "greet"]);
27
- const GREET_EXACT_LABEL_PATTERN = /^(?:打招呼|聊一聊|立即沟通(?:[\((]\d+\s*[//]\s*\d+[\))])?|沟通)$/i;
28
- export const RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS = Object.freeze([
29
- "button",
30
- ".btn",
31
- '[role="button"]',
32
- "a",
33
- "span",
34
- "div"
35
- ]);
36
-
37
- function uniqueSelectors(...selectorGroups) {
38
- return [...new Set(selectorGroups.flat().filter(Boolean))];
39
- }
40
-
41
- function uniqueByNode(candidates = []) {
42
- const seen = new Set();
43
- const result = [];
44
- for (const item of candidates) {
45
- const key = `${item.kind}:${item.root}:${item.node_id}`;
46
- if (seen.has(key)) continue;
47
- seen.add(key);
48
- result.push(item);
49
- }
50
- return result;
51
- }
52
-
53
- function lowerText(...parts) {
54
- return normalizeText(parts.filter(Boolean).join(" ")).toLowerCase();
55
- }
56
-
57
- function hasActiveClass(text) {
58
- return /(?:^|\s)(?:active|curr|current|selected|checked)(?:\s|$)/i.test(text);
59
- }
60
-
61
- function hasDisabledSignal(text) {
62
- return /(?:^|\s)(?:disabled|disable|forbidden|is-disabled)(?:\s|$)/i.test(text);
63
- }
64
-
65
- function rectArea(control) {
66
- const rect = control?.rect || {};
67
- return Math.max(0, Number(rect.width) || 0) * Math.max(0, Number(rect.height) || 0);
68
- }
69
-
70
- function isCompactLabel(control, limit = 80) {
71
- const label = normalizeText(control?.label || "");
72
- return label.length > 0 && label.length <= limit;
73
- }
74
-
75
- function controlRank(control, exactLabelPattern) {
76
- const label = normalizeText(control?.label || "");
77
- const selector = String(control?.selector || "");
78
- const className = String(control?.class_name || "");
79
- let score = 0;
80
- if (exactLabelPattern.test(label)) score -= 1000;
81
- if (/button|\[role=|\.btn/.test(selector) || /btn|button/i.test(className)) score -= 250;
82
- if (selector === "div") score += 300;
83
- if (!isCompactLabel(control)) score += 500;
84
- score += Math.min(rectArea(control), 100000) / 1000;
85
- score += label.length / 10;
86
- return score;
87
- }
88
-
89
- function bestControl(controls, exactLabelPattern) {
90
- return [...controls].sort((left, right) => (
91
- controlRank(left, exactLabelPattern) - controlRank(right, exactLabelPattern)
92
- ))[0] || null;
93
- }
94
-
95
- export function normalizeRecommendPostAction(value) {
96
- const normalized = normalizeText(value).toLowerCase();
97
- if (["", "none", "skip", "no", "不执行", "无"].includes(normalized)) return "none";
98
- if (["greet", "chat", "打招呼", "直接沟通", "沟通"].includes(normalized)) return "greet";
99
- return POST_ACTIONS.has(normalized) ? normalized : "";
100
- }
101
-
102
- export function resolveRecommendPostAction({
103
- postAction = "none",
104
- greetCount = 0,
105
- maxGreetCount = null
106
- } = {}) {
107
- const requested = normalizeRecommendPostAction(postAction) || "none";
108
- const currentGreetCount = Number.isInteger(greetCount) && greetCount >= 0 ? greetCount : 0;
109
- const limit = Number.isInteger(maxGreetCount) && maxGreetCount > 0 ? maxGreetCount : null;
110
- if (requested === "greet" && limit !== null && currentGreetCount >= limit) {
111
- return {
112
- requested,
113
- effective: "none",
114
- reason: "greet_limit_reached",
115
- greet_count: currentGreetCount,
116
- max_greet_count: limit
117
- };
118
- }
119
- return {
120
- requested,
121
- effective: requested,
122
- reason: "requested_action",
123
- greet_count: currentGreetCount,
124
- max_greet_count: limit
125
- };
126
- }
127
-
128
- export function classifyFavoriteControl({
129
- outerHTML = "",
130
- attributes = {}
131
- } = {}) {
132
- const label = htmlToText(outerHTML);
133
- const labelText = normalizeText(label);
134
- const className = normalizeText(attributes.class || "");
135
- const title = normalizeText(attributes.title || attributes["aria-label"] || "");
136
- const combined = lowerText(className, title);
137
- const labelMatches = /^(?:收藏|已收藏|感兴趣|已感兴趣)$/.test(labelText);
138
- const classMatches = /favorite|collect|interest|like/.test(combined);
139
- const matches = labelMatches || classMatches;
140
- const active = (
141
- /已收藏|已感兴趣/.test(label)
142
- || /like-icon-active|favorite-active|collect-active/i.test(outerHTML)
143
- || hasActiveClass(className)
144
- );
145
- const disabled = (
146
- Object.prototype.hasOwnProperty.call(attributes, "disabled")
147
- || hasDisabledSignal(className)
148
- || /disabled/i.test(outerHTML)
149
- );
150
- return {
151
- kind: "favorite",
152
- matches,
153
- active,
154
- disabled,
155
- label: label || title || null,
156
- class_name: className || null
157
- };
158
- }
159
-
160
- export function classifyGreetControl({
161
- outerHTML = "",
162
- attributes = {}
163
- } = {}) {
164
- const label = htmlToText(outerHTML);
165
- const labelText = normalizeText(label);
166
- const className = normalizeText(attributes.class || "");
167
- const title = normalizeText(attributes.title || attributes["aria-label"] || "");
168
- const combined = lowerText(className, title);
169
- const continueChat = labelText.length <= 40 && /继续沟通/.test(labelText);
170
- const greetQuota = parseGreetQuota(labelText || title);
171
- const greetEntry = (
172
- GREET_EXACT_LABEL_PATTERN.test(labelText)
173
- || greetQuota.found
174
- || /greet/i.test(combined)
175
- );
176
- const disabled = (
177
- Object.prototype.hasOwnProperty.call(attributes, "disabled")
178
- || hasDisabledSignal(className)
179
- || /disabled/i.test(outerHTML)
180
- );
181
- return {
182
- kind: "greet",
183
- matches: greetEntry || continueChat,
184
- available: greetEntry && !continueChat && !disabled,
185
- continue_chat: continueChat,
186
- disabled,
187
- label: label || title || null,
188
- greet_quota: greetQuota.found ? greetQuota : null,
189
- class_name: className || null
190
- };
191
- }
192
-
193
- async function readActionNode(client, {
194
- root,
195
- selector,
196
- nodeId,
197
- kind
198
- }) {
199
- const [attributes, outerHTML] = await Promise.all([
200
- getAttributesMap(client, nodeId),
201
- getOuterHTML(client, nodeId)
202
- ]);
203
- let box = null;
204
- let visible = false;
205
- try {
206
- box = await getNodeBox(client, nodeId);
207
- visible = box.rect.width > 2 && box.rect.height > 2;
208
- } catch {}
209
- const classification = kind === "favorite"
210
- ? classifyFavoriteControl({ outerHTML, attributes })
211
- : classifyGreetControl({ outerHTML, attributes });
212
- return {
213
- kind,
214
- root: root.name,
215
- root_node_id: root.nodeId,
216
- selector,
217
- node_id: nodeId,
218
- visible,
219
- center: box?.center || null,
220
- rect: box?.rect || null,
221
- attributes,
222
- outer_html_length: outerHTML.length,
223
- html_preview: outerHTML.slice(0, 500),
224
- ...classification
225
- };
226
- }
227
-
228
- export async function collectRecommendActionControls(client, roots, {
229
- favoriteSelectors = FAVORITE_BUTTON_SELECTORS,
230
- greetSelectors = GREET_BUTTON_RECOMMEND_SELECTORS,
231
- detailTextFallback = false
232
- } = {}) {
233
- const candidates = [];
234
- const favoriteScanSelectors = detailTextFallback
235
- ? uniqueSelectors(favoriteSelectors, RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS)
236
- : favoriteSelectors;
237
- const greetScanSelectors = detailTextFallback
238
- ? uniqueSelectors(greetSelectors, RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS)
239
- : greetSelectors;
240
- for (const root of roots) {
241
- if (!root?.nodeId) continue;
242
- for (const [kind, selectors] of [
243
- ["favorite", favoriteScanSelectors],
244
- ["greet", greetScanSelectors]
245
- ]) {
246
- for (const selector of selectors) {
247
- const nodeIds = await querySelectorAll(client, root.nodeId, selector);
248
- for (const nodeId of nodeIds) {
249
- candidates.push(await readActionNode(client, {
250
- root,
251
- selector,
252
- nodeId,
253
- kind
254
- }));
255
- }
256
- }
257
- }
258
- }
259
- return uniqueByNode(candidates);
260
- }
261
-
262
- export function summarizeRecommendActionControls(controls = []) {
263
- const visibleControls = controls.filter((item) => item.visible && item.matches);
264
- const favoriteControls = visibleControls.filter((item) => item.kind === "favorite");
265
- const greetControls = visibleControls.filter((item) => item.kind === "greet");
266
- const favorite = bestControl(
267
- favoriteControls.filter((item) => item.matches),
268
- /^(?:收藏|已收藏|感兴趣|已感兴趣)$/i
269
- );
270
- const greet = bestControl(
271
- greetControls.filter((item) => item.available),
272
- GREET_EXACT_LABEL_PATTERN
273
- ) || bestControl(
274
- greetControls.filter((item) => item.continue_chat),
275
- /^继续沟通$/i
276
- );
277
- return {
278
- favorite: favorite
279
- ? {
280
- found: true,
281
- active: favorite.active,
282
- disabled: favorite.disabled,
283
- label: favorite.label,
284
- selector: favorite.selector,
285
- root: favorite.root,
286
- node_id: favorite.node_id,
287
- center: favorite.center
288
- }
289
- : { found: false },
290
- greet: greet
291
- ? {
292
- found: true,
293
- available: greet.available,
294
- continue_chat: greet.continue_chat,
295
- disabled: greet.disabled,
296
- label: greet.label,
297
- greet_quota: greet.greet_quota || null,
298
- selector: greet.selector,
299
- root: greet.root,
300
- node_id: greet.node_id,
301
- center: greet.center
302
- }
303
- : { found: false },
304
- counts: {
305
- total: controls.length,
306
- visible_matching: visibleControls.length,
307
- favorite: favoriteControls.length,
308
- greet: greetControls.length
309
- }
310
- };
311
- }
312
-
313
- export async function discoverRecommendActionControls(client, {
314
- roots = null,
315
- selectors = {},
316
- detailTextFallback = false
317
- } = {}) {
318
- const rootState = roots ? { roots } : await getRecommendRoots(client);
319
- const controls = await collectRecommendActionControls(client, rootState.roots, {
320
- ...selectors,
321
- detailTextFallback
322
- });
323
- return {
324
- controls,
325
- summary: summarizeRecommendActionControls(controls)
326
- };
327
- }
328
-
329
- export async function waitForRecommendActionControls(client, {
330
- timeoutMs = 6000,
331
- intervalMs = 250,
332
- requireAny = true,
333
- ...discoveryOptions
334
- } = {}) {
335
- const started = Date.now();
336
- let lastDiscovery = null;
337
- while (Date.now() - started <= timeoutMs) {
338
- lastDiscovery = await discoverRecommendActionControls(client, discoveryOptions);
339
- const hasControl = Boolean(
340
- lastDiscovery.summary.favorite.found
341
- || lastDiscovery.summary.greet.found
342
- );
343
- if (!requireAny || hasControl) {
344
- return {
345
- ...lastDiscovery,
346
- elapsed_ms: Date.now() - started,
347
- timed_out: false
348
- };
349
- }
350
- await sleep(intervalMs);
351
- }
352
- return {
353
- ...(lastDiscovery || { controls: [], summary: summarizeRecommendActionControls([]) }),
354
- elapsed_ms: Date.now() - started,
355
- timed_out: true
356
- };
357
- }
358
-
359
- export async function getRecommendDetailActionRoots(client, detailState) {
360
- const roots = [];
361
- if (detailState?.popup?.node_id) {
362
- roots.push({
363
- name: `${detailState.popup.root || "unknown"}:detail-popup`,
364
- nodeId: detailState.popup.node_id
365
- });
366
- }
367
- if (detailState?.resumeIframe?.node_id) {
368
- try {
369
- roots.push({
370
- name: `${detailState.resumeIframe.root || "unknown"}:resume-iframe-document`,
371
- nodeId: await getFrameDocumentNodeId(client, detailState.resumeIframe.node_id)
372
- });
373
- } catch {
374
- roots.push({
375
- name: `${detailState.resumeIframe.root || "unknown"}:resume-iframe-node`,
376
- nodeId: detailState.resumeIframe.node_id
377
- });
378
- }
379
- }
380
- return roots;
381
- }
382
-
383
- export async function waitForRecommendDetailActionControls(client, {
384
- timeoutMs = 8000,
385
- intervalMs = 350,
386
- selectors = {},
387
- requireAny = true
388
- } = {}) {
389
- const started = Date.now();
390
- let lastDiscovery = null;
391
- let lastError = null;
392
- let lastRootCount = 0;
393
- while (Date.now() - started <= timeoutMs) {
394
- const detailState = await waitForRecommendDetail(client, {
395
- timeoutMs: Math.min(intervalMs, 500),
396
- intervalMs: 100
397
- });
398
- const roots = await getRecommendDetailActionRoots(client, detailState);
399
- lastRootCount = roots.length;
400
- if (roots.length) {
401
- try {
402
- lastDiscovery = await discoverRecommendActionControls(client, {
403
- roots,
404
- selectors,
405
- detailTextFallback: true
406
- });
407
- const hasControl = Boolean(
408
- lastDiscovery.summary.favorite.found
409
- || lastDiscovery.summary.greet.found
410
- );
411
- if (!requireAny || hasControl) {
412
- return {
413
- ...lastDiscovery,
414
- elapsed_ms: Date.now() - started,
415
- timed_out: false,
416
- detail_root_count: roots.length
417
- };
418
- }
419
- } catch (error) {
420
- lastError = error?.message || String(error);
421
- }
422
- }
423
- await sleep(intervalMs);
424
- }
425
- return {
426
- ...(lastDiscovery || { controls: [], summary: summarizeRecommendActionControls([]) }),
427
- elapsed_ms: Date.now() - started,
428
- timed_out: true,
429
- detail_root_count: lastRootCount,
430
- last_error: lastError
431
- };
432
- }
433
-
434
- export async function clickRecommendActionControl(client, control, {
435
- allowDisabled = false
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) {
452
- throw new Error("Action control has no clickable center");
453
- }
454
- const greetQuota = control.kind === "greet"
455
- ? assertGreetQuotaAvailable(control.greet_quota || control.label || "")
456
- : null;
457
- if (control.disabled && !allowDisabled) {
458
- throw new Error(`Action control is disabled: ${control.kind}`);
459
- }
460
- await clickPoint(client, clickCenter.x, clickCenter.y);
461
- return {
462
- clicked: true,
463
- kind: control.kind,
464
- label: control.label,
465
- greet_quota: greetQuota?.found ? greetQuota : null,
466
- selector: control.selector,
467
- root: control.root,
468
- node_id: control.node_id,
469
- center: clickCenter,
470
- rect: clickRect
471
- };
472
- }
1
+ import {
2
+ clickPoint,
3
+ describeNode,
4
+ getFrameDocumentNodeId,
5
+ getAttributesMap,
6
+ getNodeBox,
7
+ getOuterHTML,
8
+ querySelectorAll,
9
+ scrollNodeIntoView,
10
+ sleep
11
+ } from "../../core/browser/index.js";
12
+ import {
13
+ htmlToText,
14
+ normalizeText
15
+ } from "../../core/screening/index.js";
16
+ import {
17
+ assertGreetQuotaAvailable,
18
+ parseGreetQuota
19
+ } from "../../core/greet-quota/index.js";
20
+ import {
21
+ FAVORITE_BUTTON_SELECTORS,
22
+ GREET_BUTTON_RECOMMEND_SELECTORS
23
+ } from "./constants.js";
24
+ import { waitForRecommendDetail } from "./detail.js";
25
+ import { getRecommendRoots } from "./roots.js";
26
+
27
+ const POST_ACTIONS = new Set(["none", "greet"]);
28
+ const GREET_EXACT_LABEL_PATTERN = /^(?:打招呼|聊一聊|立即沟通(?:[\((]\d+\s*[//]\s*\d+[\))])?|沟通)$/i;
29
+ export const RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS = Object.freeze([
30
+ "button",
31
+ ".btn",
32
+ '[role="button"]',
33
+ "a",
34
+ "span",
35
+ "div"
36
+ ]);
37
+
38
+ function uniqueSelectors(...selectorGroups) {
39
+ return [...new Set(selectorGroups.flat().filter(Boolean))];
40
+ }
41
+
42
+ function uniqueByNode(candidates = []) {
43
+ const seen = new Set();
44
+ const result = [];
45
+ for (const item of candidates) {
46
+ const key = `${item.kind}:${item.root}:${item.node_id}`;
47
+ if (seen.has(key)) continue;
48
+ seen.add(key);
49
+ result.push(item);
50
+ }
51
+ return result;
52
+ }
53
+
54
+ function lowerText(...parts) {
55
+ return normalizeText(parts.filter(Boolean).join(" ")).toLowerCase();
56
+ }
57
+
58
+ function hasActiveClass(text) {
59
+ return /(?:^|\s)(?:active|curr|current|selected|checked)(?:\s|$)/i.test(text);
60
+ }
61
+
62
+ function hasDisabledSignal(text) {
63
+ return /(?:^|\s)(?:disabled|disable|forbidden|is-disabled)(?:\s|$)/i.test(text);
64
+ }
65
+
66
+ function rectArea(control) {
67
+ const rect = control?.rect || {};
68
+ return Math.max(0, Number(rect.width) || 0) * Math.max(0, Number(rect.height) || 0);
69
+ }
70
+
71
+ function isCompactLabel(control, limit = 80) {
72
+ const label = normalizeText(control?.label || "");
73
+ return label.length > 0 && label.length <= limit;
74
+ }
75
+
76
+ function controlRank(control, exactLabelPattern) {
77
+ const label = normalizeText(control?.label || "");
78
+ const selector = String(control?.selector || "");
79
+ const className = String(control?.class_name || "");
80
+ let score = 0;
81
+ if (exactLabelPattern.test(label)) score -= 1000;
82
+ if (/button|\[role=|\.btn/.test(selector) || /btn|button/i.test(className)) score -= 250;
83
+ if (selector === "div") score += 300;
84
+ if (!isCompactLabel(control)) score += 500;
85
+ score += Math.min(rectArea(control), 100000) / 1000;
86
+ score += label.length / 10;
87
+ return score;
88
+ }
89
+
90
+ function bestControl(controls, exactLabelPattern) {
91
+ return [...controls].sort((left, right) => (
92
+ controlRank(left, exactLabelPattern) - controlRank(right, exactLabelPattern)
93
+ ))[0] || null;
94
+ }
95
+
96
+ export function normalizeRecommendPostAction(value) {
97
+ const normalized = normalizeText(value).toLowerCase();
98
+ if (["", "none", "skip", "no", "不执行", "无"].includes(normalized)) return "none";
99
+ if (["greet", "chat", "打招呼", "直接沟通", "沟通"].includes(normalized)) return "greet";
100
+ return POST_ACTIONS.has(normalized) ? normalized : "";
101
+ }
102
+
103
+ export function resolveRecommendPostAction({
104
+ postAction = "none",
105
+ greetCount = 0,
106
+ maxGreetCount = null
107
+ } = {}) {
108
+ const requested = normalizeRecommendPostAction(postAction) || "none";
109
+ const currentGreetCount = Number.isInteger(greetCount) && greetCount >= 0 ? greetCount : 0;
110
+ const limit = Number.isInteger(maxGreetCount) && maxGreetCount > 0 ? maxGreetCount : null;
111
+ if (requested === "greet" && limit !== null && currentGreetCount >= limit) {
112
+ return {
113
+ requested,
114
+ effective: "none",
115
+ reason: "greet_limit_reached",
116
+ greet_count: currentGreetCount,
117
+ max_greet_count: limit
118
+ };
119
+ }
120
+ return {
121
+ requested,
122
+ effective: requested,
123
+ reason: "requested_action",
124
+ greet_count: currentGreetCount,
125
+ max_greet_count: limit
126
+ };
127
+ }
128
+
129
+ export function classifyFavoriteControl({
130
+ outerHTML = "",
131
+ attributes = {}
132
+ } = {}) {
133
+ const label = htmlToText(outerHTML);
134
+ const labelText = normalizeText(label);
135
+ const className = normalizeText(attributes.class || "");
136
+ const title = normalizeText(attributes.title || attributes["aria-label"] || "");
137
+ const combined = lowerText(className, title);
138
+ const labelMatches = /^(?:收藏|已收藏|感兴趣|已感兴趣)$/.test(labelText);
139
+ const classMatches = /favorite|collect|interest|like/.test(combined);
140
+ const matches = labelMatches || classMatches;
141
+ const active = (
142
+ /已收藏|已感兴趣/.test(label)
143
+ || /like-icon-active|favorite-active|collect-active/i.test(outerHTML)
144
+ || hasActiveClass(className)
145
+ );
146
+ const disabled = (
147
+ Object.prototype.hasOwnProperty.call(attributes, "disabled")
148
+ || hasDisabledSignal(className)
149
+ || /disabled/i.test(outerHTML)
150
+ );
151
+ return {
152
+ kind: "favorite",
153
+ matches,
154
+ active,
155
+ disabled,
156
+ label: label || title || null,
157
+ class_name: className || null
158
+ };
159
+ }
160
+
161
+ export function classifyGreetControl({
162
+ outerHTML = "",
163
+ attributes = {}
164
+ } = {}) {
165
+ const label = htmlToText(outerHTML);
166
+ const labelText = normalizeText(label);
167
+ const className = normalizeText(attributes.class || "");
168
+ const title = normalizeText(attributes.title || attributes["aria-label"] || "");
169
+ const combined = lowerText(className, title);
170
+ const continueChat = labelText === "继续沟通";
171
+ const greetQuota = parseGreetQuota(labelText || title);
172
+ const greetEntry = (
173
+ GREET_EXACT_LABEL_PATTERN.test(labelText)
174
+ || greetQuota.found
175
+ || /greet/i.test(combined)
176
+ );
177
+ const disabled = (
178
+ Object.prototype.hasOwnProperty.call(attributes, "disabled")
179
+ || hasDisabledSignal(className)
180
+ || /disabled/i.test(outerHTML)
181
+ );
182
+ return {
183
+ kind: "greet",
184
+ matches: greetEntry || continueChat,
185
+ available: greetEntry && !continueChat && !disabled,
186
+ continue_chat: continueChat,
187
+ disabled,
188
+ label: label || title || null,
189
+ greet_quota: greetQuota.found ? greetQuota : null,
190
+ class_name: className || null
191
+ };
192
+ }
193
+
194
+ async function readActionNode(client, {
195
+ root,
196
+ selector,
197
+ nodeId,
198
+ kind
199
+ }) {
200
+ const [attributes, outerHTML, described, describedRoot] = await Promise.all([
201
+ getAttributesMap(client, nodeId),
202
+ getOuterHTML(client, nodeId),
203
+ describeNode(client, nodeId),
204
+ describeNode(client, root.nodeId, { depth: 0, pierce: true })
205
+ ]);
206
+ let box = null;
207
+ let visible = false;
208
+ try {
209
+ box = await getNodeBox(client, nodeId);
210
+ visible = box.rect.width > 2 && box.rect.height > 2;
211
+ } catch {}
212
+ const classification = kind === "favorite"
213
+ ? classifyFavoriteControl({ outerHTML, attributes })
214
+ : classifyGreetControl({ outerHTML, attributes });
215
+ return {
216
+ kind,
217
+ root: root.name,
218
+ root_node_id: root.nodeId,
219
+ root_backend_node_id: Number.isInteger(Number(describedRoot?.backendNodeId))
220
+ ? Number(describedRoot.backendNodeId)
221
+ : null,
222
+ selector,
223
+ node_id: nodeId,
224
+ backend_node_id: Number.isInteger(Number(described?.backendNodeId))
225
+ ? Number(described.backendNodeId)
226
+ : null,
227
+ visible,
228
+ center: box?.center || null,
229
+ rect: box?.rect || null,
230
+ attributes,
231
+ outer_html_length: outerHTML.length,
232
+ html_preview: outerHTML.slice(0, 500),
233
+ ...classification
234
+ };
235
+ }
236
+
237
+ export async function collectRecommendActionControls(client, roots, {
238
+ favoriteSelectors = FAVORITE_BUTTON_SELECTORS,
239
+ greetSelectors = GREET_BUTTON_RECOMMEND_SELECTORS,
240
+ detailTextFallback = false
241
+ } = {}) {
242
+ const candidates = [];
243
+ const favoriteScanSelectors = detailTextFallback
244
+ ? uniqueSelectors(favoriteSelectors, RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS)
245
+ : favoriteSelectors;
246
+ const greetScanSelectors = detailTextFallback
247
+ ? uniqueSelectors(greetSelectors, RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS)
248
+ : greetSelectors;
249
+ for (const root of roots) {
250
+ if (!root?.nodeId) continue;
251
+ for (const [kind, selectors] of [
252
+ ["favorite", favoriteScanSelectors],
253
+ ["greet", greetScanSelectors]
254
+ ]) {
255
+ for (const selector of selectors) {
256
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
257
+ for (const nodeId of nodeIds) {
258
+ candidates.push(await readActionNode(client, {
259
+ root,
260
+ selector,
261
+ nodeId,
262
+ kind
263
+ }));
264
+ }
265
+ }
266
+ }
267
+ }
268
+ return uniqueByNode(candidates);
269
+ }
270
+
271
+ export function summarizeRecommendActionControls(controls = []) {
272
+ const visibleControls = controls.filter((item) => item.visible && item.matches);
273
+ const favoriteControls = visibleControls.filter((item) => item.kind === "favorite");
274
+ const greetControls = visibleControls.filter((item) => item.kind === "greet");
275
+ const favorite = bestControl(
276
+ favoriteControls.filter((item) => item.matches),
277
+ /^(?:收藏|已收藏|感兴趣|已感兴趣)$/i
278
+ );
279
+ const greet = bestControl(
280
+ greetControls.filter((item) => item.available),
281
+ GREET_EXACT_LABEL_PATTERN
282
+ ) || bestControl(
283
+ greetControls.filter((item) => item.continue_chat),
284
+ /^继续沟通$/i
285
+ );
286
+ return {
287
+ favorite: favorite
288
+ ? {
289
+ kind: "favorite",
290
+ found: true,
291
+ active: favorite.active,
292
+ disabled: favorite.disabled,
293
+ label: favorite.label,
294
+ selector: favorite.selector,
295
+ root: favorite.root,
296
+ root_node_id: favorite.root_node_id,
297
+ root_backend_node_id: favorite.root_backend_node_id,
298
+ node_id: favorite.node_id,
299
+ backend_node_id: favorite.backend_node_id,
300
+ center: favorite.center,
301
+ rect: favorite.rect
302
+ }
303
+ : { found: false },
304
+ greet: greet
305
+ ? {
306
+ kind: "greet",
307
+ found: true,
308
+ available: greet.available,
309
+ continue_chat: greet.continue_chat,
310
+ disabled: greet.disabled,
311
+ label: greet.label,
312
+ greet_quota: greet.greet_quota || null,
313
+ selector: greet.selector,
314
+ root: greet.root,
315
+ root_node_id: greet.root_node_id,
316
+ root_backend_node_id: greet.root_backend_node_id,
317
+ node_id: greet.node_id,
318
+ backend_node_id: greet.backend_node_id,
319
+ center: greet.center,
320
+ rect: greet.rect
321
+ }
322
+ : { found: false },
323
+ counts: {
324
+ total: controls.length,
325
+ visible_matching: visibleControls.length,
326
+ favorite: favoriteControls.length,
327
+ greet: greetControls.length
328
+ }
329
+ };
330
+ }
331
+
332
+ export async function discoverRecommendActionControls(client, {
333
+ roots = null,
334
+ selectors = {},
335
+ detailTextFallback = false
336
+ } = {}) {
337
+ const rootState = roots ? { roots } : await getRecommendRoots(client);
338
+ const controls = await collectRecommendActionControls(client, rootState.roots, {
339
+ ...selectors,
340
+ detailTextFallback
341
+ });
342
+ return {
343
+ controls,
344
+ summary: summarizeRecommendActionControls(controls)
345
+ };
346
+ }
347
+
348
+ export async function waitForRecommendActionControls(client, {
349
+ timeoutMs = 6000,
350
+ intervalMs = 250,
351
+ requireAny = true,
352
+ ...discoveryOptions
353
+ } = {}) {
354
+ const started = Date.now();
355
+ let lastDiscovery = null;
356
+ while (Date.now() - started <= timeoutMs) {
357
+ lastDiscovery = await discoverRecommendActionControls(client, discoveryOptions);
358
+ const hasControl = Boolean(
359
+ lastDiscovery.summary.favorite.found
360
+ || lastDiscovery.summary.greet.found
361
+ );
362
+ if (!requireAny || hasControl) {
363
+ return {
364
+ ...lastDiscovery,
365
+ elapsed_ms: Date.now() - started,
366
+ timed_out: false
367
+ };
368
+ }
369
+ await sleep(intervalMs);
370
+ }
371
+ return {
372
+ ...(lastDiscovery || { controls: [], summary: summarizeRecommendActionControls([]) }),
373
+ elapsed_ms: Date.now() - started,
374
+ timed_out: true
375
+ };
376
+ }
377
+
378
+ export async function getRecommendDetailActionRoots(client, detailState) {
379
+ const roots = [];
380
+ if (detailState?.popup?.node_id) {
381
+ roots.push({
382
+ name: `${detailState.popup.root || "unknown"}:detail-popup`,
383
+ nodeId: detailState.popup.node_id
384
+ });
385
+ }
386
+ if (detailState?.resumeIframe?.node_id) {
387
+ try {
388
+ roots.push({
389
+ name: `${detailState.resumeIframe.root || "unknown"}:resume-iframe-document`,
390
+ nodeId: await getFrameDocumentNodeId(client, detailState.resumeIframe.node_id)
391
+ });
392
+ } catch {
393
+ roots.push({
394
+ name: `${detailState.resumeIframe.root || "unknown"}:resume-iframe-node`,
395
+ nodeId: detailState.resumeIframe.node_id
396
+ });
397
+ }
398
+ }
399
+ return roots;
400
+ }
401
+
402
+ export async function waitForRecommendDetailActionControls(client, {
403
+ timeoutMs = 8000,
404
+ intervalMs = 350,
405
+ selectors = {},
406
+ requireAny = true,
407
+ requireContinueChat = false
408
+ } = {}) {
409
+ const started = Date.now();
410
+ let lastDiscovery = null;
411
+ let lastError = null;
412
+ let lastRootCount = 0;
413
+ while (Date.now() - started <= timeoutMs) {
414
+ const detailState = await waitForRecommendDetail(client, {
415
+ timeoutMs: Math.min(intervalMs, 500),
416
+ intervalMs: 100
417
+ });
418
+ const roots = await getRecommendDetailActionRoots(client, detailState);
419
+ lastRootCount = roots.length;
420
+ if (roots.length) {
421
+ try {
422
+ lastDiscovery = await discoverRecommendActionControls(client, {
423
+ roots,
424
+ selectors,
425
+ detailTextFallback: true
426
+ });
427
+ const hasControl = Boolean(
428
+ lastDiscovery.summary.favorite.found
429
+ || lastDiscovery.summary.greet.found
430
+ );
431
+ const hasExactContinueChat = Boolean(
432
+ lastDiscovery.summary.greet?.continue_chat === true
433
+ && normalizeText(lastDiscovery.summary.greet?.label) === "继续沟通"
434
+ );
435
+ if (requireContinueChat ? hasExactContinueChat : (!requireAny || hasControl)) {
436
+ return {
437
+ ...lastDiscovery,
438
+ elapsed_ms: Date.now() - started,
439
+ timed_out: false,
440
+ detail_root_count: roots.length
441
+ };
442
+ }
443
+ } catch (error) {
444
+ lastError = error?.message || String(error);
445
+ }
446
+ }
447
+ await sleep(intervalMs);
448
+ }
449
+ return {
450
+ ...(lastDiscovery || { controls: [], summary: summarizeRecommendActionControls([]) }),
451
+ elapsed_ms: Date.now() - started,
452
+ timed_out: true,
453
+ detail_root_count: lastRootCount,
454
+ last_error: lastError
455
+ };
456
+ }
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
+
959
+ export async function clickRecommendActionControl(client, control, {
960
+ allowDisabled = false,
961
+ beforeRefresh = null,
962
+ beforeFinalRefresh = null,
963
+ beforeClick = null,
964
+ beforeInput = null,
965
+ immediatelyBeforeInput = null
966
+ } = {}) {
967
+ const greetQuota = control?.kind === "greet"
968
+ ? assertGreetQuotaAvailable(control.greet_quota || control.label || "")
969
+ : null;
970
+ if (control?.disabled && !allowDisabled) {
971
+ throw new Error(`Action control is disabled: ${control.kind}`);
972
+ }
973
+
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";
1006
+ try {
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);
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
+ }
1068
+ refreshStep = "read_box_model";
1069
+ const box = await getNodeBox(client, nodeId);
1070
+ const centerX = Number(box?.center?.x);
1071
+ const centerY = Number(box?.center?.y);
1072
+ const width = Number(box?.rect?.width);
1073
+ const height = Number(box?.rect?.height);
1074
+ if (
1075
+ !Number.isFinite(centerX)
1076
+ || !Number.isFinite(centerY)
1077
+ || !Number.isFinite(width)
1078
+ || !Number.isFinite(height)
1079
+ || width <= 2
1080
+ || height <= 2
1081
+ ) {
1082
+ const error = new Error(
1083
+ `Could not compute box model for recommend action control nodeId=${nodeId}`
1084
+ );
1085
+ error.code = "RECOMMEND_ACTION_CONTROL_GEOMETRY_UNREADABLE";
1086
+ throw error;
1087
+ }
1088
+ clickCenter = box.center;
1089
+ clickRect = box.rect;
1090
+ refreshStep = "verify_detail_scope_before_journal";
1091
+ await verifyRecommendActionControlScope(client, activeControl);
1092
+ } catch (error) {
1093
+ const failure = error instanceof Error
1094
+ ? error
1095
+ : new Error(String(error || "Recommend action control refresh failed"));
1096
+ failure.code = failure.code || "RECOMMEND_ACTION_CONTROL_REFRESH_FAILED";
1097
+ failure.phase = failure.phase || "recommend:post-action-control-refresh";
1098
+ failure.action_control_refresh_step = failure.action_control_refresh_step || refreshStep;
1099
+ failure.action_control = failure.action_control || {
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
1107
+ };
1108
+ failure.cached_center_ignored = Boolean(control?.center);
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;
1122
+ failure.cdp_param_keys = Array.isArray(failure.cdp_param_keys)
1123
+ ? failure.cdp_param_keys
1124
+ : refreshStep.startsWith("rebind_")
1125
+ ? ["backendNodeIds"]
1126
+ : ["nodeId"];
1127
+ throw failure;
1128
+ }
1129
+ }
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;
1199
+ }
1200
+ await clickPoint(client, clickCenter.x, clickCenter.y, {
1201
+ humanRestEnabled: false,
1202
+ moveBeforePress: false
1203
+ });
1204
+ return {
1205
+ clicked: true,
1206
+ kind: activeControl.kind,
1207
+ label: activeControl.label,
1208
+ greet_quota: greetQuota?.found ? greetQuota : null,
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,
1215
+ center: clickCenter,
1216
+ rect: clickRect,
1217
+ click_target_proof: clickTargetProof
1218
+ };
1219
+ }