@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.
- package/README.md +5 -0
- package/bin/boss-recommend-mcp.js +4 -4
- package/package.json +6 -1
- package/scripts/install-macos.sh +280 -280
- package/scripts/postinstall.cjs +44 -44
- package/skills/boss-chat/README.md +43 -42
- package/skills/boss-chat/SKILL.md +107 -106
- package/skills/boss-recommend-pipeline/README.md +13 -13
- package/skills/boss-recommend-pipeline/SKILL.md +47 -47
- package/skills/boss-recruit-pipeline/README.md +19 -19
- package/skills/boss-recruit-pipeline/SKILL.md +89 -89
- package/src/chat-mcp.js +301 -127
- package/src/chat-runtime-config.js +7 -5
- package/src/core/boss-cards/index.js +199 -199
- package/src/core/browser/index.js +302 -145
- package/src/core/capture/index.js +2930 -1201
- package/src/core/cv-acquisition/index.js +512 -238
- package/src/core/cv-capture-target/index.js +513 -299
- package/src/core/greet-quota/index.js +71 -71
- package/src/core/infinite-list/index.js +31 -31
- package/src/core/reporting/legacy-csv.js +12 -12
- package/src/core/run/detached-launcher.js +305 -0
- package/src/core/run/index.js +109 -55
- package/src/core/run/timing.js +33 -33
- package/src/core/run/windows-detached-worker.ps1 +106 -0
- package/src/core/screening/index.js +2400 -2135
- package/src/core/self-heal/index.js +989 -973
- package/src/core/self-heal/viewport.js +1505 -564
- package/src/detached-worker.js +99 -99
- package/src/domains/chat/action-journal.js +536 -0
- package/src/domains/chat/cards.js +137 -137
- package/src/domains/chat/constants.js +9 -9
- package/src/domains/chat/detail.js +1684 -401
- package/src/domains/chat/index.js +8 -7
- package/src/domains/chat/jobs.js +620 -620
- package/src/domains/chat/page-guard.js +157 -122
- package/src/domains/chat/roots.js +56 -56
- package/src/domains/chat/run-service.js +1801 -760
- package/src/domains/common/account-rights-panel.js +314 -314
- package/src/domains/common/recovery-settle.js +159 -159
- package/src/domains/recommend/actions.js +1219 -472
- package/src/domains/recommend/cards.js +243 -243
- package/src/domains/recommend/colleague-contact.js +1079 -333
- package/src/domains/recommend/constants.js +92 -92
- package/src/domains/recommend/detail.js +4037 -136
- package/src/domains/recommend/filters.js +608 -590
- package/src/domains/recommend/index.js +9 -9
- package/src/domains/recommend/jobs.js +571 -542
- package/src/domains/recommend/location.js +754 -707
- package/src/domains/recommend/refresh.js +677 -392
- package/src/domains/recommend/roots.js +80 -80
- package/src/domains/recommend/run-service.js +4048 -1447
- package/src/domains/recommend/scopes.js +246 -246
- package/src/domains/recruit/actions.js +277 -277
- package/src/domains/recruit/cards.js +74 -74
- package/src/domains/recruit/constants.js +236 -236
- package/src/domains/recruit/detail.js +588 -588
- package/src/domains/recruit/index.js +9 -9
- package/src/domains/recruit/instruction-parser.js +866 -866
- package/src/domains/recruit/refresh.js +45 -45
- package/src/domains/recruit/roots.js +68 -68
- package/src/domains/recruit/run-service.js +1817 -1620
- package/src/domains/recruit/search.js +3229 -3229
- package/src/index.js +16 -1
- package/src/parser.js +1296 -1296
- package/src/recommend-mcp.js +1061 -450
- package/src/recommend-scheduler.js +75 -75
|
@@ -1,472 +1,1219 @@
|
|
|
1
|
-
import {
|
|
2
|
-
clickPoint,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
seen.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
if (selector
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
score +=
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (["
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
||
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
||
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
||
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
||
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
: { found: false },
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
+
}
|