@reconcrap/boss-recommend-mcp 2.1.21 → 2.1.23

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 -2
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/config/screening-config.example.json +33 -33
  4. package/package.json +2 -1
  5. package/scripts/install-macos.sh +280 -280
  6. package/scripts/postinstall.cjs +44 -44
  7. package/skills/boss-chat/README.md +42 -42
  8. package/skills/boss-chat/SKILL.md +106 -106
  9. package/skills/boss-recommend-pipeline/README.md +13 -13
  10. package/skills/boss-recommend-pipeline/SKILL.md +219 -214
  11. package/skills/boss-recruit-pipeline/README.md +19 -19
  12. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  13. package/src/chat-mcp.js +127 -127
  14. package/src/chat-runtime-config.js +775 -775
  15. package/src/cli.js +573 -573
  16. package/src/core/boss-cards/index.js +199 -199
  17. package/src/core/browser/index.js +2416 -2385
  18. package/src/core/capture/index.js +1201 -1201
  19. package/src/core/cv-acquisition/index.js +238 -238
  20. package/src/core/cv-capture-target/index.js +299 -299
  21. package/src/core/greet-quota/index.js +71 -71
  22. package/src/core/infinite-list/index.js +1315 -1306
  23. package/src/core/reporting/legacy-csv.js +334 -332
  24. package/src/core/run/index.js +44 -27
  25. package/src/core/run/timing.js +33 -33
  26. package/src/core/screening/index.js +2135 -2135
  27. package/src/core/self-heal/index.js +973 -973
  28. package/src/core/self-heal/viewport.js +564 -564
  29. package/src/detached-worker.js +99 -99
  30. package/src/domains/chat/cards.js +137 -137
  31. package/src/domains/chat/constants.js +9 -9
  32. package/src/domains/chat/detail.js +113 -113
  33. package/src/domains/chat/index.js +7 -7
  34. package/src/domains/chat/jobs.js +620 -620
  35. package/src/domains/chat/page-guard.js +122 -122
  36. package/src/domains/chat/roots.js +56 -56
  37. package/src/domains/chat/run-service.js +571 -571
  38. package/src/domains/common/account-rights-panel.js +314 -314
  39. package/src/domains/common/recovery-settle.js +159 -159
  40. package/src/domains/recommend/actions.js +472 -472
  41. package/src/domains/recommend/cards.js +243 -243
  42. package/src/domains/recommend/colleague-contact.js +333 -333
  43. package/src/domains/recommend/constants.js +228 -159
  44. package/src/domains/recommend/detail.js +655 -646
  45. package/src/domains/recommend/filters.js +748 -377
  46. package/src/domains/recommend/index.js +4 -3
  47. package/src/domains/recommend/jobs.js +542 -542
  48. package/src/domains/recommend/location.js +736 -0
  49. package/src/domains/recommend/refresh.js +575 -351
  50. package/src/domains/recommend/roots.js +80 -80
  51. package/src/domains/recommend/run-service.js +1616 -878
  52. package/src/domains/recommend/scopes.js +246 -246
  53. package/src/domains/recruit/actions.js +277 -277
  54. package/src/domains/recruit/cards.js +74 -74
  55. package/src/domains/recruit/constants.js +236 -236
  56. package/src/domains/recruit/detail.js +588 -588
  57. package/src/domains/recruit/index.js +9 -9
  58. package/src/domains/recruit/instruction-parser.js +866 -866
  59. package/src/domains/recruit/refresh.js +45 -45
  60. package/src/domains/recruit/roots.js +68 -68
  61. package/src/domains/recruit/run-service.js +1620 -1620
  62. package/src/domains/recruit/search.js +3229 -3229
  63. package/src/index.js +121 -4
  64. package/src/parser.js +376 -8
  65. package/src/recommend-mcp.js +1174 -914
  66. package/src/recommend-scheduler.js +535 -469
  67. package/src/recruit-mcp.js +2121 -2121
@@ -1,54 +1,83 @@
1
- import {
2
- clickNodeCenter,
3
- countSelectors,
4
- DETERMINISTIC_CLICK_OPTIONS,
5
- findFirstNode,
6
- getAttributesMap,
7
- getNodeBox,
8
- getOuterHTML,
9
- pressKey,
10
- querySelectorAll,
1
+ import {
2
+ clickNodeCenter,
3
+ countSelectors,
4
+ DETERMINISTIC_CLICK_OPTIONS,
5
+ findFirstNode,
6
+ getAttributesMap,
7
+ getNodeBox,
8
+ getOuterHTML,
9
+ pressKey,
10
+ querySelectorAll,
11
11
  sleep,
12
12
  waitForSelector
13
13
  } from "../../core/browser/index.js";
14
14
  import { htmlToText, normalizeText } from "../../core/screening/index.js";
15
15
  import {
16
+ RECOMMEND_ACTIVITY_GROUP,
17
+ RECOMMEND_ACTIVITY_GROUP_HEADING,
18
+ RECOMMEND_ACTIVITY_LEVELS,
16
19
  RECOMMEND_CARD_SELECTOR,
17
20
  RECOMMEND_FILTER_GROUP_ORDER,
18
21
  RECOMMEND_FILTER_SELECTORS
19
22
  } from "./constants.js";
20
23
 
21
- const SKIP_OPTION_LABELS = new Set(["不限", "全部", "all"]);
22
-
23
- export function normalizeFilterOptionLabel(label) {
24
- return normalizeText(label).replace(/\s+/g, "");
25
- }
26
-
24
+ const SKIP_OPTION_LABELS = new Set(["不限", "全部", "all"]);
25
+
26
+ export function normalizeFilterOptionLabel(label) {
27
+ return normalizeText(label).replace(/\s+/g, "");
28
+ }
29
+
27
30
  export function isSafeFilterOptionLabel(label) {
28
31
  const normalized = normalizeFilterOptionLabel(label);
29
32
  return Boolean(normalized) && !SKIP_OPTION_LABELS.has(normalized.toLowerCase());
30
33
  }
31
34
 
35
+ function isSelectableFilterOptionLabel(label, { allowUnlimited = false } = {}) {
36
+ const normalized = normalizeFilterOptionLabel(label);
37
+ if (!normalized) return false;
38
+ return allowUnlimited || !SKIP_OPTION_LABELS.has(normalized.toLowerCase());
39
+ }
40
+
41
+ function normalizeActivityGroupText(text) {
42
+ return normalizeFilterOptionLabel(text)
43
+ .replace(/【/g, "[")
44
+ .replace(/】/g, "]");
45
+ }
46
+
47
+ export function isRecommendActivityGroupText(text) {
48
+ const normalized = normalizeActivityGroupText(text);
49
+ const heading = normalizeActivityGroupText(RECOMMEND_ACTIVITY_GROUP_HEADING);
50
+ const exactOptionCount = RECOMMEND_ACTIVITY_LEVELS.filter((label) => (
51
+ normalized.includes(normalizeFilterOptionLabel(label))
52
+ )).length;
53
+ const hasUnlimited = normalized.includes(normalizeFilterOptionLabel("不限"));
54
+ return (
55
+ (normalized.startsWith(heading) && exactOptionCount >= 2 && hasUnlimited)
56
+ || (exactOptionCount === RECOMMEND_ACTIVITY_LEVELS.length && hasUnlimited)
57
+ );
58
+ }
59
+
32
60
  export function isActiveOption(attributes = {}, outerHTML = "") {
33
61
  const className = attributes.class || "";
34
62
  return /\bactive\b/.test(className) || /\bactive\b/.test(String(outerHTML || "").split(">")[0] || "");
35
63
  }
36
64
 
37
- export function chooseFirstSafeFilterOption(options = [], groupOrder = RECOMMEND_FILTER_GROUP_ORDER) {
38
- for (const group of groupOrder) {
39
- const option = options.find((item) => (
40
- item.group === group
41
- && !item.active
42
- && isSafeFilterOptionLabel(item.label)
43
- ));
44
- if (option) return option;
45
- }
65
+ export function chooseFirstSafeFilterOption(options = [], groupOrder = RECOMMEND_FILTER_GROUP_ORDER) {
66
+ for (const group of groupOrder) {
67
+ const option = options.find((item) => (
68
+ item.group === group
69
+ && !item.active
70
+ && isSafeFilterOptionLabel(item.label)
71
+ ));
72
+ if (option) return option;
73
+ }
46
74
  return null;
47
75
  }
48
76
 
49
77
  export function chooseFilterOptionByLabels(options = [], {
50
78
  group = "",
51
- labels = []
79
+ labels = [],
80
+ allowUnlimited = false
52
81
  } = {}) {
53
82
  const normalizedGroup = normalizeText(group);
54
83
  const normalizedLabels = labels.map(normalizeFilterOptionLabel).filter(Boolean);
@@ -57,7 +86,7 @@ export function chooseFilterOptionByLabels(options = [], {
57
86
  (!normalizedGroup || item.group === normalizedGroup)
58
87
  && !item.active
59
88
  && normalizeFilterOptionLabel(item.label) === label
60
- && isSafeFilterOptionLabel(item.label)
89
+ && isSelectableFilterOptionLabel(item.label, { allowUnlimited })
61
90
  ));
62
91
  if (option) return option;
63
92
  }
@@ -66,7 +95,8 @@ export function chooseFilterOptionByLabels(options = [], {
66
95
 
67
96
  export function chooseFilterOptionsByLabels(options = [], {
68
97
  group = "",
69
- labels = []
98
+ labels = [],
99
+ allowUnlimited = false
70
100
  } = {}) {
71
101
  const normalizedGroup = normalizeText(group);
72
102
  const normalizedLabels = labels.map(normalizeFilterOptionLabel).filter(Boolean);
@@ -74,164 +104,209 @@ export function chooseFilterOptionsByLabels(options = [], {
74
104
  const option = options.find((item) => (
75
105
  (!normalizedGroup || item.group === normalizedGroup)
76
106
  && normalizeFilterOptionLabel(item.label) === label
77
- && isSafeFilterOptionLabel(item.label)
107
+ && isSelectableFilterOptionLabel(item.label, { allowUnlimited })
78
108
  ));
79
109
  return {
80
110
  label,
81
111
  option: option || null
82
112
  };
83
- });
84
- }
85
-
86
- export async function getFilterPanelCount(client, frameNodeId) {
87
- return (await querySelectorAll(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel)).length;
113
+ });
114
+ }
115
+
116
+ export async function getFilterPanelCount(client, frameNodeId) {
117
+ return (await querySelectorAll(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel)).length;
118
+ }
119
+
120
+ export async function getRecommendFilterCounts(client, frameNodeId) {
121
+ return countSelectors(client, frameNodeId, {
122
+ filter_trigger: RECOMMEND_FILTER_SELECTORS.trigger,
123
+ filter_panel: RECOMMEND_FILTER_SELECTORS.panel,
124
+ check_box: RECOMMEND_FILTER_SELECTORS.checkBox,
125
+ option: `.filter-panel ${RECOMMEND_FILTER_SELECTORS.option}`,
126
+ active_option: RECOMMEND_FILTER_SELECTORS.activeOption,
127
+ recommend_card: RECOMMEND_CARD_SELECTOR
128
+ });
129
+ }
130
+
131
+ export async function findFilterTrigger(client, frameNodeId) {
132
+ return findFirstNode(client, frameNodeId, [
133
+ RECOMMEND_FILTER_SELECTORS.trigger,
134
+ ".recommend-filter.op-filter"
135
+ ]);
136
+ }
137
+
138
+ export async function ensureFilterPanelClosed(client, frameNodeId, triggerNodeId = 0) {
139
+ const attempts = [];
140
+ if (await getFilterPanelCount(client, frameNodeId) === 0) return attempts;
141
+
142
+ await pressKey(client, "Escape", {
143
+ code: "Escape",
144
+ windowsVirtualKeyCode: 27,
145
+ nativeVirtualKeyCode: 27
146
+ });
147
+ await sleep(400);
148
+ attempts.push("Escape");
149
+
150
+ if (await getFilterPanelCount(client, frameNodeId) > 0 && triggerNodeId) {
151
+ await clickNodeCenter(client, triggerNodeId, DETERMINISTIC_CLICK_OPTIONS);
152
+ await sleep(500);
153
+ attempts.push("filter-trigger-toggle");
154
+ }
155
+
156
+ return attempts;
157
+ }
158
+
159
+ async function dismissRecommendControlOverlays(client, settleMs = 250) {
160
+ if (typeof client?.Input?.dispatchKeyEvent !== "function") {
161
+ return ["Escape-unavailable"];
162
+ }
163
+ await pressKey(client, "Escape", {
164
+ code: "Escape",
165
+ windowsVirtualKeyCode: 27,
166
+ nativeVirtualKeyCode: 27
167
+ });
168
+ if (settleMs > 0) await sleep(settleMs);
169
+ return ["Escape"];
170
+ }
171
+
172
+ async function findFilterTriggerCandidates(client, frameNodeId) {
173
+ const candidates = [];
174
+ const seen = new Set();
175
+ for (const selector of [
176
+ RECOMMEND_FILTER_SELECTORS.trigger,
177
+ ".recommend-filter.op-filter"
178
+ ]) {
179
+ const candidate = await findFirstNode(client, frameNodeId, [selector]);
180
+ if (candidate && !seen.has(candidate.nodeId)) {
181
+ candidates.push(candidate);
182
+ seen.add(candidate.nodeId);
183
+ }
184
+ }
185
+ return candidates;
186
+ }
187
+
188
+ export async function openFilterPanel(client, frameNodeId) {
189
+ let triggerCandidates = await findFilterTriggerCandidates(client, frameNodeId);
190
+ if (!triggerCandidates.length) {
191
+ const error = new Error("Recommend filter trigger was not found");
192
+ error.code = "RECOMMEND_FILTER_TRIGGER_UNAVAILABLE";
193
+ throw error;
194
+ }
195
+
196
+ const preOpenDismissalAttempts = await dismissRecommendControlOverlays(client);
197
+ const existingPanelNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel, {
198
+ timeoutMs: 300,
199
+ intervalMs: 100
200
+ });
201
+ if (existingPanelNodeId) {
202
+ const triggerBox = await getNodeBox(client, triggerCandidates[0].nodeId);
203
+ return {
204
+ trigger: triggerCandidates[0],
205
+ trigger_box: triggerBox,
206
+ panel_node_id: existingPanelNodeId,
207
+ initial_close_attempts: preOpenDismissalAttempts,
208
+ already_open: true
209
+ };
210
+ }
211
+
212
+ const closeAttempts = await ensureFilterPanelClosed(client, frameNodeId, triggerCandidates[0].nodeId);
213
+
214
+ const attempts = [];
215
+ for (let round = 0; round < 3; round += 1) {
216
+ triggerCandidates = await findFilterTriggerCandidates(client, frameNodeId);
217
+ for (const trigger of triggerCandidates) {
218
+ const triggerBox = await getNodeBox(client, trigger.nodeId);
219
+ const clickBox = await clickNodeCenter(client, trigger.nodeId, DETERMINISTIC_CLICK_OPTIONS);
220
+ attempts.push({
221
+ selector: trigger.selector,
222
+ node_id: trigger.nodeId,
223
+ center: triggerBox.center,
224
+ click_target: clickBox.click_target,
225
+ click_result: clickBox.click_result
226
+ });
227
+ const panelNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel, {
228
+ timeoutMs: 2500,
229
+ intervalMs: 200
230
+ });
231
+ if (panelNodeId) {
232
+ return {
233
+ trigger,
234
+ trigger_box: triggerBox,
235
+ panel_node_id: panelNodeId,
236
+ initial_close_attempts: [
237
+ ...preOpenDismissalAttempts,
238
+ ...closeAttempts
239
+ ],
240
+ open_attempts: attempts
241
+ };
242
+ }
243
+ }
244
+ await sleep(500);
245
+ }
246
+
247
+ throw new Error(`Recommend filter panel did not open after ${attempts.length} trigger attempts`);
248
+ }
249
+
250
+ async function readOptionNode(client, group, nodeId) {
251
+ const [attributes, outerHTML] = await Promise.all([
252
+ getAttributesMap(client, nodeId),
253
+ getOuterHTML(client, nodeId)
254
+ ]);
255
+ const label = normalizeFilterOptionLabel(htmlToText(outerHTML));
256
+ return {
257
+ group,
258
+ node_id: nodeId,
259
+ label,
260
+ active: isActiveOption(attributes, outerHTML),
261
+ attributes: {
262
+ class: attributes.class || "",
263
+ value: attributes.value || "",
264
+ type: attributes.type || ""
265
+ }
266
+ };
88
267
  }
89
268
 
90
- export async function getRecommendFilterCounts(client, frameNodeId) {
91
- return countSelectors(client, frameNodeId, {
92
- filter_trigger: RECOMMEND_FILTER_SELECTORS.trigger,
93
- filter_panel: RECOMMEND_FILTER_SELECTORS.panel,
94
- check_box: RECOMMEND_FILTER_SELECTORS.checkBox,
95
- option: `.filter-panel ${RECOMMEND_FILTER_SELECTORS.option}`,
96
- active_option: RECOMMEND_FILTER_SELECTORS.activeOption,
97
- recommend_card: RECOMMEND_CARD_SELECTOR
98
- });
269
+ function uniqueNodeIds(nodeIds = []) {
270
+ return [...new Set(nodeIds.filter((nodeId) => Number(nodeId) > 0))];
99
271
  }
100
272
 
101
- export async function findFilterTrigger(client, frameNodeId) {
102
- return findFirstNode(client, frameNodeId, [
103
- RECOMMEND_FILTER_SELECTORS.trigger,
104
- ".recommend-filter.op-filter"
105
- ]);
273
+ async function nodeMatchesActivityGroup(client, nodeId) {
274
+ const outerHTML = await getOuterHTML(client, nodeId);
275
+ return isRecommendActivityGroupText(htmlToText(outerHTML));
106
276
  }
107
277
 
108
- export async function ensureFilterPanelClosed(client, frameNodeId, triggerNodeId = 0) {
109
- const attempts = [];
110
- if (await getFilterPanelCount(client, frameNodeId) === 0) return attempts;
111
-
112
- await pressKey(client, "Escape", {
113
- code: "Escape",
114
- windowsVirtualKeyCode: 27,
115
- nativeVirtualKeyCode: 27
116
- });
117
- await sleep(400);
118
- attempts.push("Escape");
119
-
120
- if (await getFilterPanelCount(client, frameNodeId) > 0 && triggerNodeId) {
121
- await clickNodeCenter(client, triggerNodeId, DETERMINISTIC_CLICK_OPTIONS);
122
- await sleep(500);
123
- attempts.push("filter-trigger-toggle");
278
+ export async function resolveRecommendFilterGroupNodeIds(client, frameNodeId, group) {
279
+ const groupSelector = RECOMMEND_FILTER_SELECTORS.groups[group];
280
+ if (!groupSelector) return [];
281
+ if (group !== RECOMMEND_ACTIVITY_GROUP) {
282
+ return querySelectorAll(client, frameNodeId, groupSelector);
124
283
  }
125
284
 
126
- return attempts;
127
- }
128
-
129
- async function dismissRecommendControlOverlays(client, settleMs = 250) {
130
- if (typeof client?.Input?.dispatchKeyEvent !== "function") {
131
- return ["Escape-unavailable"];
285
+ const selectorCandidates = uniqueNodeIds(await querySelectorAll(client, frameNodeId, groupSelector));
286
+ const semanticMatches = [];
287
+ for (const nodeId of selectorCandidates) {
288
+ if (await nodeMatchesActivityGroup(client, nodeId)) semanticMatches.push(nodeId);
132
289
  }
133
- await pressKey(client, "Escape", {
134
- code: "Escape",
135
- windowsVirtualKeyCode: 27,
136
- nativeVirtualKeyCode: 27
137
- });
138
- if (settleMs > 0) await sleep(settleMs);
139
- return ["Escape"];
140
- }
141
-
142
- async function findFilterTriggerCandidates(client, frameNodeId) {
143
- const candidates = [];
144
- const seen = new Set();
145
- for (const selector of [
146
- RECOMMEND_FILTER_SELECTORS.trigger,
147
- ".recommend-filter.op-filter"
148
- ]) {
149
- const candidate = await findFirstNode(client, frameNodeId, [selector]);
150
- if (candidate && !seen.has(candidate.nodeId)) {
151
- candidates.push(candidate);
152
- seen.add(candidate.nodeId);
290
+ if (semanticMatches.length) return uniqueNodeIds(semanticMatches);
291
+
292
+ const broadCandidates = uniqueNodeIds(await querySelectorAll(
293
+ client,
294
+ frameNodeId,
295
+ RECOMMEND_FILTER_SELECTORS.checkBox
296
+ ));
297
+ for (const nodeId of broadCandidates) {
298
+ if (selectorCandidates.includes(nodeId)) continue;
299
+ try {
300
+ if (await nodeMatchesActivityGroup(client, nodeId)) semanticMatches.push(nodeId);
301
+ } catch {
302
+ // An unrelated stale filter row does not make the activity control visible.
153
303
  }
154
304
  }
155
- return candidates;
156
- }
157
-
158
- export async function openFilterPanel(client, frameNodeId) {
159
- let triggerCandidates = await findFilterTriggerCandidates(client, frameNodeId);
160
- if (!triggerCandidates.length) {
161
- throw new Error("Recommend filter trigger was not found");
162
- }
163
-
164
- const preOpenDismissalAttempts = await dismissRecommendControlOverlays(client);
165
- const existingPanelNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel, {
166
- timeoutMs: 300,
167
- intervalMs: 100
168
- });
169
- if (existingPanelNodeId) {
170
- const triggerBox = await getNodeBox(client, triggerCandidates[0].nodeId);
171
- return {
172
- trigger: triggerCandidates[0],
173
- trigger_box: triggerBox,
174
- panel_node_id: existingPanelNodeId,
175
- initial_close_attempts: preOpenDismissalAttempts,
176
- already_open: true
177
- };
178
- }
179
-
180
- const closeAttempts = await ensureFilterPanelClosed(client, frameNodeId, triggerCandidates[0].nodeId);
181
-
182
- const attempts = [];
183
- for (let round = 0; round < 3; round += 1) {
184
- triggerCandidates = await findFilterTriggerCandidates(client, frameNodeId);
185
- for (const trigger of triggerCandidates) {
186
- const triggerBox = await getNodeBox(client, trigger.nodeId);
187
- const clickBox = await clickNodeCenter(client, trigger.nodeId, DETERMINISTIC_CLICK_OPTIONS);
188
- attempts.push({
189
- selector: trigger.selector,
190
- node_id: trigger.nodeId,
191
- center: triggerBox.center,
192
- click_target: clickBox.click_target,
193
- click_result: clickBox.click_result
194
- });
195
- const panelNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel, {
196
- timeoutMs: 2500,
197
- intervalMs: 200
198
- });
199
- if (panelNodeId) {
200
- return {
201
- trigger,
202
- trigger_box: triggerBox,
203
- panel_node_id: panelNodeId,
204
- initial_close_attempts: [
205
- ...preOpenDismissalAttempts,
206
- ...closeAttempts
207
- ],
208
- open_attempts: attempts
209
- };
210
- }
211
- }
212
- await sleep(500);
305
+ if (semanticMatches.length) return uniqueNodeIds(semanticMatches);
306
+ if (selectorCandidates.length) {
307
+ throw new Error("Recommend activity filter control was visible but its semantic row could not be verified");
213
308
  }
214
-
215
- throw new Error(`Recommend filter panel did not open after ${attempts.length} trigger attempts`);
216
- }
217
-
218
- async function readOptionNode(client, group, nodeId) {
219
- const [attributes, outerHTML] = await Promise.all([
220
- getAttributesMap(client, nodeId),
221
- getOuterHTML(client, nodeId)
222
- ]);
223
- const label = normalizeFilterOptionLabel(htmlToText(outerHTML));
224
- return {
225
- group,
226
- node_id: nodeId,
227
- label,
228
- active: isActiveOption(attributes, outerHTML),
229
- attributes: {
230
- class: attributes.class || "",
231
- value: attributes.value || "",
232
- type: attributes.type || ""
233
- }
234
- };
309
+ return [];
235
310
  }
236
311
 
237
312
  export async function listFilterOptions(client, frameNodeId, {
@@ -239,234 +314,337 @@ export async function listFilterOptions(client, frameNodeId, {
239
314
  } = {}) {
240
315
  const options = [];
241
316
  for (const group of groupOrder) {
242
- const groupSelector = RECOMMEND_FILTER_SELECTORS.groups[group];
243
- if (!groupSelector) continue;
244
- const groupNodeIds = await querySelectorAll(client, frameNodeId, groupSelector);
317
+ const groupNodeIds = await resolveRecommendFilterGroupNodeIds(client, frameNodeId, group);
318
+ let optionCount = 0;
245
319
  for (const groupNodeId of groupNodeIds) {
246
320
  const optionNodeIds = await querySelectorAll(client, groupNodeId, RECOMMEND_FILTER_SELECTORS.option);
247
321
  for (const optionNodeId of optionNodeIds) {
248
322
  options.push(await readOptionNode(client, group, optionNodeId));
323
+ optionCount += 1;
249
324
  }
250
325
  }
326
+ if (group === RECOMMEND_ACTIVITY_GROUP && groupNodeIds.length && optionCount === 0) {
327
+ throw new Error("Recommend activity filter control was visible but its options could not be read");
328
+ }
251
329
  }
252
330
  return options;
253
331
  }
254
332
 
255
333
  async function clickFirstAvailableNode(client, nodeIds) {
256
- const errors = [];
257
- for (const nodeId of nodeIds) {
258
- try {
259
- const box = await clickNodeCenter(client, nodeId, DETERMINISTIC_CLICK_OPTIONS);
260
- return {
261
- clicked: true,
262
- node_id: nodeId,
263
- box
264
- };
265
- } catch (error) {
266
- errors.push({
267
- node_id: nodeId,
268
- message: error?.message || String(error)
269
- });
270
- }
271
- }
272
- return {
273
- clicked: false,
274
- errors
275
- };
276
- }
277
-
278
- function normalizeButtonLabel(label) {
279
- return normalizeFilterOptionLabel(label).toLowerCase();
280
- }
281
-
282
- function buttonRank(candidate) {
283
- const label = normalizeButtonLabel(candidate.label);
284
- if (/确定|确认|完成|ok|confirm/.test(label)) return 0;
285
- if (/重置|清空|取消|reset|cancel/.test(label)) return 2;
286
- return 1;
287
- }
288
-
289
- async function readButtonCandidate(client, nodeId, index) {
290
- const [attributes, outerHTML] = await Promise.all([
291
- getAttributesMap(client, nodeId),
292
- getOuterHTML(client, nodeId)
293
- ]);
294
- return {
295
- node_id: nodeId,
296
- index,
297
- label: normalizeButtonLabel(htmlToText(outerHTML)),
298
- class_name: attributes.class || ""
299
- };
300
- }
301
-
302
- async function readConfirmButtonCandidates(client, frameNodeId) {
303
- const nodeIds = await querySelectorAll(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.confirmButton);
304
- const candidates = [];
305
- for (let index = 0; index < nodeIds.length; index += 1) {
306
- candidates.push(await readButtonCandidate(client, nodeIds[index], index));
307
- }
308
- return candidates.sort((left, right) => {
309
- const rankDiff = buttonRank(left) - buttonRank(right);
310
- if (rankDiff !== 0) return rankDiff;
311
- return right.index - left.index;
312
- });
313
- }
314
-
315
- export async function selectFirstSafeFilterOption(client, frameNodeId, {
316
- groupOrder = RECOMMEND_FILTER_GROUP_ORDER
317
- } = {}) {
318
- const options = await listFilterOptions(client, frameNodeId, { groupOrder });
319
- const selected = chooseFirstSafeFilterOption(options, groupOrder);
320
- if (!selected) {
321
- throw new Error("No safe non-active recommend filter option was found");
322
- }
323
-
324
- const box = await clickNodeCenter(client, selected.node_id, {
325
- ...DETERMINISTIC_CLICK_OPTIONS,
326
- scrollIntoView: true
327
- });
328
- await sleep(300);
329
-
330
- return {
331
- selected_option: {
332
- group: selected.group,
333
- label: selected.label,
334
- node_id: selected.node_id,
335
- was_active: selected.active
336
- },
337
- option_box: box,
338
- discovered_options: options.map((option) => ({
339
- group: option.group,
340
- label: option.label,
341
- active: option.active,
342
- node_id: option.node_id
343
- }))
344
- };
334
+ const errors = [];
335
+ for (const nodeId of nodeIds) {
336
+ try {
337
+ const box = await clickNodeCenter(client, nodeId, DETERMINISTIC_CLICK_OPTIONS);
338
+ return {
339
+ clicked: true,
340
+ node_id: nodeId,
341
+ box
342
+ };
343
+ } catch (error) {
344
+ errors.push({
345
+ node_id: nodeId,
346
+ message: error?.message || String(error)
347
+ });
348
+ }
349
+ }
350
+ return {
351
+ clicked: false,
352
+ errors
353
+ };
354
+ }
355
+
356
+ function normalizeButtonLabel(label) {
357
+ return normalizeFilterOptionLabel(label).toLowerCase();
358
+ }
359
+
360
+ function buttonRank(candidate) {
361
+ const label = normalizeButtonLabel(candidate.label);
362
+ if (/确定|确认|完成|ok|confirm/.test(label)) return 0;
363
+ if (/重置|清空|取消|reset|cancel/.test(label)) return 2;
364
+ return 1;
365
+ }
366
+
367
+ async function readButtonCandidate(client, nodeId, index) {
368
+ const [attributes, outerHTML] = await Promise.all([
369
+ getAttributesMap(client, nodeId),
370
+ getOuterHTML(client, nodeId)
371
+ ]);
372
+ return {
373
+ node_id: nodeId,
374
+ index,
375
+ label: normalizeButtonLabel(htmlToText(outerHTML)),
376
+ class_name: attributes.class || ""
377
+ };
378
+ }
379
+
380
+ async function readConfirmButtonCandidates(client, frameNodeId) {
381
+ const nodeIds = await querySelectorAll(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.confirmButton);
382
+ const candidates = [];
383
+ for (let index = 0; index < nodeIds.length; index += 1) {
384
+ candidates.push(await readButtonCandidate(client, nodeIds[index], index));
385
+ }
386
+ return candidates.sort((left, right) => {
387
+ const rankDiff = buttonRank(left) - buttonRank(right);
388
+ if (rankDiff !== 0) return rankDiff;
389
+ return right.index - left.index;
390
+ });
391
+ }
392
+
393
+ export async function selectFirstSafeFilterOption(client, frameNodeId, {
394
+ groupOrder = RECOMMEND_FILTER_GROUP_ORDER
395
+ } = {}) {
396
+ const options = await listFilterOptions(client, frameNodeId, { groupOrder });
397
+ const selected = chooseFirstSafeFilterOption(options, groupOrder);
398
+ if (!selected) {
399
+ throw new Error("No safe non-active recommend filter option was found");
400
+ }
401
+
402
+ const box = await clickNodeCenter(client, selected.node_id, {
403
+ ...DETERMINISTIC_CLICK_OPTIONS,
404
+ scrollIntoView: true
405
+ });
406
+ await sleep(300);
407
+
408
+ return {
409
+ selected_option: {
410
+ group: selected.group,
411
+ label: selected.label,
412
+ node_id: selected.node_id,
413
+ was_active: selected.active
414
+ },
415
+ option_box: box,
416
+ discovered_options: options.map((option) => ({
417
+ group: option.group,
418
+ label: option.label,
419
+ active: option.active,
420
+ node_id: option.node_id
421
+ }))
422
+ };
345
423
  }
346
424
 
347
425
  export async function selectFilterOption(client, frameNodeId, {
348
426
  group = "",
349
427
  labels = [],
350
- groupOrder = RECOMMEND_FILTER_GROUP_ORDER
428
+ groupOrder = RECOMMEND_FILTER_GROUP_ORDER,
429
+ allowUnlimited = false
351
430
  } = {}) {
352
431
  const options = await listFilterOptions(client, frameNodeId, { groupOrder });
432
+ const alreadyActive = labels.length
433
+ ? options.find((option) => (
434
+ (!group || option.group === group)
435
+ && labels.some((label) => (
436
+ normalizeFilterOptionLabel(option.label) === normalizeFilterOptionLabel(label)
437
+ ))
438
+ && option.active
439
+ && isSelectableFilterOptionLabel(option.label, { allowUnlimited })
440
+ ))
441
+ : null;
442
+ if (alreadyActive) {
443
+ return {
444
+ selected_option: {
445
+ group: alreadyActive.group,
446
+ label: alreadyActive.label,
447
+ node_id: alreadyActive.node_id,
448
+ was_active: true,
449
+ clicked: false,
450
+ requested_group: group || null,
451
+ requested_labels: labels
452
+ },
453
+ selected_options: [],
454
+ option_box: null,
455
+ unavailable: false,
456
+ unavailable_groups: [],
457
+ discovered_options: options.map((option) => ({
458
+ group: option.group,
459
+ label: option.label,
460
+ active: option.active,
461
+ node_id: option.node_id
462
+ }))
463
+ };
464
+ }
353
465
  const selected = labels.length
354
- ? chooseFilterOptionByLabels(options, { group, labels })
466
+ ? chooseFilterOptionByLabels(options, { group, labels, allowUnlimited })
355
467
  : chooseFirstSafeFilterOption(options, groupOrder);
356
468
 
357
469
  if (!selected) {
470
+ const requestedLabels = labels.map(normalizeFilterOptionLabel).filter(Boolean);
471
+ const groupOptions = options.filter((option) => !group || option.group === group);
472
+ const unavailableDefault = (
473
+ group === RECOMMEND_ACTIVITY_GROUP
474
+ && allowUnlimited
475
+ && requestedLabels.length === 1
476
+ && requestedLabels[0] === normalizeFilterOptionLabel("不限")
477
+ && groupOptions.length === 0
478
+ );
479
+ if (unavailableDefault) {
480
+ return {
481
+ selected_option: null,
482
+ selected_options: [],
483
+ option_box: null,
484
+ unavailable: true,
485
+ unavailable_groups: [{
486
+ group,
487
+ requested_labels: labels,
488
+ reason: "activity_control_unavailable_default"
489
+ }],
490
+ discovered_options: options.map((option) => ({
491
+ group: option.group,
492
+ label: option.label,
493
+ active: option.active,
494
+ node_id: option.node_id
495
+ }))
496
+ };
497
+ }
358
498
  const target = labels.length
359
499
  ? `${group || "any group"} / ${labels.join(", ")}`
360
500
  : "first safe non-active option";
361
501
  throw new Error(`No matching recommend filter option was found for ${target}`);
362
502
  }
363
-
364
- const box = await clickNodeCenter(client, selected.node_id, {
365
- ...DETERMINISTIC_CLICK_OPTIONS,
366
- scrollIntoView: true
367
- });
368
- await sleep(300);
369
-
370
- return {
503
+
504
+ const box = await clickNodeCenter(client, selected.node_id, {
505
+ ...DETERMINISTIC_CLICK_OPTIONS,
506
+ scrollIntoView: true
507
+ });
508
+ await sleep(300);
509
+
510
+ return {
371
511
  selected_option: {
372
512
  group: selected.group,
373
513
  label: selected.label,
374
514
  node_id: selected.node_id,
375
515
  was_active: selected.active,
516
+ clicked: true,
376
517
  requested_group: group || null,
377
518
  requested_labels: labels
378
519
  },
379
520
  option_box: box,
521
+ unavailable: false,
522
+ unavailable_groups: [],
380
523
  discovered_options: options.map((option) => ({
381
524
  group: option.group,
382
525
  label: option.label,
383
526
  active: option.active,
384
527
  node_id: option.node_id
385
- }))
386
- };
528
+ }))
529
+ };
387
530
  }
388
531
 
389
532
  export async function selectFilterOptions(client, frameNodeId, {
390
533
  group = "",
391
534
  labels = [],
392
- groupOrder = RECOMMEND_FILTER_GROUP_ORDER
535
+ groupOrder = RECOMMEND_FILTER_GROUP_ORDER,
536
+ allowUnlimited = false
393
537
  } = {}) {
394
538
  if (!labels.length) {
395
- return selectFilterOption(client, frameNodeId, { group, labels, groupOrder });
539
+ return selectFilterOption(client, frameNodeId, {
540
+ group,
541
+ labels,
542
+ groupOrder,
543
+ allowUnlimited
544
+ });
396
545
  }
397
546
 
398
547
  const selectedOptions = [];
399
548
  const missingLabels = [];
400
549
  let discoveredOptions = [];
401
-
402
- for (const label of labels) {
403
- if (await getFilterPanelCount(client, frameNodeId) === 0) {
404
- await openFilterPanel(client, frameNodeId);
405
- }
406
-
407
- const options = await listFilterOptions(client, frameNodeId, { groupOrder });
408
- discoveredOptions = options.map((option) => ({
550
+
551
+ for (const label of labels) {
552
+ if (await getFilterPanelCount(client, frameNodeId) === 0) {
553
+ await openFilterPanel(client, frameNodeId);
554
+ }
555
+
556
+ const options = await listFilterOptions(client, frameNodeId, { groupOrder });
557
+ discoveredOptions = options.map((option) => ({
409
558
  group: option.group,
410
559
  label: option.label,
411
560
  active: option.active,
412
561
  node_id: option.node_id
413
562
  }));
414
- const selected = chooseFilterOptionByLabels(options, { group, labels: [label] });
563
+ const selected = chooseFilterOptionByLabels(options, {
564
+ group,
565
+ labels: [label],
566
+ allowUnlimited
567
+ });
415
568
  const alreadyActive = options.find((option) => (
416
569
  (!group || option.group === group)
417
570
  && normalizeFilterOptionLabel(option.label) === normalizeFilterOptionLabel(label)
418
571
  && option.active
572
+ && isSelectableFilterOptionLabel(option.label, { allowUnlimited })
419
573
  ));
420
574
 
421
575
  if (alreadyActive) {
422
576
  selectedOptions.push({
423
577
  group: alreadyActive.group,
424
- label: alreadyActive.label,
425
- node_id: alreadyActive.node_id,
426
- was_active: true,
427
- clicked: false,
428
- requested_group: group || null
429
- });
430
- continue;
431
- }
432
-
433
- if (!selected) {
434
- missingLabels.push(label);
435
- continue;
436
- }
437
-
438
- const box = await clickNodeCenter(client, selected.node_id, {
439
- ...DETERMINISTIC_CLICK_OPTIONS,
440
- scrollIntoView: true
441
- });
442
- selectedOptions.push({
443
- group: selected.group,
444
- label: selected.label,
445
- node_id: selected.node_id,
446
- was_active: false,
447
- clicked: true,
448
- requested_group: group || null,
449
- option_box: box
578
+ label: alreadyActive.label,
579
+ node_id: alreadyActive.node_id,
580
+ was_active: true,
581
+ clicked: false,
582
+ requested_group: group || null
583
+ });
584
+ continue;
585
+ }
586
+
587
+ if (!selected) {
588
+ missingLabels.push(label);
589
+ continue;
590
+ }
591
+
592
+ const box = await clickNodeCenter(client, selected.node_id, {
593
+ ...DETERMINISTIC_CLICK_OPTIONS,
594
+ scrollIntoView: true
595
+ });
596
+ selectedOptions.push({
597
+ group: selected.group,
598
+ label: selected.label,
599
+ node_id: selected.node_id,
600
+ was_active: false,
601
+ clicked: true,
602
+ requested_group: group || null,
603
+ option_box: box
450
604
  });
451
605
  await sleep(450);
452
606
  }
453
607
 
454
608
  if (missingLabels.length) {
609
+ const groupOptions = discoveredOptions.filter((option) => !group || option.group === group);
610
+ const unavailableDefault = (
611
+ group === RECOMMEND_ACTIVITY_GROUP
612
+ && allowUnlimited
613
+ && missingLabels.length === 1
614
+ && normalizeFilterOptionLabel(missingLabels[0]) === normalizeFilterOptionLabel("不限")
615
+ && groupOptions.length === 0
616
+ );
617
+ if (unavailableDefault) {
618
+ return {
619
+ selected_option: null,
620
+ selected_options: [],
621
+ option_box: null,
622
+ unavailable: true,
623
+ unavailable_groups: [{
624
+ group,
625
+ requested_labels: labels,
626
+ reason: "activity_control_unavailable_default"
627
+ }],
628
+ discovered_options: discoveredOptions
629
+ };
630
+ }
455
631
  throw new Error(`No matching recommend filter options were found for ${group || "any group"} / ${missingLabels.join(", ")}`);
456
632
  }
457
633
 
458
634
  return {
459
635
  selected_option: selectedOptions[0] || null,
460
- selected_options: selectedOptions.map((option) => ({
461
- group: option.group,
462
- label: option.label,
463
- node_id: option.node_id,
464
- was_active: option.was_active,
636
+ selected_options: selectedOptions.map((option) => ({
637
+ group: option.group,
638
+ label: option.label,
639
+ node_id: option.node_id,
640
+ was_active: option.was_active,
465
641
  clicked: option.clicked,
466
642
  requested_group: option.requested_group,
467
643
  requested_labels: labels
468
644
  })),
469
645
  option_box: selectedOptions.find((option) => option.option_box)?.option_box || null,
646
+ unavailable: false,
647
+ unavailable_groups: [],
470
648
  discovered_options: discoveredOptions
471
649
  };
472
650
  }
@@ -477,23 +655,26 @@ export async function selectFilterGroups(client, frameNodeId, {
477
655
  } = {}) {
478
656
  const selectedOptions = [];
479
657
  const discoveredOptions = [];
658
+ const unavailableGroups = [];
480
659
  const groups = filterGroups.filter((item) => item && (item.group || item.labels?.length));
481
660
  if (!groups.length) {
482
661
  return selectFilterOption(client, frameNodeId, { groupOrder });
483
662
  }
484
663
 
485
- for (const spec of groups) {
664
+ for (const spec of groups) {
486
665
  const labels = Array.isArray(spec.labels) ? spec.labels : [];
487
666
  const selection = spec.selectAllLabels === false
488
667
  ? await selectFilterOption(client, frameNodeId, {
489
668
  group: spec.group || "",
490
669
  labels,
491
- groupOrder
670
+ groupOrder,
671
+ allowUnlimited: spec.allowUnlimited === true
492
672
  })
493
673
  : await selectFilterOptions(client, frameNodeId, {
494
674
  group: spec.group || "",
495
675
  labels,
496
- groupOrder
676
+ groupOrder,
677
+ allowUnlimited: spec.allowUnlimited === true
497
678
  });
498
679
  if (selection.selected_option) selectedOptions.push(selection.selected_option);
499
680
  for (const option of selection.selected_options || []) {
@@ -502,109 +683,299 @@ export async function selectFilterGroups(client, frameNodeId, {
502
683
  for (const option of selection.discovered_options || []) {
503
684
  discoveredOptions.push(option);
504
685
  }
686
+ for (const unavailable of selection.unavailable_groups || []) {
687
+ unavailableGroups.push(unavailable);
688
+ }
505
689
  }
506
690
 
507
691
  const dedupedSelected = [];
508
692
  const seenSelected = new Set();
509
693
  for (const option of selectedOptions) {
510
- const key = `${option.group || ""}:${normalizeFilterOptionLabel(option.label || "")}`;
511
- if (seenSelected.has(key)) continue;
512
- seenSelected.add(key);
513
- dedupedSelected.push(option);
514
- }
694
+ const key = `${option.group || ""}:${normalizeFilterOptionLabel(option.label || "")}`;
695
+ if (seenSelected.has(key)) continue;
696
+ seenSelected.add(key);
697
+ dedupedSelected.push(option);
698
+ }
515
699
 
516
700
  return {
517
701
  selected_option: dedupedSelected[0] || null,
518
702
  selected_options: dedupedSelected,
519
703
  option_box: dedupedSelected.find((option) => option.option_box)?.option_box || null,
704
+ unavailable: unavailableGroups.length > 0,
705
+ unavailable_groups: unavailableGroups,
520
706
  discovered_options: discoveredOptions
521
707
  };
522
708
  }
523
709
 
524
710
  export async function confirmFilterPanel(client, frameNodeId, {
525
- timeoutMs = 8000
711
+ timeoutMs = 8000
712
+ } = {}) {
713
+ const candidates = await readConfirmButtonCandidates(client, frameNodeId);
714
+ if (!candidates.length && await getFilterPanelCount(client, frameNodeId) === 0) {
715
+ return {
716
+ confirmed: true,
717
+ confirm_node_id: null,
718
+ confirm_label: "auto-closed",
719
+ confirm_candidates: [],
720
+ confirm_attempts: [],
721
+ panel_count: 0
722
+ };
723
+ }
724
+ if (!candidates.length) {
725
+ throw new Error("Recommend filter confirm button was not found");
726
+ }
727
+
728
+ const attempts = [];
729
+ for (const candidate of candidates) {
730
+ const clickResult = await clickFirstAvailableNode(client, [candidate.node_id]);
731
+ attempts.push({
732
+ node_id: candidate.node_id,
733
+ label: candidate.label,
734
+ clicked: clickResult.clicked,
735
+ errors: clickResult.errors
736
+ });
737
+ if (!clickResult.clicked) continue;
738
+
739
+ const started = Date.now();
740
+ while (Date.now() - started <= timeoutMs) {
741
+ const panelCount = await getFilterPanelCount(client, frameNodeId);
742
+ if (panelCount === 0) {
743
+ return {
744
+ confirmed: true,
745
+ confirm_node_id: clickResult.node_id,
746
+ confirm_label: candidate.label,
747
+ confirm_box: clickResult.box,
748
+ confirm_candidates: candidates,
749
+ confirm_attempts: attempts,
750
+ panel_count: 0
751
+ };
752
+ }
753
+ await sleep(250);
754
+ }
755
+ }
756
+
757
+ return {
758
+ confirmed: false,
759
+ confirm_node_id: attempts.at(-1)?.node_id || null,
760
+ confirm_label: attempts.at(-1)?.label || null,
761
+ confirm_candidates: candidates,
762
+ confirm_attempts: attempts,
763
+ panel_count: await getFilterPanelCount(client, frameNodeId)
764
+ };
765
+ }
766
+
767
+ function normalizeStickyVerificationSpecs(options = {}) {
768
+ const source = Array.isArray(options.filterGroups) && options.filterGroups.length
769
+ ? options.filterGroups
770
+ : [options];
771
+ return source.filter((spec) => spec?.verifySticky === true).map((spec) => ({
772
+ group: String(spec.group || ""),
773
+ labels: Array.isArray(spec.labels) ? spec.labels : [],
774
+ selectAllLabels: spec.selectAllLabels !== false,
775
+ allowUnlimited: spec.allowUnlimited === true
776
+ })).filter((spec) => spec.group && spec.labels.length);
777
+ }
778
+
779
+ function findUnavailableGroup(unavailableGroups = [], group = "") {
780
+ return unavailableGroups.find((item) => item?.group === group) || null;
781
+ }
782
+
783
+ function normalizeRequestedFilterGroups(options = {}) {
784
+ const source = Array.isArray(options.filterGroups) && options.filterGroups.length
785
+ ? options.filterGroups.filter(Boolean)
786
+ : (options.group || options.labels?.length ? [options] : []);
787
+ return source.map((spec) => ({
788
+ group: String(spec?.group || ""),
789
+ labels: Array.isArray(spec?.labels)
790
+ ? spec.labels.map(normalizeFilterOptionLabel).filter(Boolean)
791
+ : [],
792
+ select_all_labels: spec?.selectAllLabels !== false,
793
+ allow_unlimited: spec?.allowUnlimited === true,
794
+ verify_sticky: spec?.verifySticky === true
795
+ }));
796
+ }
797
+
798
+ export async function verifyFilterGroupsSticky(client, frameNodeId, {
799
+ specs = [],
800
+ unavailableGroups = [],
801
+ settleMs = 300
526
802
  } = {}) {
527
- const candidates = await readConfirmButtonCandidates(client, frameNodeId);
528
- if (!candidates.length && await getFilterPanelCount(client, frameNodeId) === 0) {
529
- return {
530
- confirmed: true,
531
- confirm_node_id: null,
532
- confirm_label: "auto-closed",
533
- confirm_candidates: [],
534
- confirm_attempts: [],
535
- panel_count: 0
536
- };
537
- }
538
- if (!candidates.length) {
539
- throw new Error("Recommend filter confirm button was not found");
803
+ const normalizedSpecs = specs.filter((spec) => spec?.group && Array.isArray(spec.labels) && spec.labels.length);
804
+ const groups = [];
805
+ const availableSpecs = [];
806
+ for (const spec of normalizedSpecs) {
807
+ const unavailable = findUnavailableGroup(unavailableGroups, spec.group);
808
+ if (unavailable) {
809
+ groups.push({
810
+ group: spec.group,
811
+ requested_labels: spec.labels,
812
+ active_labels: [],
813
+ verified: true,
814
+ unavailable: true,
815
+ reason: unavailable.reason || "control_unavailable_default"
816
+ });
817
+ } else {
818
+ availableSpecs.push(spec);
819
+ }
540
820
  }
541
821
 
542
- const attempts = [];
543
- for (const candidate of candidates) {
544
- const clickResult = await clickFirstAvailableNode(client, [candidate.node_id]);
545
- attempts.push({
546
- node_id: candidate.node_id,
547
- label: candidate.label,
548
- clicked: clickResult.clicked,
549
- errors: clickResult.errors
550
- });
551
- if (!clickResult.clicked) continue;
552
-
553
- const started = Date.now();
554
- while (Date.now() - started <= timeoutMs) {
555
- const panelCount = await getFilterPanelCount(client, frameNodeId);
556
- if (panelCount === 0) {
557
- return {
558
- confirmed: true,
559
- confirm_node_id: clickResult.node_id,
560
- confirm_label: candidate.label,
561
- confirm_box: clickResult.box,
562
- confirm_candidates: candidates,
563
- confirm_attempts: attempts,
564
- panel_count: 0
565
- };
566
- }
567
- await sleep(250);
822
+ let reopen = null;
823
+ let closeConfirmation = null;
824
+ if (availableSpecs.length) {
825
+ reopen = await openFilterPanel(client, frameNodeId);
826
+ for (const spec of availableSpecs) {
827
+ const options = await listFilterOptions(client, frameNodeId, {
828
+ groupOrder: [spec.group]
829
+ });
830
+ const requestedLabels = spec.labels.map(normalizeFilterOptionLabel).filter(Boolean);
831
+ const activeLabels = options
832
+ .filter((option) => option.group === spec.group && option.active)
833
+ .map((option) => normalizeFilterOptionLabel(option.label));
834
+ const verified = spec.selectAllLabels === false
835
+ ? requestedLabels.length === 1
836
+ && activeLabels.length === 1
837
+ && activeLabels[0] === requestedLabels[0]
838
+ : requestedLabels.every((label) => activeLabels.includes(label));
839
+ groups.push({
840
+ group: spec.group,
841
+ requested_labels: requestedLabels,
842
+ active_labels: activeLabels,
843
+ verified,
844
+ unavailable: false,
845
+ single_select: spec.selectAllLabels === false,
846
+ discovered_options: options.map((option) => ({
847
+ label: option.label,
848
+ active: option.active,
849
+ node_id: option.node_id
850
+ }))
851
+ });
568
852
  }
853
+ closeConfirmation = await confirmFilterPanel(client, frameNodeId);
854
+ if (!closeConfirmation.confirmed) {
855
+ throw new Error("Recommend filter sticky verification panel could not be closed");
856
+ }
857
+ if (settleMs > 0) await sleep(settleMs);
569
858
  }
570
859
 
571
- return {
572
- confirmed: false,
573
- confirm_node_id: attempts.at(-1)?.node_id || null,
574
- confirm_label: attempts.at(-1)?.label || null,
575
- confirm_candidates: candidates,
576
- confirm_attempts: attempts,
577
- panel_count: await getFilterPanelCount(client, frameNodeId)
860
+ const verified = groups.length === normalizedSpecs.length && groups.every((item) => item.verified);
861
+ const result = {
862
+ verified,
863
+ groups,
864
+ reopened: Boolean(reopen),
865
+ reopen_panel_node_id: reopen?.panel_node_id || null,
866
+ close_confirmation: closeConfirmation
867
+ ? {
868
+ confirmed: closeConfirmation.confirmed,
869
+ label: closeConfirmation.confirm_label,
870
+ node_id: closeConfirmation.confirm_node_id
871
+ }
872
+ : null
578
873
  };
874
+ if (!verified) {
875
+ const failedGroups = groups.filter((item) => !item.verified).map((item) => item.group);
876
+ const error = new Error(`Recommend filter sticky verification failed for: ${failedGroups.join(", ")}`);
877
+ error.sticky_verification = result;
878
+ throw error;
879
+ }
880
+ return result;
579
881
  }
580
882
 
581
883
  export async function selectAndConfirmFirstSafeFilter(client, frameNodeId, options = {}) {
582
884
  const beforeCounts = await getRecommendFilterCounts(client, frameNodeId);
583
- const openResult = await openFilterPanel(client, frameNodeId);
885
+ const requestedGroups = normalizeRequestedFilterGroups(options);
886
+ let openResult;
887
+ try {
888
+ openResult = await openFilterPanel(client, frameNodeId);
889
+ } catch (error) {
890
+ const onlyGroup = requestedGroups.length === 1 ? requestedGroups[0] : null;
891
+ const requestedLabels = onlyGroup?.labels || [];
892
+ const unavailableActivityDefault = (
893
+ error?.code === "RECOMMEND_FILTER_TRIGGER_UNAVAILABLE"
894
+ && normalizeText(onlyGroup?.group) === RECOMMEND_ACTIVITY_GROUP
895
+ && onlyGroup?.allow_unlimited === true
896
+ && requestedLabels.length === 1
897
+ && requestedLabels[0] === normalizeFilterOptionLabel("不限")
898
+ );
899
+ if (!unavailableActivityDefault) throw error;
900
+
901
+ const unavailableGroups = [{
902
+ group: RECOMMEND_ACTIVITY_GROUP,
903
+ requested_labels: requestedLabels,
904
+ reason: "activity_control_unavailable_default",
905
+ scope: "filter_panel"
906
+ }];
907
+ const stickySpecs = normalizeStickyVerificationSpecs(options);
908
+ const stickyVerification = stickySpecs.length
909
+ ? await verifyFilterGroupsSticky(client, frameNodeId, {
910
+ specs: stickySpecs,
911
+ unavailableGroups,
912
+ settleMs: 0
913
+ })
914
+ : null;
915
+ const afterCounts = await getRecommendFilterCounts(client, frameNodeId);
916
+ return {
917
+ opened_panel: false,
918
+ trigger: null,
919
+ initial_close_attempts: [],
920
+ open_attempts: [],
921
+ before_counts: beforeCounts,
922
+ after_open_counts: afterCounts,
923
+ requested_groups: requestedGroups,
924
+ selected_option: null,
925
+ selected_options: [],
926
+ option_box: null,
927
+ unavailable: true,
928
+ unavailable_default: true,
929
+ unavailable_groups: unavailableGroups,
930
+ discovered_options: [],
931
+ confirmed: true,
932
+ confirm_node_id: null,
933
+ confirm_label: "unavailable-default",
934
+ confirm_candidates: [],
935
+ confirm_attempts: [],
936
+ panel_count: Number(afterCounts.filter_panel || 0),
937
+ sticky_verification: stickyVerification,
938
+ after_confirm_counts: afterCounts
939
+ };
940
+ }
584
941
  const afterOpenCounts = await getRecommendFilterCounts(client, frameNodeId);
585
942
  const filterGroups = Array.isArray(options.filterGroups) ? options.filterGroups : [];
586
- const selection = filterGroups.length
943
+ const selection = filterGroups.length
587
944
  ? await selectFilterGroups(client, frameNodeId, { filterGroups, groupOrder: options.groupOrder })
588
945
  : options.selectAllLabels
589
946
  ? await selectFilterOptions(client, frameNodeId, options)
590
947
  : await selectFilterOption(client, frameNodeId, options);
591
948
  const confirm = await confirmFilterPanel(client, frameNodeId);
592
- await sleep(1200);
949
+ const stickySpecs = normalizeStickyVerificationSpecs(options);
950
+ const stickyVerification = confirm.confirmed && stickySpecs.length
951
+ ? await verifyFilterGroupsSticky(client, frameNodeId, {
952
+ specs: stickySpecs,
953
+ unavailableGroups: selection.unavailable_groups || [],
954
+ settleMs: Number.isFinite(options.stickySettleMs) ? Math.max(0, options.stickySettleMs) : 300
955
+ })
956
+ : null;
957
+ const afterConfirmSettleMs = Number.isFinite(options.afterConfirmSettleMs)
958
+ ? Math.max(0, options.afterConfirmSettleMs)
959
+ : 1200;
960
+ if (afterConfirmSettleMs > 0) await sleep(afterConfirmSettleMs);
593
961
  const afterConfirmCounts = await getRecommendFilterCounts(client, frameNodeId);
594
962
 
595
963
  return {
596
964
  opened_panel: true,
965
+ requested_groups: requestedGroups,
597
966
  trigger: {
598
- node_id: openResult.trigger.nodeId,
599
- selector: openResult.trigger.selector,
600
- center: openResult.trigger_box.center,
601
- rect: openResult.trigger_box.rect
967
+ node_id: openResult.trigger.nodeId,
968
+ selector: openResult.trigger.selector,
969
+ center: openResult.trigger_box.center,
970
+ rect: openResult.trigger_box.rect
602
971
  },
603
972
  initial_close_attempts: openResult.initial_close_attempts,
973
+ open_attempts: openResult.open_attempts || [],
604
974
  before_counts: beforeCounts,
605
975
  after_open_counts: afterOpenCounts,
606
976
  ...selection,
607
977
  ...confirm,
978
+ sticky_verification: stickyVerification,
608
979
  after_confirm_counts: afterConfirmCounts
609
980
  };
610
981
  }