@reconcrap/boss-recommend-mcp 2.0.45 → 2.0.47

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 (56) hide show
  1. package/bin/boss-recommend-mcp.js +4 -4
  2. package/config/screening-config.example.json +27 -27
  3. package/package.json +1 -1
  4. package/scripts/postinstall.cjs +44 -44
  5. package/skills/boss-chat/README.md +39 -39
  6. package/skills/boss-chat/SKILL.md +93 -93
  7. package/skills/boss-recommend-pipeline/README.md +12 -12
  8. package/skills/boss-recommend-pipeline/SKILL.md +180 -180
  9. package/skills/boss-recruit-pipeline/README.md +17 -17
  10. package/skills/boss-recruit-pipeline/SKILL.md +58 -58
  11. package/src/chat-mcp.js +1780 -1780
  12. package/src/chat-runtime-config.js +749 -749
  13. package/src/cli.js +3054 -3054
  14. package/src/core/boss-cards/index.js +199 -199
  15. package/src/core/browser/index.js +1453 -1446
  16. package/src/core/capture/index.js +1201 -1201
  17. package/src/core/cv-acquisition/index.js +238 -238
  18. package/src/core/cv-capture-target/index.js +299 -299
  19. package/src/core/greet-quota/index.js +54 -54
  20. package/src/core/infinite-list/index.js +1326 -1326
  21. package/src/core/reporting/legacy-csv.js +341 -341
  22. package/src/core/run/timing.js +33 -33
  23. package/src/core/screening/index.js +50 -3
  24. package/src/core/self-heal/index.js +973 -973
  25. package/src/core/self-heal/viewport.js +564 -564
  26. package/src/domains/chat/cards.js +137 -137
  27. package/src/domains/chat/constants.js +221 -221
  28. package/src/domains/chat/detail.js +1668 -1661
  29. package/src/domains/chat/index.js +7 -7
  30. package/src/domains/chat/jobs.js +592 -588
  31. package/src/domains/chat/page-guard.js +98 -98
  32. package/src/domains/chat/roots.js +56 -56
  33. package/src/domains/chat/run-service.js +1977 -1955
  34. package/src/domains/recommend/actions.js +457 -457
  35. package/src/domains/recommend/cards.js +243 -243
  36. package/src/domains/recommend/constants.js +165 -165
  37. package/src/domains/recommend/detail.js +36 -28
  38. package/src/domains/recommend/filters.js +610 -581
  39. package/src/domains/recommend/index.js +10 -10
  40. package/src/domains/recommend/jobs.js +316 -263
  41. package/src/domains/recommend/refresh.js +472 -472
  42. package/src/domains/recommend/roots.js +80 -80
  43. package/src/domains/recommend/run-service.js +75 -35
  44. package/src/domains/recommend/scopes.js +246 -245
  45. package/src/domains/recruit/actions.js +277 -277
  46. package/src/domains/recruit/cards.js +74 -74
  47. package/src/domains/recruit/constants.js +167 -167
  48. package/src/domains/recruit/detail.js +461 -460
  49. package/src/domains/recruit/index.js +9 -9
  50. package/src/domains/recruit/instruction-parser.js +451 -451
  51. package/src/domains/recruit/refresh.js +44 -44
  52. package/src/domains/recruit/roots.js +68 -68
  53. package/src/domains/recruit/run-service.js +1207 -1161
  54. package/src/domains/recruit/search.js +1202 -1149
  55. package/src/recommend-mcp.js +22 -22
  56. package/src/recruit-mcp.js +1338 -1338
@@ -1,581 +1,610 @@
1
- import {
2
- clickNodeCenter,
3
- countSelectors,
4
- findFirstNode,
5
- getAttributesMap,
6
- getNodeBox,
7
- getOuterHTML,
8
- pressKey,
9
- querySelectorAll,
10
- sleep,
11
- waitForSelector
12
- } from "../../core/browser/index.js";
13
- import { htmlToText, normalizeText } from "../../core/screening/index.js";
14
- import {
15
- RECOMMEND_CARD_SELECTOR,
16
- RECOMMEND_FILTER_GROUP_ORDER,
17
- RECOMMEND_FILTER_SELECTORS
18
- } from "./constants.js";
19
-
20
- const SKIP_OPTION_LABELS = new Set(["不限", "全部", "all"]);
21
-
22
- export function normalizeFilterOptionLabel(label) {
23
- return normalizeText(label).replace(/\s+/g, "");
24
- }
25
-
26
- export function isSafeFilterOptionLabel(label) {
27
- const normalized = normalizeFilterOptionLabel(label);
28
- return Boolean(normalized) && !SKIP_OPTION_LABELS.has(normalized.toLowerCase());
29
- }
30
-
31
- export function isActiveOption(attributes = {}, outerHTML = "") {
32
- const className = attributes.class || "";
33
- return /\bactive\b/.test(className) || /\bactive\b/.test(String(outerHTML || "").split(">")[0] || "");
34
- }
35
-
36
- export function chooseFirstSafeFilterOption(options = [], groupOrder = RECOMMEND_FILTER_GROUP_ORDER) {
37
- for (const group of groupOrder) {
38
- const option = options.find((item) => (
39
- item.group === group
40
- && !item.active
41
- && isSafeFilterOptionLabel(item.label)
42
- ));
43
- if (option) return option;
44
- }
45
- return null;
46
- }
47
-
48
- export function chooseFilterOptionByLabels(options = [], {
49
- group = "",
50
- labels = []
51
- } = {}) {
52
- const normalizedGroup = normalizeText(group);
53
- const normalizedLabels = labels.map(normalizeFilterOptionLabel).filter(Boolean);
54
- for (const label of normalizedLabels) {
55
- const option = options.find((item) => (
56
- (!normalizedGroup || item.group === normalizedGroup)
57
- && !item.active
58
- && normalizeFilterOptionLabel(item.label) === label
59
- && isSafeFilterOptionLabel(item.label)
60
- ));
61
- if (option) return option;
62
- }
63
- return null;
64
- }
65
-
66
- export function chooseFilterOptionsByLabels(options = [], {
67
- group = "",
68
- labels = []
69
- } = {}) {
70
- const normalizedGroup = normalizeText(group);
71
- const normalizedLabels = labels.map(normalizeFilterOptionLabel).filter(Boolean);
72
- return normalizedLabels.map((label) => {
73
- const option = options.find((item) => (
74
- (!normalizedGroup || item.group === normalizedGroup)
75
- && normalizeFilterOptionLabel(item.label) === label
76
- && isSafeFilterOptionLabel(item.label)
77
- ));
78
- return {
79
- label,
80
- option: option || null
81
- };
82
- });
83
- }
84
-
85
- export async function getFilterPanelCount(client, frameNodeId) {
86
- return (await querySelectorAll(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel)).length;
87
- }
88
-
89
- export async function getRecommendFilterCounts(client, frameNodeId) {
90
- return countSelectors(client, frameNodeId, {
91
- filter_trigger: RECOMMEND_FILTER_SELECTORS.trigger,
92
- filter_panel: RECOMMEND_FILTER_SELECTORS.panel,
93
- check_box: RECOMMEND_FILTER_SELECTORS.checkBox,
94
- option: `.filter-panel ${RECOMMEND_FILTER_SELECTORS.option}`,
95
- active_option: RECOMMEND_FILTER_SELECTORS.activeOption,
96
- recommend_card: RECOMMEND_CARD_SELECTOR
97
- });
98
- }
99
-
100
- export async function findFilterTrigger(client, frameNodeId) {
101
- return findFirstNode(client, frameNodeId, [
102
- RECOMMEND_FILTER_SELECTORS.trigger,
103
- ".recommend-filter.op-filter"
104
- ]);
105
- }
106
-
107
- export async function ensureFilterPanelClosed(client, frameNodeId, triggerNodeId = 0) {
108
- const attempts = [];
109
- if (await getFilterPanelCount(client, frameNodeId) === 0) return attempts;
110
-
111
- await pressKey(client, "Escape", {
112
- code: "Escape",
113
- windowsVirtualKeyCode: 27,
114
- nativeVirtualKeyCode: 27
115
- });
116
- await sleep(400);
117
- attempts.push("Escape");
118
-
119
- if (await getFilterPanelCount(client, frameNodeId) > 0 && triggerNodeId) {
120
- await clickNodeCenter(client, triggerNodeId);
121
- await sleep(500);
122
- attempts.push("filter-trigger-toggle");
123
- }
124
-
125
- return attempts;
126
- }
127
-
128
- async function findFilterTriggerCandidates(client, frameNodeId) {
129
- const candidates = [];
130
- const seen = new Set();
131
- for (const selector of [
132
- RECOMMEND_FILTER_SELECTORS.trigger,
133
- ".recommend-filter.op-filter"
134
- ]) {
135
- const candidate = await findFirstNode(client, frameNodeId, [selector]);
136
- if (candidate && !seen.has(candidate.nodeId)) {
137
- candidates.push(candidate);
138
- seen.add(candidate.nodeId);
139
- }
140
- }
141
- return candidates;
142
- }
143
-
144
- export async function openFilterPanel(client, frameNodeId) {
145
- let triggerCandidates = await findFilterTriggerCandidates(client, frameNodeId);
146
- if (!triggerCandidates.length) {
147
- throw new Error("Recommend filter trigger was not found");
148
- }
149
-
150
- const existingPanelNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel, {
151
- timeoutMs: 300,
152
- intervalMs: 100
153
- });
154
- if (existingPanelNodeId) {
155
- const triggerBox = await getNodeBox(client, triggerCandidates[0].nodeId);
156
- return {
157
- trigger: triggerCandidates[0],
158
- trigger_box: triggerBox,
159
- panel_node_id: existingPanelNodeId,
160
- initial_close_attempts: [],
161
- already_open: true
162
- };
163
- }
164
-
165
- const closeAttempts = await ensureFilterPanelClosed(client, frameNodeId, triggerCandidates[0].nodeId);
166
-
167
- const attempts = [];
168
- for (let round = 0; round < 3; round += 1) {
169
- triggerCandidates = await findFilterTriggerCandidates(client, frameNodeId);
170
- for (const trigger of triggerCandidates) {
171
- const triggerBox = await getNodeBox(client, trigger.nodeId);
172
- await clickNodeCenter(client, trigger.nodeId);
173
- attempts.push({
174
- selector: trigger.selector,
175
- node_id: trigger.nodeId,
176
- center: triggerBox.center
177
- });
178
- const panelNodeId = await waitForSelector(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel, {
179
- timeoutMs: 2500,
180
- intervalMs: 200
181
- });
182
- if (panelNodeId) {
183
- return {
184
- trigger,
185
- trigger_box: triggerBox,
186
- panel_node_id: panelNodeId,
187
- initial_close_attempts: closeAttempts,
188
- open_attempts: attempts
189
- };
190
- }
191
- }
192
- await sleep(500);
193
- }
194
-
195
- throw new Error(`Recommend filter panel did not open after ${attempts.length} trigger attempts`);
196
- }
197
-
198
- async function readOptionNode(client, group, nodeId) {
199
- const [attributes, outerHTML] = await Promise.all([
200
- getAttributesMap(client, nodeId),
201
- getOuterHTML(client, nodeId)
202
- ]);
203
- const label = normalizeFilterOptionLabel(htmlToText(outerHTML));
204
- return {
205
- group,
206
- node_id: nodeId,
207
- label,
208
- active: isActiveOption(attributes, outerHTML),
209
- attributes: {
210
- class: attributes.class || "",
211
- value: attributes.value || "",
212
- type: attributes.type || ""
213
- }
214
- };
215
- }
216
-
217
- export async function listFilterOptions(client, frameNodeId, {
218
- groupOrder = RECOMMEND_FILTER_GROUP_ORDER
219
- } = {}) {
220
- const options = [];
221
- for (const group of groupOrder) {
222
- const groupSelector = RECOMMEND_FILTER_SELECTORS.groups[group];
223
- if (!groupSelector) continue;
224
- const groupNodeIds = await querySelectorAll(client, frameNodeId, groupSelector);
225
- for (const groupNodeId of groupNodeIds) {
226
- const optionNodeIds = await querySelectorAll(client, groupNodeId, RECOMMEND_FILTER_SELECTORS.option);
227
- for (const optionNodeId of optionNodeIds) {
228
- options.push(await readOptionNode(client, group, optionNodeId));
229
- }
230
- }
231
- }
232
- return options;
233
- }
234
-
235
- async function clickFirstAvailableNode(client, nodeIds) {
236
- const errors = [];
237
- for (const nodeId of nodeIds) {
238
- try {
239
- const box = await clickNodeCenter(client, nodeId);
240
- return {
241
- clicked: true,
242
- node_id: nodeId,
243
- box
244
- };
245
- } catch (error) {
246
- errors.push({
247
- node_id: nodeId,
248
- message: error?.message || String(error)
249
- });
250
- }
251
- }
252
- return {
253
- clicked: false,
254
- errors
255
- };
256
- }
257
-
258
- function normalizeButtonLabel(label) {
259
- return normalizeFilterOptionLabel(label).toLowerCase();
260
- }
261
-
262
- function buttonRank(candidate) {
263
- const label = normalizeButtonLabel(candidate.label);
264
- if (/确定|确认|完成|ok|confirm/.test(label)) return 0;
265
- if (/重置|清空|取消|reset|cancel/.test(label)) return 2;
266
- return 1;
267
- }
268
-
269
- async function readButtonCandidate(client, nodeId, index) {
270
- const [attributes, outerHTML] = await Promise.all([
271
- getAttributesMap(client, nodeId),
272
- getOuterHTML(client, nodeId)
273
- ]);
274
- return {
275
- node_id: nodeId,
276
- index,
277
- label: normalizeButtonLabel(htmlToText(outerHTML)),
278
- class_name: attributes.class || ""
279
- };
280
- }
281
-
282
- async function readConfirmButtonCandidates(client, frameNodeId) {
283
- const nodeIds = await querySelectorAll(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.confirmButton);
284
- const candidates = [];
285
- for (let index = 0; index < nodeIds.length; index += 1) {
286
- candidates.push(await readButtonCandidate(client, nodeIds[index], index));
287
- }
288
- return candidates.sort((left, right) => {
289
- const rankDiff = buttonRank(left) - buttonRank(right);
290
- if (rankDiff !== 0) return rankDiff;
291
- return right.index - left.index;
292
- });
293
- }
294
-
295
- export async function selectFirstSafeFilterOption(client, frameNodeId, {
296
- groupOrder = RECOMMEND_FILTER_GROUP_ORDER
297
- } = {}) {
298
- const options = await listFilterOptions(client, frameNodeId, { groupOrder });
299
- const selected = chooseFirstSafeFilterOption(options, groupOrder);
300
- if (!selected) {
301
- throw new Error("No safe non-active recommend filter option was found");
302
- }
303
-
304
- const box = await clickNodeCenter(client, selected.node_id, { scrollIntoView: true });
305
- await sleep(300);
306
-
307
- return {
308
- selected_option: {
309
- group: selected.group,
310
- label: selected.label,
311
- node_id: selected.node_id,
312
- was_active: selected.active
313
- },
314
- option_box: box,
315
- discovered_options: options.map((option) => ({
316
- group: option.group,
317
- label: option.label,
318
- active: option.active,
319
- node_id: option.node_id
320
- }))
321
- };
322
- }
323
-
324
- export async function selectFilterOption(client, frameNodeId, {
325
- group = "",
326
- labels = [],
327
- groupOrder = RECOMMEND_FILTER_GROUP_ORDER
328
- } = {}) {
329
- const options = await listFilterOptions(client, frameNodeId, { groupOrder });
330
- const selected = labels.length
331
- ? chooseFilterOptionByLabels(options, { group, labels })
332
- : chooseFirstSafeFilterOption(options, groupOrder);
333
-
334
- if (!selected) {
335
- const target = labels.length
336
- ? `${group || "any group"} / ${labels.join(", ")}`
337
- : "first safe non-active option";
338
- throw new Error(`No matching recommend filter option was found for ${target}`);
339
- }
340
-
341
- const box = await clickNodeCenter(client, selected.node_id, { scrollIntoView: true });
342
- await sleep(300);
343
-
344
- return {
345
- selected_option: {
346
- group: selected.group,
347
- label: selected.label,
348
- node_id: selected.node_id,
349
- was_active: selected.active,
350
- requested_group: group || null,
351
- requested_labels: labels
352
- },
353
- option_box: box,
354
- discovered_options: options.map((option) => ({
355
- group: option.group,
356
- label: option.label,
357
- active: option.active,
358
- node_id: option.node_id
359
- }))
360
- };
361
- }
362
-
363
- export async function selectFilterOptions(client, frameNodeId, {
364
- group = "",
365
- labels = [],
366
- groupOrder = RECOMMEND_FILTER_GROUP_ORDER
367
- } = {}) {
368
- if (!labels.length) {
369
- return selectFilterOption(client, frameNodeId, { group, labels, groupOrder });
370
- }
371
-
372
- const selectedOptions = [];
373
- const missingLabels = [];
374
- let discoveredOptions = [];
375
-
376
- for (const label of labels) {
377
- if (await getFilterPanelCount(client, frameNodeId) === 0) {
378
- await openFilterPanel(client, frameNodeId);
379
- }
380
-
381
- const options = await listFilterOptions(client, frameNodeId, { groupOrder });
382
- discoveredOptions = options.map((option) => ({
383
- group: option.group,
384
- label: option.label,
385
- active: option.active,
386
- node_id: option.node_id
387
- }));
388
- const selected = chooseFilterOptionByLabels(options, { group, labels: [label] });
389
- const alreadyActive = options.find((option) => (
390
- (!group || option.group === group)
391
- && normalizeFilterOptionLabel(option.label) === normalizeFilterOptionLabel(label)
392
- && option.active
393
- ));
394
-
395
- if (alreadyActive) {
396
- selectedOptions.push({
397
- group: alreadyActive.group,
398
- label: alreadyActive.label,
399
- node_id: alreadyActive.node_id,
400
- was_active: true,
401
- clicked: false,
402
- requested_group: group || null
403
- });
404
- continue;
405
- }
406
-
407
- if (!selected) {
408
- missingLabels.push(label);
409
- continue;
410
- }
411
-
412
- const box = await clickNodeCenter(client, selected.node_id, { scrollIntoView: true });
413
- selectedOptions.push({
414
- group: selected.group,
415
- label: selected.label,
416
- node_id: selected.node_id,
417
- was_active: false,
418
- clicked: true,
419
- requested_group: group || null,
420
- option_box: box
421
- });
422
- await sleep(450);
423
- }
424
-
425
- if (missingLabels.length) {
426
- throw new Error(`No matching recommend filter options were found for ${group || "any group"} / ${missingLabels.join(", ")}`);
427
- }
428
-
429
- return {
430
- selected_option: selectedOptions[0] || null,
431
- selected_options: selectedOptions.map((option) => ({
432
- group: option.group,
433
- label: option.label,
434
- node_id: option.node_id,
435
- was_active: option.was_active,
436
- clicked: option.clicked,
437
- requested_group: option.requested_group,
438
- requested_labels: labels
439
- })),
440
- option_box: selectedOptions.find((option) => option.option_box)?.option_box || null,
441
- discovered_options: discoveredOptions
442
- };
443
- }
444
-
445
- export async function selectFilterGroups(client, frameNodeId, {
446
- filterGroups = [],
447
- groupOrder = RECOMMEND_FILTER_GROUP_ORDER
448
- } = {}) {
449
- const selectedOptions = [];
450
- const discoveredOptions = [];
451
- const groups = filterGroups.filter((item) => item && (item.group || item.labels?.length));
452
- if (!groups.length) {
453
- return selectFilterOption(client, frameNodeId, { groupOrder });
454
- }
455
-
456
- for (const spec of groups) {
457
- const labels = Array.isArray(spec.labels) ? spec.labels : [];
458
- const selection = spec.selectAllLabels === false
459
- ? await selectFilterOption(client, frameNodeId, {
460
- group: spec.group || "",
461
- labels,
462
- groupOrder
463
- })
464
- : await selectFilterOptions(client, frameNodeId, {
465
- group: spec.group || "",
466
- labels,
467
- groupOrder
468
- });
469
- if (selection.selected_option) selectedOptions.push(selection.selected_option);
470
- for (const option of selection.selected_options || []) {
471
- selectedOptions.push(option);
472
- }
473
- for (const option of selection.discovered_options || []) {
474
- discoveredOptions.push(option);
475
- }
476
- }
477
-
478
- const dedupedSelected = [];
479
- const seenSelected = new Set();
480
- for (const option of selectedOptions) {
481
- const key = `${option.group || ""}:${normalizeFilterOptionLabel(option.label || "")}`;
482
- if (seenSelected.has(key)) continue;
483
- seenSelected.add(key);
484
- dedupedSelected.push(option);
485
- }
486
-
487
- return {
488
- selected_option: dedupedSelected[0] || null,
489
- selected_options: dedupedSelected,
490
- option_box: dedupedSelected.find((option) => option.option_box)?.option_box || null,
491
- discovered_options: discoveredOptions
492
- };
493
- }
494
-
495
- export async function confirmFilterPanel(client, frameNodeId, {
496
- timeoutMs = 8000
497
- } = {}) {
498
- const candidates = await readConfirmButtonCandidates(client, frameNodeId);
499
- if (!candidates.length && await getFilterPanelCount(client, frameNodeId) === 0) {
500
- return {
501
- confirmed: true,
502
- confirm_node_id: null,
503
- confirm_label: "auto-closed",
504
- confirm_candidates: [],
505
- confirm_attempts: [],
506
- panel_count: 0
507
- };
508
- }
509
- if (!candidates.length) {
510
- throw new Error("Recommend filter confirm button was not found");
511
- }
512
-
513
- const attempts = [];
514
- for (const candidate of candidates) {
515
- const clickResult = await clickFirstAvailableNode(client, [candidate.node_id]);
516
- attempts.push({
517
- node_id: candidate.node_id,
518
- label: candidate.label,
519
- clicked: clickResult.clicked,
520
- errors: clickResult.errors
521
- });
522
- if (!clickResult.clicked) continue;
523
-
524
- const started = Date.now();
525
- while (Date.now() - started <= timeoutMs) {
526
- const panelCount = await getFilterPanelCount(client, frameNodeId);
527
- if (panelCount === 0) {
528
- return {
529
- confirmed: true,
530
- confirm_node_id: clickResult.node_id,
531
- confirm_label: candidate.label,
532
- confirm_box: clickResult.box,
533
- confirm_candidates: candidates,
534
- confirm_attempts: attempts,
535
- panel_count: 0
536
- };
537
- }
538
- await sleep(250);
539
- }
540
- }
541
-
542
- return {
543
- confirmed: false,
544
- confirm_node_id: attempts.at(-1)?.node_id || null,
545
- confirm_label: attempts.at(-1)?.label || null,
546
- confirm_candidates: candidates,
547
- confirm_attempts: attempts,
548
- panel_count: await getFilterPanelCount(client, frameNodeId)
549
- };
550
- }
551
-
552
- export async function selectAndConfirmFirstSafeFilter(client, frameNodeId, options = {}) {
553
- const beforeCounts = await getRecommendFilterCounts(client, frameNodeId);
554
- const openResult = await openFilterPanel(client, frameNodeId);
555
- const afterOpenCounts = await getRecommendFilterCounts(client, frameNodeId);
556
- const filterGroups = Array.isArray(options.filterGroups) ? options.filterGroups : [];
557
- const selection = filterGroups.length
558
- ? await selectFilterGroups(client, frameNodeId, { filterGroups, groupOrder: options.groupOrder })
559
- : options.selectAllLabels
560
- ? await selectFilterOptions(client, frameNodeId, options)
561
- : await selectFilterOption(client, frameNodeId, options);
562
- const confirm = await confirmFilterPanel(client, frameNodeId);
563
- await sleep(1200);
564
- const afterConfirmCounts = await getRecommendFilterCounts(client, frameNodeId);
565
-
566
- return {
567
- opened_panel: true,
568
- trigger: {
569
- node_id: openResult.trigger.nodeId,
570
- selector: openResult.trigger.selector,
571
- center: openResult.trigger_box.center,
572
- rect: openResult.trigger_box.rect
573
- },
574
- initial_close_attempts: openResult.initial_close_attempts,
575
- before_counts: beforeCounts,
576
- after_open_counts: afterOpenCounts,
577
- ...selection,
578
- ...confirm,
579
- after_confirm_counts: afterConfirmCounts
580
- };
581
- }
1
+ import {
2
+ clickNodeCenter,
3
+ countSelectors,
4
+ DETERMINISTIC_CLICK_OPTIONS,
5
+ findFirstNode,
6
+ getAttributesMap,
7
+ getNodeBox,
8
+ getOuterHTML,
9
+ pressKey,
10
+ querySelectorAll,
11
+ sleep,
12
+ waitForSelector
13
+ } from "../../core/browser/index.js";
14
+ import { htmlToText, normalizeText } from "../../core/screening/index.js";
15
+ import {
16
+ RECOMMEND_CARD_SELECTOR,
17
+ RECOMMEND_FILTER_GROUP_ORDER,
18
+ RECOMMEND_FILTER_SELECTORS
19
+ } from "./constants.js";
20
+
21
+ const SKIP_OPTION_LABELS = new Set(["不限", "全部", "all"]);
22
+
23
+ export function normalizeFilterOptionLabel(label) {
24
+ return normalizeText(label).replace(/\s+/g, "");
25
+ }
26
+
27
+ export function isSafeFilterOptionLabel(label) {
28
+ const normalized = normalizeFilterOptionLabel(label);
29
+ return Boolean(normalized) && !SKIP_OPTION_LABELS.has(normalized.toLowerCase());
30
+ }
31
+
32
+ export function isActiveOption(attributes = {}, outerHTML = "") {
33
+ const className = attributes.class || "";
34
+ return /\bactive\b/.test(className) || /\bactive\b/.test(String(outerHTML || "").split(">")[0] || "");
35
+ }
36
+
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
+ }
46
+ return null;
47
+ }
48
+
49
+ export function chooseFilterOptionByLabels(options = [], {
50
+ group = "",
51
+ labels = []
52
+ } = {}) {
53
+ const normalizedGroup = normalizeText(group);
54
+ const normalizedLabels = labels.map(normalizeFilterOptionLabel).filter(Boolean);
55
+ for (const label of normalizedLabels) {
56
+ const option = options.find((item) => (
57
+ (!normalizedGroup || item.group === normalizedGroup)
58
+ && !item.active
59
+ && normalizeFilterOptionLabel(item.label) === label
60
+ && isSafeFilterOptionLabel(item.label)
61
+ ));
62
+ if (option) return option;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ export function chooseFilterOptionsByLabels(options = [], {
68
+ group = "",
69
+ labels = []
70
+ } = {}) {
71
+ const normalizedGroup = normalizeText(group);
72
+ const normalizedLabels = labels.map(normalizeFilterOptionLabel).filter(Boolean);
73
+ return normalizedLabels.map((label) => {
74
+ const option = options.find((item) => (
75
+ (!normalizedGroup || item.group === normalizedGroup)
76
+ && normalizeFilterOptionLabel(item.label) === label
77
+ && isSafeFilterOptionLabel(item.label)
78
+ ));
79
+ return {
80
+ label,
81
+ option: option || null
82
+ };
83
+ });
84
+ }
85
+
86
+ export async function getFilterPanelCount(client, frameNodeId) {
87
+ return (await querySelectorAll(client, frameNodeId, RECOMMEND_FILTER_SELECTORS.panel)).length;
88
+ }
89
+
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
+ });
99
+ }
100
+
101
+ export async function findFilterTrigger(client, frameNodeId) {
102
+ return findFirstNode(client, frameNodeId, [
103
+ RECOMMEND_FILTER_SELECTORS.trigger,
104
+ ".recommend-filter.op-filter"
105
+ ]);
106
+ }
107
+
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");
124
+ }
125
+
126
+ return attempts;
127
+ }
128
+
129
+ async function dismissRecommendControlOverlays(client, settleMs = 250) {
130
+ if (typeof client?.Input?.dispatchKeyEvent !== "function") {
131
+ return ["Escape-unavailable"];
132
+ }
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);
153
+ }
154
+ }
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);
213
+ }
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
+ };
235
+ }
236
+
237
+ export async function listFilterOptions(client, frameNodeId, {
238
+ groupOrder = RECOMMEND_FILTER_GROUP_ORDER
239
+ } = {}) {
240
+ const options = [];
241
+ 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);
245
+ for (const groupNodeId of groupNodeIds) {
246
+ const optionNodeIds = await querySelectorAll(client, groupNodeId, RECOMMEND_FILTER_SELECTORS.option);
247
+ for (const optionNodeId of optionNodeIds) {
248
+ options.push(await readOptionNode(client, group, optionNodeId));
249
+ }
250
+ }
251
+ }
252
+ return options;
253
+ }
254
+
255
+ 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
+ };
345
+ }
346
+
347
+ export async function selectFilterOption(client, frameNodeId, {
348
+ group = "",
349
+ labels = [],
350
+ groupOrder = RECOMMEND_FILTER_GROUP_ORDER
351
+ } = {}) {
352
+ const options = await listFilterOptions(client, frameNodeId, { groupOrder });
353
+ const selected = labels.length
354
+ ? chooseFilterOptionByLabels(options, { group, labels })
355
+ : chooseFirstSafeFilterOption(options, groupOrder);
356
+
357
+ if (!selected) {
358
+ const target = labels.length
359
+ ? `${group || "any group"} / ${labels.join(", ")}`
360
+ : "first safe non-active option";
361
+ throw new Error(`No matching recommend filter option was found for ${target}`);
362
+ }
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 {
371
+ selected_option: {
372
+ group: selected.group,
373
+ label: selected.label,
374
+ node_id: selected.node_id,
375
+ was_active: selected.active,
376
+ requested_group: group || null,
377
+ requested_labels: labels
378
+ },
379
+ option_box: box,
380
+ discovered_options: options.map((option) => ({
381
+ group: option.group,
382
+ label: option.label,
383
+ active: option.active,
384
+ node_id: option.node_id
385
+ }))
386
+ };
387
+ }
388
+
389
+ export async function selectFilterOptions(client, frameNodeId, {
390
+ group = "",
391
+ labels = [],
392
+ groupOrder = RECOMMEND_FILTER_GROUP_ORDER
393
+ } = {}) {
394
+ if (!labels.length) {
395
+ return selectFilterOption(client, frameNodeId, { group, labels, groupOrder });
396
+ }
397
+
398
+ const selectedOptions = [];
399
+ const missingLabels = [];
400
+ 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) => ({
409
+ group: option.group,
410
+ label: option.label,
411
+ active: option.active,
412
+ node_id: option.node_id
413
+ }));
414
+ const selected = chooseFilterOptionByLabels(options, { group, labels: [label] });
415
+ const alreadyActive = options.find((option) => (
416
+ (!group || option.group === group)
417
+ && normalizeFilterOptionLabel(option.label) === normalizeFilterOptionLabel(label)
418
+ && option.active
419
+ ));
420
+
421
+ if (alreadyActive) {
422
+ selectedOptions.push({
423
+ 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
450
+ });
451
+ await sleep(450);
452
+ }
453
+
454
+ if (missingLabels.length) {
455
+ throw new Error(`No matching recommend filter options were found for ${group || "any group"} / ${missingLabels.join(", ")}`);
456
+ }
457
+
458
+ return {
459
+ 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,
465
+ clicked: option.clicked,
466
+ requested_group: option.requested_group,
467
+ requested_labels: labels
468
+ })),
469
+ option_box: selectedOptions.find((option) => option.option_box)?.option_box || null,
470
+ discovered_options: discoveredOptions
471
+ };
472
+ }
473
+
474
+ export async function selectFilterGroups(client, frameNodeId, {
475
+ filterGroups = [],
476
+ groupOrder = RECOMMEND_FILTER_GROUP_ORDER
477
+ } = {}) {
478
+ const selectedOptions = [];
479
+ const discoveredOptions = [];
480
+ const groups = filterGroups.filter((item) => item && (item.group || item.labels?.length));
481
+ if (!groups.length) {
482
+ return selectFilterOption(client, frameNodeId, { groupOrder });
483
+ }
484
+
485
+ for (const spec of groups) {
486
+ const labels = Array.isArray(spec.labels) ? spec.labels : [];
487
+ const selection = spec.selectAllLabels === false
488
+ ? await selectFilterOption(client, frameNodeId, {
489
+ group: spec.group || "",
490
+ labels,
491
+ groupOrder
492
+ })
493
+ : await selectFilterOptions(client, frameNodeId, {
494
+ group: spec.group || "",
495
+ labels,
496
+ groupOrder
497
+ });
498
+ if (selection.selected_option) selectedOptions.push(selection.selected_option);
499
+ for (const option of selection.selected_options || []) {
500
+ selectedOptions.push(option);
501
+ }
502
+ for (const option of selection.discovered_options || []) {
503
+ discoveredOptions.push(option);
504
+ }
505
+ }
506
+
507
+ const dedupedSelected = [];
508
+ const seenSelected = new Set();
509
+ 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
+ }
515
+
516
+ return {
517
+ selected_option: dedupedSelected[0] || null,
518
+ selected_options: dedupedSelected,
519
+ option_box: dedupedSelected.find((option) => option.option_box)?.option_box || null,
520
+ discovered_options: discoveredOptions
521
+ };
522
+ }
523
+
524
+ export async function confirmFilterPanel(client, frameNodeId, {
525
+ timeoutMs = 8000
526
+ } = {}) {
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");
540
+ }
541
+
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);
568
+ }
569
+ }
570
+
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)
578
+ };
579
+ }
580
+
581
+ export async function selectAndConfirmFirstSafeFilter(client, frameNodeId, options = {}) {
582
+ const beforeCounts = await getRecommendFilterCounts(client, frameNodeId);
583
+ const openResult = await openFilterPanel(client, frameNodeId);
584
+ const afterOpenCounts = await getRecommendFilterCounts(client, frameNodeId);
585
+ const filterGroups = Array.isArray(options.filterGroups) ? options.filterGroups : [];
586
+ const selection = filterGroups.length
587
+ ? await selectFilterGroups(client, frameNodeId, { filterGroups, groupOrder: options.groupOrder })
588
+ : options.selectAllLabels
589
+ ? await selectFilterOptions(client, frameNodeId, options)
590
+ : await selectFilterOption(client, frameNodeId, options);
591
+ const confirm = await confirmFilterPanel(client, frameNodeId);
592
+ await sleep(1200);
593
+ const afterConfirmCounts = await getRecommendFilterCounts(client, frameNodeId);
594
+
595
+ return {
596
+ opened_panel: true,
597
+ trigger: {
598
+ node_id: openResult.trigger.nodeId,
599
+ selector: openResult.trigger.selector,
600
+ center: openResult.trigger_box.center,
601
+ rect: openResult.trigger_box.rect
602
+ },
603
+ initial_close_attempts: openResult.initial_close_attempts,
604
+ before_counts: beforeCounts,
605
+ after_open_counts: afterOpenCounts,
606
+ ...selection,
607
+ ...confirm,
608
+ after_confirm_counts: afterConfirmCounts
609
+ };
610
+ }