@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,736 +1,783 @@
1
- import {
2
- clickNodeCenter,
3
- DETERMINISTIC_CLICK_OPTIONS,
4
- getAttributesMap,
5
- getNodeBox,
6
- getOuterHTML,
7
- pressKey,
8
- querySelectorAll,
9
- sleep
10
- } from "../../core/browser/index.js";
11
- import { htmlToText, normalizeText } from "../../core/screening/index.js";
12
- import {
13
- RECOMMEND_CURRENT_CITY_ONLY_LABEL,
14
- RECOMMEND_LOCATION_SELECTORS
15
- } from "./constants.js";
16
-
17
- function normalizeControlText(value) {
18
- return normalizeText(value).replace(/\s+/g, "");
19
- }
20
-
21
- function hasOwn(object, key) {
22
- return Object.prototype.hasOwnProperty.call(object || {}, key);
23
- }
24
-
25
- function parseBooleanState(value) {
26
- if (typeof value === "boolean") return value;
27
- const normalized = String(value ?? "").trim().toLowerCase();
28
- if (["true", "1", "yes", "on", "checked", "selected"].includes(normalized)) return true;
29
- if (["false", "0", "no", "off", "unchecked", "unselected"].includes(normalized)) return false;
30
- return null;
31
- }
32
-
33
- function uniqueNodeIds(nodeIds = []) {
34
- return [...new Set(nodeIds.filter((nodeId) => Number(nodeId) > 0))];
35
- }
36
-
37
- async function getVisibleBox(client, nodeId) {
38
- try {
39
- const box = await getNodeBox(client, nodeId);
40
- if (box.rect.width <= 0 || box.rect.height <= 0) return null;
41
- return box;
42
- } catch {
43
- return null;
44
- }
45
- }
46
-
47
- function extractCityLabel(attributes = {}, outerHTML = "") {
48
- const cleanCityLabel = (value) => normalizeText(value).replace(/^仅看\s*/, "").trim();
49
- const attributeValue = [
50
- attributes["data-city"],
51
- attributes["data-name"],
52
- attributes.title,
53
- attributes["aria-label"]
54
- ].map(cleanCityLabel).find((value) => value && value.length <= 24);
55
- if (attributeValue) return attributeValue;
56
- const text = cleanCityLabel(htmlToText(outerHTML)).replace(/\s+/g, " ").trim();
57
- if (!text || text.length > 24 || text.includes(RECOMMEND_CURRENT_CITY_ONLY_LABEL)) return null;
58
- return text;
59
- }
60
-
61
- async function readLocationTriggerCandidate(client, nodeId, selector) {
62
- const [attributes, outerHTML, box] = await Promise.all([
63
- getAttributesMap(client, nodeId),
64
- getOuterHTML(client, nodeId),
65
- getVisibleBox(client, nodeId)
66
- ]);
67
- if (!box) return null;
68
- const currentCityLabel = extractCityLabel(attributes, outerHTML);
69
- return {
70
- node_id: nodeId,
71
- selector,
72
- current_city_label: currentCityLabel,
73
- class_name: attributes.class || "",
74
- box
75
- };
76
- }
77
-
78
- export async function findRecommendLocationTrigger(client, frameNodeId) {
79
- const candidates = [];
80
- const seen = new Set();
81
- for (const selector of RECOMMEND_LOCATION_SELECTORS.trigger) {
82
- const nodeIds = await querySelectorAll(client, frameNodeId, selector);
83
- for (const nodeId of nodeIds) {
84
- if (seen.has(nodeId)) continue;
85
- seen.add(nodeId);
86
- const candidate = await readLocationTriggerCandidate(client, nodeId, selector);
87
- if (candidate) candidates.push(candidate);
88
- }
89
- }
90
- candidates.sort((left, right) => {
91
- const labelDiff = Number(Boolean(right.current_city_label)) - Number(Boolean(left.current_city_label));
92
- if (labelDiff !== 0) return labelDiff;
93
- return left.box.rect.width - right.box.rect.width;
94
- });
95
- return candidates[0] || null;
96
- }
97
-
98
- async function readAccessibilityCheckedState(client, nodeId) {
99
- if (typeof client?.Accessibility?.getPartialAXTree !== "function") return null;
100
- try {
101
- const result = await client.Accessibility.getPartialAXTree({
102
- nodeId,
103
- fetchRelatives: true
104
- });
105
- for (const node of result.nodes || []) {
106
- for (const property of node.properties || []) {
107
- if (property.name !== "checked") continue;
108
- const checked = parseBooleanState(property.value?.value);
109
- if (checked !== null) {
110
- return {
111
- checked,
112
- source: "accessibility.checked"
113
- };
114
- }
115
- }
116
- }
117
- } catch {
118
- // Attribute and verified class fallbacks remain CDP-only and cover custom controls.
119
- }
120
- return null;
121
- }
122
-
123
- function readAttributeCheckedState(attributes = {}, source = "attributes") {
124
- for (const key of ["aria-checked", "data-checked", "data-state"]) {
125
- if (!hasOwn(attributes, key)) continue;
126
- const checked = parseBooleanState(attributes[key]);
127
- if (checked !== null) return { checked, source: `${source}.${key}` };
128
- }
129
- if (hasOwn(attributes, "checked")) {
130
- return { checked: true, source: `${source}.checked` };
131
- }
132
- const className = String(attributes.class || "");
133
- if (/(?:^|\s)(?:is-checked|checked|selected)(?:\s|$)/i.test(className)) {
134
- return { checked: true, source: `${source}.class` };
135
- }
136
- if (/(?:^|\s)(?:is-unchecked|unchecked)(?:\s|$)/i.test(className)) {
137
- return { checked: false, source: `${source}.class` };
138
- }
139
- return null;
140
- }
141
-
142
- async function readCurrentCityCheckboxState(client, nodeId) {
143
- const nestedNodeIds = uniqueNodeIds(await querySelectorAll(
144
- client,
145
- nodeId,
146
- RECOMMEND_LOCATION_SELECTORS.checkboxInput
147
- ));
148
- const stateNodeIds = uniqueNodeIds([nodeId, ...nestedNodeIds]);
149
- for (const stateNodeId of stateNodeIds) {
150
- const accessibility = await readAccessibilityCheckedState(client, stateNodeId);
151
- if (accessibility) {
152
- return {
153
- ...accessibility,
154
- state_node_id: stateNodeId,
155
- input_node_ids: nestedNodeIds
156
- };
157
- }
158
- }
159
- const attributesByNode = [];
160
- for (const stateNodeId of stateNodeIds) {
161
- const attributes = await getAttributesMap(client, stateNodeId);
162
- attributesByNode.push({ node_id: stateNodeId, attributes });
163
- const state = readAttributeCheckedState(attributes, `node_${stateNodeId}`);
164
- if (state) {
165
- return {
166
- ...state,
167
- state_node_id: stateNodeId,
168
- input_node_ids: nestedNodeIds
169
- };
170
- }
171
- }
172
- const checkboxInput = attributesByNode.find(({ attributes }) => (
173
- String(attributes.type || "").toLowerCase() === "checkbox"
174
- || String(attributes.role || "").toLowerCase() === "checkbox"
175
- ));
176
- if (checkboxInput) {
177
- return {
178
- checked: false,
179
- source: `node_${checkboxInput.node_id}.unchecked_input`,
180
- state_node_id: checkboxInput.node_id,
181
- input_node_ids: nestedNodeIds
182
- };
183
- }
184
- const wrapper = attributesByNode[0]?.attributes || {};
185
- if (/(?:^|\s)(?:checkbox|check-box)(?:\s|$)/i.test(String(wrapper.class || ""))) {
186
- return {
187
- checked: false,
188
- source: `node_${nodeId}.unchecked_class`,
189
- state_node_id: nodeId,
190
- input_node_ids: nestedNodeIds
191
- };
192
- }
193
- return {
194
- checked: null,
195
- source: "unreadable",
196
- state_node_id: null,
197
- input_node_ids: nestedNodeIds
198
- };
199
- }
200
-
201
- async function readCurrentCityControlCandidates(client, nodeIds) {
202
- const matchingCandidates = [];
203
- for (const nodeId of nodeIds) {
204
- let outerHTML;
205
- try {
206
- outerHTML = await getOuterHTML(client, nodeId);
207
- } catch {
208
- continue;
209
- }
210
- const label = normalizeControlText(htmlToText(outerHTML));
211
- if (!label.includes(normalizeControlText(RECOMMEND_CURRENT_CITY_ONLY_LABEL))) continue;
212
- const box = await getVisibleBox(client, nodeId);
213
- if (!box) continue;
214
- const state = await readCurrentCityCheckboxState(client, nodeId);
215
- matchingCandidates.push({
216
- node_id: nodeId,
217
- label,
218
- exact_label: label === normalizeControlText(RECOMMEND_CURRENT_CITY_ONLY_LABEL),
219
- box,
220
- state
221
- });
222
- }
223
- matchingCandidates.sort((left, right) => {
224
- const readableDiff = Number(right.state.checked !== null) - Number(left.state.checked !== null);
225
- if (readableDiff !== 0) return readableDiff;
226
- const exactDiff = Number(right.exact_label) - Number(left.exact_label);
227
- if (exactDiff !== 0) return exactDiff;
228
- return left.label.length - right.label.length;
229
- });
230
- return matchingCandidates;
231
- }
232
-
233
- export async function findRecommendCurrentCityControl(client, frameNodeId) {
234
- const calibratedNodeIds = uniqueNodeIds(await querySelectorAll(
235
- client,
236
- frameNodeId,
237
- RECOMMEND_LOCATION_SELECTORS.checkboxCalibrated
238
- ));
239
- let matchingCandidates = await readCurrentCityControlCandidates(client, calibratedNodeIds);
240
- if (!matchingCandidates.length) {
241
- const fallbackNodeIds = uniqueNodeIds(await querySelectorAll(
242
- client,
243
- frameNodeId,
244
- RECOMMEND_LOCATION_SELECTORS.checkboxCandidates
245
- ));
246
- matchingCandidates = await readCurrentCityControlCandidates(client, fallbackNodeIds);
247
- }
248
- if (!matchingCandidates.length) return null;
249
- const best = matchingCandidates[0];
250
- return {
251
- ...best,
252
- visible: true,
253
- readable: best.state.checked !== null,
254
- matching_candidate_count: matchingCandidates.length
255
- };
256
- }
257
-
258
- async function getControlAncestorNodeIds(client, nodeId, frameNodeId, maxDepth = 10) {
259
- const ancestors = [];
260
- let currentNodeId = nodeId;
261
- for (let depth = 0; depth < maxDepth; depth += 1) {
262
- const described = await client.DOM.describeNode({
263
- nodeId: currentNodeId,
264
- depth: 0,
265
- pierce: true
266
- });
267
- const parentId = described.node?.parentId || 0;
268
- if (!parentId || parentId === frameNodeId) break;
269
- ancestors.push(parentId);
270
- currentNodeId = parentId;
271
- }
272
- return uniqueNodeIds(ancestors);
273
- }
274
-
275
- async function findExactLocationConfirmCandidates(client, frameNodeId, {
276
- controlNodeId
277
- } = {}) {
278
- if (!controlNodeId) return [];
279
- const popoverNodeIds = uniqueNodeIds(await querySelectorAll(
280
- client,
281
- frameNodeId,
282
- RECOMMEND_LOCATION_SELECTORS.popoverCandidates
283
- ));
284
- for (const popoverNodeId of popoverNodeIds) {
285
- const popoverHTML = await getOuterHTML(client, popoverNodeId);
286
- if (!normalizeControlText(htmlToText(popoverHTML)).includes(
287
- normalizeControlText(RECOMMEND_CURRENT_CITY_ONLY_LABEL)
288
- )) continue;
289
- const nodeIds = uniqueNodeIds(await querySelectorAll(
290
- client,
291
- popoverNodeId,
292
- RECOMMEND_LOCATION_SELECTORS.confirmCandidates
293
- ));
294
- const candidates = [];
295
- for (const nodeId of nodeIds) {
296
- const outerHTML = await getOuterHTML(client, nodeId);
297
- const label = normalizeControlText(htmlToText(outerHTML));
298
- if (label !== normalizeControlText("确认")) continue;
299
- const box = await getVisibleBox(client, nodeId);
300
- if (!box) continue;
301
- candidates.push({
302
- node_id: nodeId,
303
- label: "确认",
304
- box,
305
- scope_node_id: popoverNodeId
306
- });
307
- }
308
- if (candidates.length) return candidates;
309
- }
310
- const ancestorNodeIds = await getControlAncestorNodeIds(client, controlNodeId, frameNodeId);
311
- for (const ancestorNodeId of ancestorNodeIds) {
312
- const nodeIds = uniqueNodeIds(await querySelectorAll(
313
- client,
314
- ancestorNodeId,
315
- RECOMMEND_LOCATION_SELECTORS.confirmCandidates
316
- ));
317
- const candidates = [];
318
- for (const nodeId of nodeIds) {
319
- const outerHTML = await getOuterHTML(client, nodeId);
320
- const label = normalizeControlText(htmlToText(outerHTML));
321
- if (label !== normalizeControlText("确认")) continue;
322
- const box = await getVisibleBox(client, nodeId);
323
- if (!box) continue;
324
- candidates.push({
325
- node_id: nodeId,
326
- label: "确认",
327
- box,
328
- scope_node_id: ancestorNodeId
329
- });
330
- }
331
- if (candidates.length) return candidates;
332
- }
333
- return [];
334
- }
335
-
336
- async function waitForCurrentCityControl(client, frameNodeId, {
337
- timeoutMs = 1800,
338
- intervalMs = 150
339
- } = {}) {
340
- const started = Date.now();
341
- while (true) {
342
- const control = await findRecommendCurrentCityControl(client, frameNodeId);
343
- if (control) return control;
344
- if (Date.now() - started >= timeoutMs) break;
345
- if (intervalMs > 0) await sleep(intervalMs);
346
- }
347
- return null;
348
- }
349
-
350
- async function findVisibleRecommendLocationPopover(client, frameNodeId) {
351
- const nodeIds = uniqueNodeIds(await querySelectorAll(
352
- client,
353
- frameNodeId,
354
- RECOMMEND_LOCATION_SELECTORS.popoverCandidates
355
- ));
356
- for (const nodeId of nodeIds) {
357
- const box = await getVisibleBox(client, nodeId);
358
- if (box) return { node_id: nodeId, box };
359
- }
360
- return null;
361
- }
362
-
363
- async function waitForRecommendLocationPopoverState(client, frameNodeId, {
364
- timeoutMs = 1800,
365
- intervalMs = 150
366
- } = {}) {
367
- const started = Date.now();
368
- let popover = null;
369
- while (true) {
370
- const control = await findRecommendCurrentCityControl(client, frameNodeId);
371
- if (control) return { opened: true, control, popover };
372
- popover = await findVisibleRecommendLocationPopover(client, frameNodeId) || popover;
373
- if (Date.now() - started >= timeoutMs) break;
374
- if (intervalMs > 0) await sleep(intervalMs);
375
- }
376
- return {
377
- opened: Boolean(popover),
378
- control: null,
379
- popover
380
- };
381
- }
382
-
383
- async function openRecommendLocationPopover(client, frameNodeId, {
384
- timeoutMs = 1800,
385
- intervalMs = 150,
386
- attemptsLimit = 3
387
- } = {}) {
388
- const attempts = [];
389
- const alreadyOpenControl = await findRecommendCurrentCityControl(client, frameNodeId);
390
- if (alreadyOpenControl) {
391
- const trigger = await findRecommendLocationTrigger(client, frameNodeId);
392
- return {
393
- opened: true,
394
- already_open: true,
395
- trigger,
396
- control: alreadyOpenControl,
397
- attempts,
398
- reason: "control_already_open"
399
- };
400
- }
401
- const alreadyOpenPopover = await findVisibleRecommendLocationPopover(client, frameNodeId);
402
- if (alreadyOpenPopover) {
403
- const state = await waitForRecommendLocationPopoverState(client, frameNodeId, {
404
- timeoutMs,
405
- intervalMs
406
- });
407
- const trigger = await findRecommendLocationTrigger(client, frameNodeId);
408
- return {
409
- opened: true,
410
- already_open: true,
411
- trigger,
412
- control: state.control,
413
- popover: state.popover || alreadyOpenPopover,
414
- attempts,
415
- reason: state.control ? "control_already_open" : "popover_already_open_without_control"
416
- };
417
- }
418
- for (let attempt = 1; attempt <= attemptsLimit; attempt += 1) {
419
- const trigger = await findRecommendLocationTrigger(client, frameNodeId);
420
- if (!trigger) {
421
- return {
422
- opened: false,
423
- trigger: null,
424
- control: null,
425
- attempts,
426
- reason: "location_trigger_unavailable"
427
- };
428
- }
429
- try {
430
- const click = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS);
431
- const state = await waitForRecommendLocationPopoverState(client, frameNodeId, {
432
- timeoutMs,
433
- intervalMs
434
- });
435
- attempts.push({
436
- attempt,
437
- trigger_node_id: trigger.node_id,
438
- trigger_selector: trigger.selector,
439
- clicked: true,
440
- click_target: click.click_target,
441
- popover_found: Boolean(state.popover),
442
- control_found: Boolean(state.control)
443
- });
444
- if (state.opened) {
445
- return {
446
- opened: true,
447
- trigger,
448
- control: state.control,
449
- popover: state.popover,
450
- attempts,
451
- reason: state.control ? "control_found" : "control_unavailable"
452
- };
453
- }
454
- } catch (error) {
455
- attempts.push({
456
- attempt,
457
- trigger_node_id: trigger.node_id,
458
- trigger_selector: trigger.selector,
459
- clicked: false,
460
- error: error?.message || String(error)
461
- });
462
- if (typeof client?.Input?.dispatchKeyEvent === "function") {
463
- await pressKey(client, "Escape", {
464
- code: "Escape",
465
- windowsVirtualKeyCode: 27,
466
- nativeVirtualKeyCode: 27
467
- });
468
- attempts.at(-1).recovery = "Escape";
469
- }
470
- }
471
- }
472
- return {
473
- opened: false,
474
- trigger: null,
475
- control: null,
476
- attempts,
477
- reason: "location_popover_did_not_open"
478
- };
479
- }
480
-
1
+ import {
2
+ clickNodeCenter,
3
+ DETERMINISTIC_CLICK_OPTIONS,
4
+ getAttributesMap,
5
+ getNodeBox,
6
+ getOuterHTML,
7
+ pressKey,
8
+ querySelectorAll,
9
+ sleep
10
+ } from "../../core/browser/index.js";
11
+ import { htmlToText, normalizeText } from "../../core/screening/index.js";
12
+ import {
13
+ RECOMMEND_CURRENT_CITY_ONLY_LABEL,
14
+ RECOMMEND_LOCATION_SELECTORS
15
+ } from "./constants.js";
16
+
17
+ function normalizeControlText(value) {
18
+ return normalizeText(value).replace(/\s+/g, "");
19
+ }
20
+
21
+ function hasOwn(object, key) {
22
+ return Object.prototype.hasOwnProperty.call(object || {}, key);
23
+ }
24
+
25
+ function parseBooleanState(value) {
26
+ if (typeof value === "boolean") return value;
27
+ const normalized = String(value ?? "").trim().toLowerCase();
28
+ if (["true", "1", "yes", "on", "checked", "selected"].includes(normalized)) return true;
29
+ if (["false", "0", "no", "off", "unchecked", "unselected"].includes(normalized)) return false;
30
+ return null;
31
+ }
32
+
33
+ function uniqueNodeIds(nodeIds = []) {
34
+ return [...new Set(nodeIds.filter((nodeId) => Number(nodeId) > 0))];
35
+ }
36
+
37
+ async function getVisibleBox(client, nodeId) {
38
+ try {
39
+ const box = await getNodeBox(client, nodeId);
40
+ if (box.rect.width <= 0 || box.rect.height <= 0) return null;
41
+ return box;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function extractCityLabel(attributes = {}, outerHTML = "") {
48
+ const cleanCityLabel = (value) => normalizeText(value).replace(/^仅看\s*/, "").trim();
49
+ const attributeValue = [
50
+ attributes["data-city"],
51
+ attributes["data-name"],
52
+ attributes.title,
53
+ attributes["aria-label"]
54
+ ].map(cleanCityLabel).find((value) => value && value.length <= 24);
55
+ if (attributeValue) return attributeValue;
56
+ const text = cleanCityLabel(htmlToText(outerHTML)).replace(/\s+/g, " ").trim();
57
+ if (!text || text.length > 24 || text.includes(RECOMMEND_CURRENT_CITY_ONLY_LABEL)) return null;
58
+ return text;
59
+ }
60
+
61
+ async function readLocationTriggerCandidate(client, nodeId, selector) {
62
+ const [attributes, outerHTML, box] = await Promise.all([
63
+ getAttributesMap(client, nodeId),
64
+ getOuterHTML(client, nodeId),
65
+ getVisibleBox(client, nodeId)
66
+ ]);
67
+ if (!box) return null;
68
+ const currentCityLabel = extractCityLabel(attributes, outerHTML);
69
+ return {
70
+ node_id: nodeId,
71
+ selector,
72
+ current_city_label: currentCityLabel,
73
+ class_name: attributes.class || "",
74
+ box
75
+ };
76
+ }
77
+
78
+ export async function findRecommendLocationTrigger(client, frameNodeId) {
79
+ const candidates = [];
80
+ const seen = new Set();
81
+ for (const selector of RECOMMEND_LOCATION_SELECTORS.trigger) {
82
+ const nodeIds = await querySelectorAll(client, frameNodeId, selector);
83
+ for (const nodeId of nodeIds) {
84
+ if (seen.has(nodeId)) continue;
85
+ seen.add(nodeId);
86
+ const candidate = await readLocationTriggerCandidate(client, nodeId, selector);
87
+ if (candidate) candidates.push(candidate);
88
+ }
89
+ }
90
+ candidates.sort((left, right) => {
91
+ const labelDiff = Number(Boolean(right.current_city_label)) - Number(Boolean(left.current_city_label));
92
+ if (labelDiff !== 0) return labelDiff;
93
+ return left.box.rect.width - right.box.rect.width;
94
+ });
95
+ return candidates[0] || null;
96
+ }
97
+
98
+ async function readAccessibilityCheckedState(client, nodeId) {
99
+ if (typeof client?.Accessibility?.getPartialAXTree !== "function") return null;
100
+ try {
101
+ const result = await client.Accessibility.getPartialAXTree({
102
+ nodeId,
103
+ fetchRelatives: true
104
+ });
105
+ for (const node of result.nodes || []) {
106
+ for (const property of node.properties || []) {
107
+ if (property.name !== "checked") continue;
108
+ const checked = parseBooleanState(property.value?.value);
109
+ if (checked !== null) {
110
+ return {
111
+ checked,
112
+ source: "accessibility.checked"
113
+ };
114
+ }
115
+ }
116
+ }
117
+ } catch {
118
+ // Attribute and verified class fallbacks remain CDP-only and cover custom controls.
119
+ }
120
+ return null;
121
+ }
122
+
123
+ function readAttributeCheckedState(attributes = {}, source = "attributes") {
124
+ for (const key of ["aria-checked", "data-checked", "data-state"]) {
125
+ if (!hasOwn(attributes, key)) continue;
126
+ const checked = parseBooleanState(attributes[key]);
127
+ if (checked !== null) return { checked, source: `${source}.${key}` };
128
+ }
129
+ if (hasOwn(attributes, "checked")) {
130
+ return { checked: true, source: `${source}.checked` };
131
+ }
132
+ const className = String(attributes.class || "");
133
+ if (/(?:^|\s)(?:is-checked|checked|selected)(?:\s|$)/i.test(className)) {
134
+ return { checked: true, source: `${source}.class` };
135
+ }
136
+ if (/(?:^|\s)(?:is-unchecked|unchecked)(?:\s|$)/i.test(className)) {
137
+ return { checked: false, source: `${source}.class` };
138
+ }
139
+ return null;
140
+ }
141
+
142
+ async function readCurrentCityCheckboxState(client, nodeId) {
143
+ const nestedNodeIds = uniqueNodeIds(await querySelectorAll(
144
+ client,
145
+ nodeId,
146
+ RECOMMEND_LOCATION_SELECTORS.checkboxInput
147
+ ));
148
+ const stateNodeIds = uniqueNodeIds([nodeId, ...nestedNodeIds]);
149
+ for (const stateNodeId of stateNodeIds) {
150
+ const accessibility = await readAccessibilityCheckedState(client, stateNodeId);
151
+ if (accessibility) {
152
+ return {
153
+ ...accessibility,
154
+ state_node_id: stateNodeId,
155
+ input_node_ids: nestedNodeIds
156
+ };
157
+ }
158
+ }
159
+ const attributesByNode = [];
160
+ for (const stateNodeId of stateNodeIds) {
161
+ const attributes = await getAttributesMap(client, stateNodeId);
162
+ attributesByNode.push({ node_id: stateNodeId, attributes });
163
+ const state = readAttributeCheckedState(attributes, `node_${stateNodeId}`);
164
+ if (state) {
165
+ return {
166
+ ...state,
167
+ state_node_id: stateNodeId,
168
+ input_node_ids: nestedNodeIds
169
+ };
170
+ }
171
+ }
172
+ const checkboxInput = attributesByNode.find(({ attributes }) => (
173
+ String(attributes.type || "").toLowerCase() === "checkbox"
174
+ || String(attributes.role || "").toLowerCase() === "checkbox"
175
+ ));
176
+ if (checkboxInput) {
177
+ return {
178
+ checked: false,
179
+ source: `node_${checkboxInput.node_id}.unchecked_input`,
180
+ state_node_id: checkboxInput.node_id,
181
+ input_node_ids: nestedNodeIds
182
+ };
183
+ }
184
+ const wrapper = attributesByNode[0]?.attributes || {};
185
+ if (/(?:^|\s)(?:checkbox|check-box)(?:\s|$)/i.test(String(wrapper.class || ""))) {
186
+ return {
187
+ checked: false,
188
+ source: `node_${nodeId}.unchecked_class`,
189
+ state_node_id: nodeId,
190
+ input_node_ids: nestedNodeIds
191
+ };
192
+ }
193
+ return {
194
+ checked: null,
195
+ source: "unreadable",
196
+ state_node_id: null,
197
+ input_node_ids: nestedNodeIds
198
+ };
199
+ }
200
+
201
+ async function readCurrentCityControlCandidates(client, nodeIds) {
202
+ const matchingCandidates = [];
203
+ for (const nodeId of nodeIds) {
204
+ let outerHTML;
205
+ try {
206
+ outerHTML = await getOuterHTML(client, nodeId);
207
+ } catch {
208
+ continue;
209
+ }
210
+ const label = normalizeControlText(htmlToText(outerHTML));
211
+ if (!label.includes(normalizeControlText(RECOMMEND_CURRENT_CITY_ONLY_LABEL))) continue;
212
+ const box = await getVisibleBox(client, nodeId);
213
+ if (!box) continue;
214
+ const state = await readCurrentCityCheckboxState(client, nodeId);
215
+ matchingCandidates.push({
216
+ node_id: nodeId,
217
+ label,
218
+ exact_label: label === normalizeControlText(RECOMMEND_CURRENT_CITY_ONLY_LABEL),
219
+ box,
220
+ state
221
+ });
222
+ }
223
+ matchingCandidates.sort((left, right) => {
224
+ const readableDiff = Number(right.state.checked !== null) - Number(left.state.checked !== null);
225
+ if (readableDiff !== 0) return readableDiff;
226
+ const exactDiff = Number(right.exact_label) - Number(left.exact_label);
227
+ if (exactDiff !== 0) return exactDiff;
228
+ return left.label.length - right.label.length;
229
+ });
230
+ return matchingCandidates;
231
+ }
232
+
233
+ export async function findRecommendCurrentCityControl(client, frameNodeId) {
234
+ const calibratedNodeIds = uniqueNodeIds(await querySelectorAll(
235
+ client,
236
+ frameNodeId,
237
+ RECOMMEND_LOCATION_SELECTORS.checkboxCalibrated
238
+ ));
239
+ let matchingCandidates = await readCurrentCityControlCandidates(client, calibratedNodeIds);
240
+ if (!matchingCandidates.length) {
241
+ const fallbackNodeIds = uniqueNodeIds(await querySelectorAll(
242
+ client,
243
+ frameNodeId,
244
+ RECOMMEND_LOCATION_SELECTORS.checkboxCandidates
245
+ ));
246
+ matchingCandidates = await readCurrentCityControlCandidates(client, fallbackNodeIds);
247
+ }
248
+ if (!matchingCandidates.length) return null;
249
+ const best = matchingCandidates[0];
250
+ return {
251
+ ...best,
252
+ visible: true,
253
+ readable: best.state.checked !== null,
254
+ matching_candidate_count: matchingCandidates.length
255
+ };
256
+ }
257
+
258
+ async function getControlAncestorNodeIds(client, nodeId, frameNodeId, maxDepth = 10) {
259
+ const ancestors = [];
260
+ let currentNodeId = nodeId;
261
+ for (let depth = 0; depth < maxDepth; depth += 1) {
262
+ const described = await client.DOM.describeNode({
263
+ nodeId: currentNodeId,
264
+ depth: 0,
265
+ pierce: true
266
+ });
267
+ const parentId = described.node?.parentId || 0;
268
+ if (!parentId || parentId === frameNodeId) break;
269
+ ancestors.push(parentId);
270
+ currentNodeId = parentId;
271
+ }
272
+ return uniqueNodeIds(ancestors);
273
+ }
274
+
275
+ async function findExactLocationConfirmCandidates(client, frameNodeId, {
276
+ controlNodeId
277
+ } = {}) {
278
+ if (!controlNodeId) return [];
279
+ const popoverNodeIds = uniqueNodeIds(await querySelectorAll(
280
+ client,
281
+ frameNodeId,
282
+ RECOMMEND_LOCATION_SELECTORS.popoverCandidates
283
+ ));
284
+ for (const popoverNodeId of popoverNodeIds) {
285
+ const popoverHTML = await getOuterHTML(client, popoverNodeId);
286
+ if (!normalizeControlText(htmlToText(popoverHTML)).includes(
287
+ normalizeControlText(RECOMMEND_CURRENT_CITY_ONLY_LABEL)
288
+ )) continue;
289
+ const nodeIds = uniqueNodeIds(await querySelectorAll(
290
+ client,
291
+ popoverNodeId,
292
+ RECOMMEND_LOCATION_SELECTORS.confirmCandidates
293
+ ));
294
+ const candidates = [];
295
+ for (const nodeId of nodeIds) {
296
+ const outerHTML = await getOuterHTML(client, nodeId);
297
+ const label = normalizeControlText(htmlToText(outerHTML));
298
+ if (label !== normalizeControlText("确认")) continue;
299
+ const box = await getVisibleBox(client, nodeId);
300
+ if (!box) continue;
301
+ candidates.push({
302
+ node_id: nodeId,
303
+ label: "确认",
304
+ box,
305
+ scope_node_id: popoverNodeId
306
+ });
307
+ }
308
+ if (candidates.length) return candidates;
309
+ }
310
+ const ancestorNodeIds = await getControlAncestorNodeIds(client, controlNodeId, frameNodeId);
311
+ for (const ancestorNodeId of ancestorNodeIds) {
312
+ const nodeIds = uniqueNodeIds(await querySelectorAll(
313
+ client,
314
+ ancestorNodeId,
315
+ RECOMMEND_LOCATION_SELECTORS.confirmCandidates
316
+ ));
317
+ const candidates = [];
318
+ for (const nodeId of nodeIds) {
319
+ const outerHTML = await getOuterHTML(client, nodeId);
320
+ const label = normalizeControlText(htmlToText(outerHTML));
321
+ if (label !== normalizeControlText("确认")) continue;
322
+ const box = await getVisibleBox(client, nodeId);
323
+ if (!box) continue;
324
+ candidates.push({
325
+ node_id: nodeId,
326
+ label: "确认",
327
+ box,
328
+ scope_node_id: ancestorNodeId
329
+ });
330
+ }
331
+ if (candidates.length) return candidates;
332
+ }
333
+ return [];
334
+ }
335
+
336
+ async function waitForCurrentCityControl(client, frameNodeId, {
337
+ timeoutMs = 1800,
338
+ intervalMs = 150
339
+ } = {}) {
340
+ const started = Date.now();
341
+ while (true) {
342
+ const control = await findRecommendCurrentCityControl(client, frameNodeId);
343
+ if (control) return control;
344
+ if (Date.now() - started >= timeoutMs) break;
345
+ if (intervalMs > 0) await sleep(intervalMs);
346
+ }
347
+ return null;
348
+ }
349
+
350
+ async function findVisibleRecommendLocationPopover(client, frameNodeId) {
351
+ const nodeIds = uniqueNodeIds(await querySelectorAll(
352
+ client,
353
+ frameNodeId,
354
+ RECOMMEND_LOCATION_SELECTORS.popoverCandidates
355
+ ));
356
+ for (const nodeId of nodeIds) {
357
+ const box = await getVisibleBox(client, nodeId);
358
+ if (box) return { node_id: nodeId, box };
359
+ }
360
+ return null;
361
+ }
362
+
363
+ async function waitForRecommendLocationPopoverState(client, frameNodeId, {
364
+ timeoutMs = 1800,
365
+ intervalMs = 150
366
+ } = {}) {
367
+ const started = Date.now();
368
+ let popover = null;
369
+ while (true) {
370
+ const control = await findRecommendCurrentCityControl(client, frameNodeId);
371
+ if (control) return { opened: true, control, popover };
372
+ popover = await findVisibleRecommendLocationPopover(client, frameNodeId) || popover;
373
+ if (Date.now() - started >= timeoutMs) break;
374
+ if (intervalMs > 0) await sleep(intervalMs);
375
+ }
376
+ return {
377
+ opened: Boolean(popover),
378
+ control: null,
379
+ popover
380
+ };
381
+ }
382
+
383
+ async function openRecommendLocationPopover(client, frameNodeId, {
384
+ timeoutMs = 1800,
385
+ intervalMs = 150,
386
+ attemptsLimit = 3
387
+ } = {}) {
388
+ const attempts = [];
389
+ const alreadyOpenControl = await findRecommendCurrentCityControl(client, frameNodeId);
390
+ if (alreadyOpenControl) {
391
+ const trigger = await findRecommendLocationTrigger(client, frameNodeId);
392
+ return {
393
+ opened: true,
394
+ already_open: true,
395
+ trigger,
396
+ control: alreadyOpenControl,
397
+ attempts,
398
+ reason: "control_already_open"
399
+ };
400
+ }
401
+ const alreadyOpenPopover = await findVisibleRecommendLocationPopover(client, frameNodeId);
402
+ if (alreadyOpenPopover) {
403
+ const state = await waitForRecommendLocationPopoverState(client, frameNodeId, {
404
+ timeoutMs,
405
+ intervalMs
406
+ });
407
+ const trigger = await findRecommendLocationTrigger(client, frameNodeId);
408
+ return {
409
+ opened: true,
410
+ already_open: true,
411
+ trigger,
412
+ control: state.control,
413
+ popover: state.popover || alreadyOpenPopover,
414
+ attempts,
415
+ reason: state.control ? "control_already_open" : "popover_already_open_without_control"
416
+ };
417
+ }
418
+ for (let attempt = 1; attempt <= attemptsLimit; attempt += 1) {
419
+ const trigger = await findRecommendLocationTrigger(client, frameNodeId);
420
+ if (!trigger) {
421
+ return {
422
+ opened: false,
423
+ trigger: null,
424
+ control: null,
425
+ attempts,
426
+ reason: "location_trigger_unavailable"
427
+ };
428
+ }
429
+ try {
430
+ const click = await clickNodeCenter(client, trigger.node_id, DETERMINISTIC_CLICK_OPTIONS);
431
+ const state = await waitForRecommendLocationPopoverState(client, frameNodeId, {
432
+ timeoutMs,
433
+ intervalMs
434
+ });
435
+ attempts.push({
436
+ attempt,
437
+ trigger_node_id: trigger.node_id,
438
+ trigger_selector: trigger.selector,
439
+ clicked: true,
440
+ click_target: click.click_target,
441
+ popover_found: Boolean(state.popover),
442
+ control_found: Boolean(state.control)
443
+ });
444
+ if (state.opened) {
445
+ return {
446
+ opened: true,
447
+ trigger,
448
+ control: state.control,
449
+ popover: state.popover,
450
+ attempts,
451
+ reason: state.control ? "control_found" : "control_unavailable"
452
+ };
453
+ }
454
+ } catch (error) {
455
+ attempts.push({
456
+ attempt,
457
+ trigger_node_id: trigger.node_id,
458
+ trigger_selector: trigger.selector,
459
+ clicked: false,
460
+ error: error?.message || String(error)
461
+ });
462
+ if (typeof client?.Input?.dispatchKeyEvent === "function") {
463
+ await pressKey(client, "Escape", {
464
+ code: "Escape",
465
+ windowsVirtualKeyCode: 27,
466
+ nativeVirtualKeyCode: 27
467
+ });
468
+ attempts.at(-1).recovery = "Escape";
469
+ }
470
+ }
471
+ }
472
+ return {
473
+ opened: false,
474
+ trigger: null,
475
+ control: null,
476
+ attempts,
477
+ reason: "location_popover_did_not_open"
478
+ };
479
+ }
480
+
481
481
  async function confirmRecommendLocationPopover(client, frameNodeId, {
482
482
  timeoutMs = 1800,
483
483
  intervalMs = 150,
484
- controlNodeId
484
+ controlNodeId,
485
+ stableCloseMs = 300
485
486
  } = {}) {
486
- const candidates = await findExactLocationConfirmCandidates(client, frameNodeId, { controlNodeId });
487
- if (!candidates.length) {
488
- throw new Error("Recommend location exact 确认 button was not found");
489
- }
487
+ const candidates = await findExactLocationConfirmCandidates(client, frameNodeId, { controlNodeId });
488
+ if (!candidates.length) {
489
+ throw new Error("Recommend location exact 确认 button was not found");
490
+ }
490
491
  const clickErrors = [];
491
492
  for (const candidate of candidates) {
493
+ let box;
492
494
  try {
493
- const box = await clickNodeCenter(client, candidate.node_id, DETERMINISTIC_CLICK_OPTIONS);
494
- const started = Date.now();
495
- while (Date.now() - started <= timeoutMs) {
496
- const control = await findRecommendCurrentCityControl(client, frameNodeId);
497
- if (!control) {
498
- return {
499
- confirmed: true,
500
- label: "确认",
501
- node_id: candidate.node_id,
502
- box
503
- };
504
- }
505
- if (intervalMs > 0) await sleep(intervalMs);
506
- }
495
+ box = await clickNodeCenter(client, candidate.node_id, DETERMINISTIC_CLICK_OPTIONS);
507
496
  } catch (error) {
508
497
  clickErrors.push({
509
498
  node_id: candidate.node_id,
510
499
  message: error?.message || String(error)
511
500
  });
501
+ continue;
512
502
  }
513
- }
514
- const error = new Error("Recommend location popover did not close after exact 确认 click");
515
- error.click_errors = clickErrors;
516
- throw error;
517
- }
518
503
 
519
- async function clickCurrentCityControl(client, control) {
520
- const candidates = uniqueNodeIds([
521
- control.node_id,
522
- ...(control.state.input_node_ids || [])
523
- ]);
524
- const errors = [];
525
- for (const nodeId of candidates) {
526
- try {
527
- const box = await clickNodeCenter(client, nodeId, DETERMINISTIC_CLICK_OPTIONS);
528
- return { clicked: true, node_id: nodeId, box };
529
- } catch (error) {
530
- errors.push({ node_id: nodeId, message: error?.message || String(error) });
504
+ const started = Date.now();
505
+ const requiredStableCloseMs = Math.max(0, Number(stableCloseMs) || 0);
506
+ let absentSince = null;
507
+ let absentObservations = 0;
508
+ let lastObservation = null;
509
+ while (Date.now() - started <= timeoutMs) {
510
+ try {
511
+ const [control, popover] = await Promise.all([
512
+ findRecommendCurrentCityControl(client, frameNodeId),
513
+ findVisibleRecommendLocationPopover(client, frameNodeId)
514
+ ]);
515
+ const observedAt = Date.now();
516
+ lastObservation = {
517
+ control_visible: Boolean(control),
518
+ popover_visible: Boolean(popover),
519
+ observed_after_ms: observedAt - started
520
+ };
521
+ if (!control && !popover) {
522
+ absentSince ??= observedAt;
523
+ absentObservations += 1;
524
+ if (observedAt - absentSince >= requiredStableCloseMs) {
525
+ return {
526
+ confirmed: true,
527
+ label: "确认",
528
+ node_id: candidate.node_id,
529
+ box,
530
+ stable_close_ms: observedAt - absentSince,
531
+ stable_close_observations: absentObservations,
532
+ control_absent: true,
533
+ popover_invisible: true
534
+ };
535
+ }
536
+ } else {
537
+ absentSince = null;
538
+ absentObservations = 0;
539
+ }
540
+ } catch (error) {
541
+ const uncertain = new Error("Recommend location popover close state was uncertain after exact 确认 click");
542
+ uncertain.cause = error;
543
+ uncertain.confirm_node_id = candidate.node_id;
544
+ uncertain.last_observation = lastObservation;
545
+ throw uncertain;
546
+ }
547
+ if (intervalMs > 0) await sleep(intervalMs);
531
548
  }
532
- }
533
- const error = new Error("Recommend current-city checkbox could not be clicked");
534
- error.click_errors = errors;
535
- throw error;
536
- }
537
-
538
- function compactControlState(control) {
539
- if (!control) return null;
540
- return {
541
- checked: control.state.checked,
542
- state_source: control.state.source,
543
- node_id: control.node_id,
544
- state_node_id: control.state.state_node_id
545
- };
546
- }
547
-
548
- function isStaleLocationNodeError(error) {
549
- return /Could not find node|Could not compute box model|No node with given id|stale/i.test(
550
- String(error?.message || "")
551
- );
552
- }
553
-
554
- function unavailableResult({ requested, currentCityLabel, reason, attempts }) {
555
- return {
556
- requested,
557
- effective: false,
558
- available: false,
559
- unavailable: true,
560
- reason,
561
- clicked: false,
562
- current_city_label: currentCityLabel || null,
563
- before: null,
564
- after_toggle: null,
565
- confirmation: null,
566
- sticky_verification: {
567
- verified: true,
568
- expected: false,
569
- actual: null,
570
- unavailable: true,
571
- state_source: "control_unavailable",
572
- close_confirmation: null
573
- },
574
- attempts
575
- };
576
- }
577
549
 
550
+ const error = new Error("Recommend location popover did not close after exact 确认 click");
551
+ error.confirm_node_id = candidate.node_id;
552
+ error.last_observation = lastObservation;
553
+ error.stable_close_ms = absentSince === null ? 0 : Date.now() - absentSince;
554
+ error.stable_close_observations = absentObservations;
555
+ error.click_errors = clickErrors;
556
+ throw error;
557
+ }
558
+ const error = new Error("Recommend location popover did not close after exact 确认 click");
559
+ error.click_errors = clickErrors;
560
+ throw error;
561
+ }
562
+
563
+ async function clickCurrentCityControl(client, control) {
564
+ const candidates = uniqueNodeIds([
565
+ control.node_id,
566
+ ...(control.state.input_node_ids || [])
567
+ ]);
568
+ const errors = [];
569
+ for (const nodeId of candidates) {
570
+ try {
571
+ const box = await clickNodeCenter(client, nodeId, DETERMINISTIC_CLICK_OPTIONS);
572
+ return { clicked: true, node_id: nodeId, box };
573
+ } catch (error) {
574
+ errors.push({ node_id: nodeId, message: error?.message || String(error) });
575
+ }
576
+ }
577
+ const error = new Error("Recommend current-city checkbox could not be clicked");
578
+ error.click_errors = errors;
579
+ throw error;
580
+ }
581
+
582
+ function compactControlState(control) {
583
+ if (!control) return null;
584
+ return {
585
+ checked: control.state.checked,
586
+ state_source: control.state.source,
587
+ node_id: control.node_id,
588
+ state_node_id: control.state.state_node_id
589
+ };
590
+ }
591
+
592
+ function isStaleLocationNodeError(error) {
593
+ return /Could not find node|Could not compute box model|No node with given id|stale/i.test(
594
+ String(error?.message || "")
595
+ );
596
+ }
597
+
598
+ function unavailableResult({ requested, currentCityLabel, reason, attempts }) {
599
+ return {
600
+ requested,
601
+ effective: false,
602
+ available: false,
603
+ unavailable: true,
604
+ reason,
605
+ clicked: false,
606
+ current_city_label: currentCityLabel || null,
607
+ before: null,
608
+ after_toggle: null,
609
+ confirmation: null,
610
+ sticky_verification: {
611
+ verified: true,
612
+ expected: false,
613
+ actual: null,
614
+ unavailable: true,
615
+ state_source: "control_unavailable",
616
+ close_confirmation: null
617
+ },
618
+ attempts
619
+ };
620
+ }
621
+
578
622
  export async function ensureRecommendCurrentCityOnly(client, frameNodeId, {
579
623
  enabled = false,
580
624
  timeoutMs = 1800,
581
625
  intervalMs = 150,
582
626
  attemptsLimit = 2,
583
627
  openAttemptsLimit = 3,
584
- settleMs = 250
628
+ settleMs = 250,
629
+ closeStableMs = 300
585
630
  } = {}) {
586
- const requested = enabled === true;
587
- const attempts = [];
588
- for (let attempt = 1; attempt <= attemptsLimit; attempt += 1) {
589
- try {
590
- const opened = await openRecommendLocationPopover(client, frameNodeId, {
591
- timeoutMs,
592
- intervalMs,
593
- attemptsLimit: openAttemptsLimit
594
- });
595
- const currentCityLabel = opened.trigger?.current_city_label || null;
596
- attempts.push({
597
- attempt,
598
- opened: opened.opened,
599
- reason: opened.reason,
600
- open_attempts: opened.attempts
601
- });
602
- if (!opened.opened) {
603
- if (!requested && opened.reason === "location_trigger_unavailable") {
604
- return unavailableResult({
605
- requested,
606
- currentCityLabel,
607
- reason: "location_trigger_unavailable",
608
- attempts
609
- });
610
- }
611
- throw new Error(`Recommend location popover could not be opened: ${opened.reason}`);
612
- }
613
- if (!opened.control) {
614
- if (typeof client?.Input?.dispatchKeyEvent === "function") {
615
- await pressKey(client, "Escape", {
616
- code: "Escape",
617
- windowsVirtualKeyCode: 27,
618
- nativeVirtualKeyCode: 27
619
- });
620
- attempts.at(-1).unavailable_close = "Escape";
621
- }
622
- if (!requested) {
623
- return unavailableResult({
624
- requested,
625
- currentCityLabel,
626
- reason: "current_city_control_unavailable",
627
- attempts
628
- });
629
- }
630
- throw new Error("Recommend current-city checkbox is unavailable for an enabled request");
631
- }
632
- if (!opened.control.readable) {
633
- throw new Error("Recommend current-city checkbox is visible but its state is unreadable");
634
- }
635
-
636
- const before = compactControlState(opened.control);
637
- let clicked = false;
638
- let clickEvidence = null;
639
- let afterToggleControl = opened.control;
640
- if (opened.control.state.checked !== requested) {
641
- clickEvidence = await clickCurrentCityControl(client, opened.control);
642
- clicked = true;
643
- afterToggleControl = await waitForCurrentCityControl(client, frameNodeId, { timeoutMs, intervalMs });
644
- if (!afterToggleControl?.readable || afterToggleControl.state.checked !== requested) {
645
- throw new Error("Recommend current-city checkbox did not reach the requested state after click");
646
- }
647
- }
648
- const afterToggle = compactControlState(afterToggleControl);
631
+ const requested = enabled === true;
632
+ const attempts = [];
633
+ for (let attempt = 1; attempt <= attemptsLimit; attempt += 1) {
634
+ try {
635
+ const opened = await openRecommendLocationPopover(client, frameNodeId, {
636
+ timeoutMs,
637
+ intervalMs,
638
+ attemptsLimit: openAttemptsLimit
639
+ });
640
+ const currentCityLabel = opened.trigger?.current_city_label || null;
641
+ attempts.push({
642
+ attempt,
643
+ opened: opened.opened,
644
+ reason: opened.reason,
645
+ open_attempts: opened.attempts
646
+ });
647
+ if (!opened.opened) {
648
+ if (!requested && opened.reason === "location_trigger_unavailable") {
649
+ return unavailableResult({
650
+ requested,
651
+ currentCityLabel,
652
+ reason: "location_trigger_unavailable",
653
+ attempts
654
+ });
655
+ }
656
+ throw new Error(`Recommend location popover could not be opened: ${opened.reason}`);
657
+ }
658
+ if (!opened.control) {
659
+ if (typeof client?.Input?.dispatchKeyEvent === "function") {
660
+ await pressKey(client, "Escape", {
661
+ code: "Escape",
662
+ windowsVirtualKeyCode: 27,
663
+ nativeVirtualKeyCode: 27
664
+ });
665
+ attempts.at(-1).unavailable_close = "Escape";
666
+ }
667
+ if (!requested) {
668
+ return unavailableResult({
669
+ requested,
670
+ currentCityLabel,
671
+ reason: "current_city_control_unavailable",
672
+ attempts
673
+ });
674
+ }
675
+ throw new Error("Recommend current-city checkbox is unavailable for an enabled request");
676
+ }
677
+ if (!opened.control.readable) {
678
+ throw new Error("Recommend current-city checkbox is visible but its state is unreadable");
679
+ }
680
+
681
+ const before = compactControlState(opened.control);
682
+ let clicked = false;
683
+ let clickEvidence = null;
684
+ let afterToggleControl = opened.control;
685
+ if (opened.control.state.checked !== requested) {
686
+ clickEvidence = await clickCurrentCityControl(client, opened.control);
687
+ clicked = true;
688
+ afterToggleControl = await waitForCurrentCityControl(client, frameNodeId, { timeoutMs, intervalMs });
689
+ if (!afterToggleControl?.readable || afterToggleControl.state.checked !== requested) {
690
+ throw new Error("Recommend current-city checkbox did not reach the requested state after click");
691
+ }
692
+ }
693
+ const afterToggle = compactControlState(afterToggleControl);
649
694
  const confirmation = await confirmRecommendLocationPopover(client, frameNodeId, {
650
695
  timeoutMs,
651
696
  intervalMs,
652
- controlNodeId: afterToggleControl.node_id
653
- });
654
- if (!confirmation.confirmed) {
655
- throw new Error("Recommend location state was not confirmed");
656
- }
657
- if (settleMs > 0) await sleep(settleMs);
658
-
659
- const reopened = await openRecommendLocationPopover(client, frameNodeId, {
660
- timeoutMs,
661
- intervalMs,
662
- attemptsLimit: openAttemptsLimit
663
- });
664
- if (!reopened.opened || !reopened.control) {
665
- throw new Error("Recommend current-city checkbox was unavailable during sticky verification");
666
- }
667
- if (!reopened.control.readable) {
668
- throw new Error("Recommend current-city checkbox was visible but unreadable during sticky verification");
669
- }
670
- const actual = reopened.control.state.checked;
697
+ controlNodeId: afterToggleControl.node_id,
698
+ stableCloseMs: closeStableMs
699
+ });
700
+ if (!confirmation.confirmed) {
701
+ throw new Error("Recommend location state was not confirmed");
702
+ }
703
+ if (settleMs > 0) await sleep(settleMs);
704
+
705
+ const reopened = await openRecommendLocationPopover(client, frameNodeId, {
706
+ timeoutMs,
707
+ intervalMs,
708
+ attemptsLimit: openAttemptsLimit
709
+ });
710
+ if (!reopened.opened || !reopened.control) {
711
+ throw new Error("Recommend current-city checkbox was unavailable during sticky verification");
712
+ }
713
+ if (!reopened.control.readable) {
714
+ throw new Error("Recommend current-city checkbox was visible but unreadable during sticky verification");
715
+ }
716
+ const actual = reopened.control.state.checked;
671
717
  const stickyClose = await confirmRecommendLocationPopover(client, frameNodeId, {
672
718
  timeoutMs,
673
719
  intervalMs,
674
- controlNodeId: reopened.control.node_id
675
- });
676
- if (!stickyClose.confirmed) {
677
- throw new Error("Recommend location sticky verification was not confirmed");
678
- }
679
- const stickyVerification = {
680
- verified: actual === requested,
681
- expected: requested,
682
- actual,
683
- unavailable: false,
684
- state_source: reopened.control.state.source,
685
- close_confirmation: {
686
- confirmed: stickyClose.confirmed,
687
- label: stickyClose.label,
688
- node_id: stickyClose.node_id
689
- }
690
- };
691
- if (!stickyVerification.verified) {
692
- const error = new Error("Recommend current-city checkbox failed sticky verification");
693
- error.sticky_verification = stickyVerification;
694
- throw error;
695
- }
696
- attempts.at(-1).click = clickEvidence
697
- ? { clicked: true, node_id: clickEvidence.node_id }
698
- : { clicked: false, reason: "already_in_requested_state" };
699
- attempts.at(-1).verified = true;
700
- return {
701
- requested,
702
- effective: actual,
703
- available: true,
704
- unavailable: false,
705
- reason: clicked ? "state_updated" : "already_in_requested_state",
706
- clicked,
707
- current_city_label: currentCityLabel,
708
- before,
709
- after_toggle: afterToggle,
710
- confirmation: {
711
- confirmed: confirmation.confirmed,
712
- label: confirmation.label,
713
- node_id: confirmation.node_id
714
- },
715
- sticky_verification: stickyVerification,
716
- attempts
717
- };
718
- } catch (error) {
719
- attempts.push({
720
- attempt,
721
- error: error?.message || String(error),
722
- stale_node: isStaleLocationNodeError(error)
723
- });
724
- if (!isStaleLocationNodeError(error) || attempt >= attemptsLimit) throw error;
725
- if (typeof client?.Input?.dispatchKeyEvent === "function") {
726
- await pressKey(client, "Escape", {
727
- code: "Escape",
728
- windowsVirtualKeyCode: 27,
729
- nativeVirtualKeyCode: 27
730
- });
731
- }
732
- if (settleMs > 0) await sleep(settleMs);
733
- }
734
- }
735
- throw new Error("Recommend current-city checkbox state was not ensured");
736
- }
720
+ controlNodeId: reopened.control.node_id,
721
+ stableCloseMs: closeStableMs
722
+ });
723
+ if (!stickyClose.confirmed) {
724
+ throw new Error("Recommend location sticky verification was not confirmed");
725
+ }
726
+ const stickyVerification = {
727
+ verified: actual === requested,
728
+ expected: requested,
729
+ actual,
730
+ unavailable: false,
731
+ state_source: reopened.control.state.source,
732
+ close_confirmation: {
733
+ confirmed: stickyClose.confirmed,
734
+ label: stickyClose.label,
735
+ node_id: stickyClose.node_id
736
+ }
737
+ };
738
+ if (!stickyVerification.verified) {
739
+ const error = new Error("Recommend current-city checkbox failed sticky verification");
740
+ error.sticky_verification = stickyVerification;
741
+ throw error;
742
+ }
743
+ attempts.at(-1).click = clickEvidence
744
+ ? { clicked: true, node_id: clickEvidence.node_id }
745
+ : { clicked: false, reason: "already_in_requested_state" };
746
+ attempts.at(-1).verified = true;
747
+ return {
748
+ requested,
749
+ effective: actual,
750
+ available: true,
751
+ unavailable: false,
752
+ reason: clicked ? "state_updated" : "already_in_requested_state",
753
+ clicked,
754
+ current_city_label: currentCityLabel,
755
+ before,
756
+ after_toggle: afterToggle,
757
+ confirmation: {
758
+ confirmed: confirmation.confirmed,
759
+ label: confirmation.label,
760
+ node_id: confirmation.node_id
761
+ },
762
+ sticky_verification: stickyVerification,
763
+ attempts
764
+ };
765
+ } catch (error) {
766
+ attempts.push({
767
+ attempt,
768
+ error: error?.message || String(error),
769
+ stale_node: isStaleLocationNodeError(error)
770
+ });
771
+ if (!isStaleLocationNodeError(error) || attempt >= attemptsLimit) throw error;
772
+ if (typeof client?.Input?.dispatchKeyEvent === "function") {
773
+ await pressKey(client, "Escape", {
774
+ code: "Escape",
775
+ windowsVirtualKeyCode: 27,
776
+ nativeVirtualKeyCode: 27
777
+ });
778
+ }
779
+ if (settleMs > 0) await sleep(settleMs);
780
+ }
781
+ }
782
+ throw new Error("Recommend current-city checkbox state was not ensured");
783
+ }