@reconcrap/boss-recommend-mcp 2.1.21 → 2.1.22

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 +8 -8
  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 +2419 -2415
  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 +1326 -1326
  23. package/src/core/reporting/legacy-csv.js +334 -332
  24. package/src/core/run/index.js +32 -32
  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 +650 -650
  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 +504 -361
  50. package/src/domains/recommend/roots.js +80 -80
  51. package/src/domains/recommend/run-service.js +987 -854
  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 +13 -0
  64. package/src/parser.js +376 -8
  65. package/src/recommend-mcp.js +929 -915
  66. package/src/recommend-scheduler.js +496 -496
  67. package/src/recruit-mcp.js +2121 -2121
@@ -1,472 +1,472 @@
1
- import {
2
- clickPoint,
3
- getFrameDocumentNodeId,
4
- getAttributesMap,
5
- getNodeBox,
6
- getOuterHTML,
7
- querySelectorAll,
8
- scrollNodeIntoView,
9
- sleep
10
- } from "../../core/browser/index.js";
11
- import {
12
- htmlToText,
13
- normalizeText
14
- } from "../../core/screening/index.js";
15
- import {
16
- assertGreetQuotaAvailable,
17
- parseGreetQuota
18
- } from "../../core/greet-quota/index.js";
19
- import {
20
- FAVORITE_BUTTON_SELECTORS,
21
- GREET_BUTTON_RECOMMEND_SELECTORS
22
- } from "./constants.js";
23
- import { waitForRecommendDetail } from "./detail.js";
24
- import { getRecommendRoots } from "./roots.js";
25
-
26
- const POST_ACTIONS = new Set(["none", "greet"]);
27
- const GREET_EXACT_LABEL_PATTERN = /^(?:打招呼|聊一聊|立即沟通(?:[\((]\d+\s*[//]\s*\d+[\))])?|沟通)$/i;
28
- export const RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS = Object.freeze([
29
- "button",
30
- ".btn",
31
- '[role="button"]',
32
- "a",
33
- "span",
34
- "div"
35
- ]);
36
-
37
- function uniqueSelectors(...selectorGroups) {
38
- return [...new Set(selectorGroups.flat().filter(Boolean))];
39
- }
40
-
41
- function uniqueByNode(candidates = []) {
42
- const seen = new Set();
43
- const result = [];
44
- for (const item of candidates) {
45
- const key = `${item.kind}:${item.root}:${item.node_id}`;
46
- if (seen.has(key)) continue;
47
- seen.add(key);
48
- result.push(item);
49
- }
50
- return result;
51
- }
52
-
53
- function lowerText(...parts) {
54
- return normalizeText(parts.filter(Boolean).join(" ")).toLowerCase();
55
- }
56
-
57
- function hasActiveClass(text) {
58
- return /(?:^|\s)(?:active|curr|current|selected|checked)(?:\s|$)/i.test(text);
59
- }
60
-
61
- function hasDisabledSignal(text) {
62
- return /(?:^|\s)(?:disabled|disable|forbidden|is-disabled)(?:\s|$)/i.test(text);
63
- }
64
-
65
- function rectArea(control) {
66
- const rect = control?.rect || {};
67
- return Math.max(0, Number(rect.width) || 0) * Math.max(0, Number(rect.height) || 0);
68
- }
69
-
70
- function isCompactLabel(control, limit = 80) {
71
- const label = normalizeText(control?.label || "");
72
- return label.length > 0 && label.length <= limit;
73
- }
74
-
75
- function controlRank(control, exactLabelPattern) {
76
- const label = normalizeText(control?.label || "");
77
- const selector = String(control?.selector || "");
78
- const className = String(control?.class_name || "");
79
- let score = 0;
80
- if (exactLabelPattern.test(label)) score -= 1000;
81
- if (/button|\[role=|\.btn/.test(selector) || /btn|button/i.test(className)) score -= 250;
82
- if (selector === "div") score += 300;
83
- if (!isCompactLabel(control)) score += 500;
84
- score += Math.min(rectArea(control), 100000) / 1000;
85
- score += label.length / 10;
86
- return score;
87
- }
88
-
89
- function bestControl(controls, exactLabelPattern) {
90
- return [...controls].sort((left, right) => (
91
- controlRank(left, exactLabelPattern) - controlRank(right, exactLabelPattern)
92
- ))[0] || null;
93
- }
94
-
95
- export function normalizeRecommendPostAction(value) {
96
- const normalized = normalizeText(value).toLowerCase();
97
- if (["", "none", "skip", "no", "不执行", "无"].includes(normalized)) return "none";
98
- if (["greet", "chat", "打招呼", "直接沟通", "沟通"].includes(normalized)) return "greet";
99
- return POST_ACTIONS.has(normalized) ? normalized : "";
100
- }
101
-
102
- export function resolveRecommendPostAction({
103
- postAction = "none",
104
- greetCount = 0,
105
- maxGreetCount = null
106
- } = {}) {
107
- const requested = normalizeRecommendPostAction(postAction) || "none";
108
- const currentGreetCount = Number.isInteger(greetCount) && greetCount >= 0 ? greetCount : 0;
109
- const limit = Number.isInteger(maxGreetCount) && maxGreetCount > 0 ? maxGreetCount : null;
110
- if (requested === "greet" && limit !== null && currentGreetCount >= limit) {
111
- return {
112
- requested,
113
- effective: "none",
114
- reason: "greet_limit_reached",
115
- greet_count: currentGreetCount,
116
- max_greet_count: limit
117
- };
118
- }
119
- return {
120
- requested,
121
- effective: requested,
122
- reason: "requested_action",
123
- greet_count: currentGreetCount,
124
- max_greet_count: limit
125
- };
126
- }
127
-
128
- export function classifyFavoriteControl({
129
- outerHTML = "",
130
- attributes = {}
131
- } = {}) {
132
- const label = htmlToText(outerHTML);
133
- const labelText = normalizeText(label);
134
- const className = normalizeText(attributes.class || "");
135
- const title = normalizeText(attributes.title || attributes["aria-label"] || "");
136
- const combined = lowerText(className, title);
137
- const labelMatches = /^(?:收藏|已收藏|感兴趣|已感兴趣)$/.test(labelText);
138
- const classMatches = /favorite|collect|interest|like/.test(combined);
139
- const matches = labelMatches || classMatches;
140
- const active = (
141
- /已收藏|已感兴趣/.test(label)
142
- || /like-icon-active|favorite-active|collect-active/i.test(outerHTML)
143
- || hasActiveClass(className)
144
- );
145
- const disabled = (
146
- Object.prototype.hasOwnProperty.call(attributes, "disabled")
147
- || hasDisabledSignal(className)
148
- || /disabled/i.test(outerHTML)
149
- );
150
- return {
151
- kind: "favorite",
152
- matches,
153
- active,
154
- disabled,
155
- label: label || title || null,
156
- class_name: className || null
157
- };
158
- }
159
-
160
- export function classifyGreetControl({
161
- outerHTML = "",
162
- attributes = {}
163
- } = {}) {
164
- const label = htmlToText(outerHTML);
165
- const labelText = normalizeText(label);
166
- const className = normalizeText(attributes.class || "");
167
- const title = normalizeText(attributes.title || attributes["aria-label"] || "");
168
- const combined = lowerText(className, title);
169
- const continueChat = labelText.length <= 40 && /继续沟通/.test(labelText);
170
- const greetQuota = parseGreetQuota(labelText || title);
171
- const greetEntry = (
172
- GREET_EXACT_LABEL_PATTERN.test(labelText)
173
- || greetQuota.found
174
- || /greet/i.test(combined)
175
- );
176
- const disabled = (
177
- Object.prototype.hasOwnProperty.call(attributes, "disabled")
178
- || hasDisabledSignal(className)
179
- || /disabled/i.test(outerHTML)
180
- );
181
- return {
182
- kind: "greet",
183
- matches: greetEntry || continueChat,
184
- available: greetEntry && !continueChat && !disabled,
185
- continue_chat: continueChat,
186
- disabled,
187
- label: label || title || null,
188
- greet_quota: greetQuota.found ? greetQuota : null,
189
- class_name: className || null
190
- };
191
- }
192
-
193
- async function readActionNode(client, {
194
- root,
195
- selector,
196
- nodeId,
197
- kind
198
- }) {
199
- const [attributes, outerHTML] = await Promise.all([
200
- getAttributesMap(client, nodeId),
201
- getOuterHTML(client, nodeId)
202
- ]);
203
- let box = null;
204
- let visible = false;
205
- try {
206
- box = await getNodeBox(client, nodeId);
207
- visible = box.rect.width > 2 && box.rect.height > 2;
208
- } catch {}
209
- const classification = kind === "favorite"
210
- ? classifyFavoriteControl({ outerHTML, attributes })
211
- : classifyGreetControl({ outerHTML, attributes });
212
- return {
213
- kind,
214
- root: root.name,
215
- root_node_id: root.nodeId,
216
- selector,
217
- node_id: nodeId,
218
- visible,
219
- center: box?.center || null,
220
- rect: box?.rect || null,
221
- attributes,
222
- outer_html_length: outerHTML.length,
223
- html_preview: outerHTML.slice(0, 500),
224
- ...classification
225
- };
226
- }
227
-
228
- export async function collectRecommendActionControls(client, roots, {
229
- favoriteSelectors = FAVORITE_BUTTON_SELECTORS,
230
- greetSelectors = GREET_BUTTON_RECOMMEND_SELECTORS,
231
- detailTextFallback = false
232
- } = {}) {
233
- const candidates = [];
234
- const favoriteScanSelectors = detailTextFallback
235
- ? uniqueSelectors(favoriteSelectors, RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS)
236
- : favoriteSelectors;
237
- const greetScanSelectors = detailTextFallback
238
- ? uniqueSelectors(greetSelectors, RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS)
239
- : greetSelectors;
240
- for (const root of roots) {
241
- if (!root?.nodeId) continue;
242
- for (const [kind, selectors] of [
243
- ["favorite", favoriteScanSelectors],
244
- ["greet", greetScanSelectors]
245
- ]) {
246
- for (const selector of selectors) {
247
- const nodeIds = await querySelectorAll(client, root.nodeId, selector);
248
- for (const nodeId of nodeIds) {
249
- candidates.push(await readActionNode(client, {
250
- root,
251
- selector,
252
- nodeId,
253
- kind
254
- }));
255
- }
256
- }
257
- }
258
- }
259
- return uniqueByNode(candidates);
260
- }
261
-
262
- export function summarizeRecommendActionControls(controls = []) {
263
- const visibleControls = controls.filter((item) => item.visible && item.matches);
264
- const favoriteControls = visibleControls.filter((item) => item.kind === "favorite");
265
- const greetControls = visibleControls.filter((item) => item.kind === "greet");
266
- const favorite = bestControl(
267
- favoriteControls.filter((item) => item.matches),
268
- /^(?:收藏|已收藏|感兴趣|已感兴趣)$/i
269
- );
270
- const greet = bestControl(
271
- greetControls.filter((item) => item.available),
272
- GREET_EXACT_LABEL_PATTERN
273
- ) || bestControl(
274
- greetControls.filter((item) => item.continue_chat),
275
- /^继续沟通$/i
276
- );
277
- return {
278
- favorite: favorite
279
- ? {
280
- found: true,
281
- active: favorite.active,
282
- disabled: favorite.disabled,
283
- label: favorite.label,
284
- selector: favorite.selector,
285
- root: favorite.root,
286
- node_id: favorite.node_id,
287
- center: favorite.center
288
- }
289
- : { found: false },
290
- greet: greet
291
- ? {
292
- found: true,
293
- available: greet.available,
294
- continue_chat: greet.continue_chat,
295
- disabled: greet.disabled,
296
- label: greet.label,
297
- greet_quota: greet.greet_quota || null,
298
- selector: greet.selector,
299
- root: greet.root,
300
- node_id: greet.node_id,
301
- center: greet.center
302
- }
303
- : { found: false },
304
- counts: {
305
- total: controls.length,
306
- visible_matching: visibleControls.length,
307
- favorite: favoriteControls.length,
308
- greet: greetControls.length
309
- }
310
- };
311
- }
312
-
313
- export async function discoverRecommendActionControls(client, {
314
- roots = null,
315
- selectors = {},
316
- detailTextFallback = false
317
- } = {}) {
318
- const rootState = roots ? { roots } : await getRecommendRoots(client);
319
- const controls = await collectRecommendActionControls(client, rootState.roots, {
320
- ...selectors,
321
- detailTextFallback
322
- });
323
- return {
324
- controls,
325
- summary: summarizeRecommendActionControls(controls)
326
- };
327
- }
328
-
329
- export async function waitForRecommendActionControls(client, {
330
- timeoutMs = 6000,
331
- intervalMs = 250,
332
- requireAny = true,
333
- ...discoveryOptions
334
- } = {}) {
335
- const started = Date.now();
336
- let lastDiscovery = null;
337
- while (Date.now() - started <= timeoutMs) {
338
- lastDiscovery = await discoverRecommendActionControls(client, discoveryOptions);
339
- const hasControl = Boolean(
340
- lastDiscovery.summary.favorite.found
341
- || lastDiscovery.summary.greet.found
342
- );
343
- if (!requireAny || hasControl) {
344
- return {
345
- ...lastDiscovery,
346
- elapsed_ms: Date.now() - started,
347
- timed_out: false
348
- };
349
- }
350
- await sleep(intervalMs);
351
- }
352
- return {
353
- ...(lastDiscovery || { controls: [], summary: summarizeRecommendActionControls([]) }),
354
- elapsed_ms: Date.now() - started,
355
- timed_out: true
356
- };
357
- }
358
-
359
- export async function getRecommendDetailActionRoots(client, detailState) {
360
- const roots = [];
361
- if (detailState?.popup?.node_id) {
362
- roots.push({
363
- name: `${detailState.popup.root || "unknown"}:detail-popup`,
364
- nodeId: detailState.popup.node_id
365
- });
366
- }
367
- if (detailState?.resumeIframe?.node_id) {
368
- try {
369
- roots.push({
370
- name: `${detailState.resumeIframe.root || "unknown"}:resume-iframe-document`,
371
- nodeId: await getFrameDocumentNodeId(client, detailState.resumeIframe.node_id)
372
- });
373
- } catch {
374
- roots.push({
375
- name: `${detailState.resumeIframe.root || "unknown"}:resume-iframe-node`,
376
- nodeId: detailState.resumeIframe.node_id
377
- });
378
- }
379
- }
380
- return roots;
381
- }
382
-
383
- export async function waitForRecommendDetailActionControls(client, {
384
- timeoutMs = 8000,
385
- intervalMs = 350,
386
- selectors = {},
387
- requireAny = true
388
- } = {}) {
389
- const started = Date.now();
390
- let lastDiscovery = null;
391
- let lastError = null;
392
- let lastRootCount = 0;
393
- while (Date.now() - started <= timeoutMs) {
394
- const detailState = await waitForRecommendDetail(client, {
395
- timeoutMs: Math.min(intervalMs, 500),
396
- intervalMs: 100
397
- });
398
- const roots = await getRecommendDetailActionRoots(client, detailState);
399
- lastRootCount = roots.length;
400
- if (roots.length) {
401
- try {
402
- lastDiscovery = await discoverRecommendActionControls(client, {
403
- roots,
404
- selectors,
405
- detailTextFallback: true
406
- });
407
- const hasControl = Boolean(
408
- lastDiscovery.summary.favorite.found
409
- || lastDiscovery.summary.greet.found
410
- );
411
- if (!requireAny || hasControl) {
412
- return {
413
- ...lastDiscovery,
414
- elapsed_ms: Date.now() - started,
415
- timed_out: false,
416
- detail_root_count: roots.length
417
- };
418
- }
419
- } catch (error) {
420
- lastError = error?.message || String(error);
421
- }
422
- }
423
- await sleep(intervalMs);
424
- }
425
- return {
426
- ...(lastDiscovery || { controls: [], summary: summarizeRecommendActionControls([]) }),
427
- elapsed_ms: Date.now() - started,
428
- timed_out: true,
429
- detail_root_count: lastRootCount,
430
- last_error: lastError
431
- };
432
- }
433
-
434
- export async function clickRecommendActionControl(client, control, {
435
- allowDisabled = false
436
- } = {}) {
437
- let clickCenter = control?.center || null;
438
- let clickRect = control?.rect || null;
439
- if (control?.node_id) {
440
- try {
441
- await scrollNodeIntoView(client, control.node_id);
442
- await sleep(150);
443
- const box = await getNodeBox(client, control.node_id);
444
- clickCenter = box.center;
445
- clickRect = box.rect;
446
- } catch {
447
- // Fall back to the discovered center below; callers still get a clear
448
- // error if no usable click point exists.
449
- }
450
- }
451
- if (!clickCenter) {
452
- throw new Error("Action control has no clickable center");
453
- }
454
- const greetQuota = control.kind === "greet"
455
- ? assertGreetQuotaAvailable(control.greet_quota || control.label || "")
456
- : null;
457
- if (control.disabled && !allowDisabled) {
458
- throw new Error(`Action control is disabled: ${control.kind}`);
459
- }
460
- await clickPoint(client, clickCenter.x, clickCenter.y);
461
- return {
462
- clicked: true,
463
- kind: control.kind,
464
- label: control.label,
465
- greet_quota: greetQuota?.found ? greetQuota : null,
466
- selector: control.selector,
467
- root: control.root,
468
- node_id: control.node_id,
469
- center: clickCenter,
470
- rect: clickRect
471
- };
472
- }
1
+ import {
2
+ clickPoint,
3
+ getFrameDocumentNodeId,
4
+ getAttributesMap,
5
+ getNodeBox,
6
+ getOuterHTML,
7
+ querySelectorAll,
8
+ scrollNodeIntoView,
9
+ sleep
10
+ } from "../../core/browser/index.js";
11
+ import {
12
+ htmlToText,
13
+ normalizeText
14
+ } from "../../core/screening/index.js";
15
+ import {
16
+ assertGreetQuotaAvailable,
17
+ parseGreetQuota
18
+ } from "../../core/greet-quota/index.js";
19
+ import {
20
+ FAVORITE_BUTTON_SELECTORS,
21
+ GREET_BUTTON_RECOMMEND_SELECTORS
22
+ } from "./constants.js";
23
+ import { waitForRecommendDetail } from "./detail.js";
24
+ import { getRecommendRoots } from "./roots.js";
25
+
26
+ const POST_ACTIONS = new Set(["none", "greet"]);
27
+ const GREET_EXACT_LABEL_PATTERN = /^(?:打招呼|聊一聊|立即沟通(?:[\((]\d+\s*[//]\s*\d+[\))])?|沟通)$/i;
28
+ export const RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS = Object.freeze([
29
+ "button",
30
+ ".btn",
31
+ '[role="button"]',
32
+ "a",
33
+ "span",
34
+ "div"
35
+ ]);
36
+
37
+ function uniqueSelectors(...selectorGroups) {
38
+ return [...new Set(selectorGroups.flat().filter(Boolean))];
39
+ }
40
+
41
+ function uniqueByNode(candidates = []) {
42
+ const seen = new Set();
43
+ const result = [];
44
+ for (const item of candidates) {
45
+ const key = `${item.kind}:${item.root}:${item.node_id}`;
46
+ if (seen.has(key)) continue;
47
+ seen.add(key);
48
+ result.push(item);
49
+ }
50
+ return result;
51
+ }
52
+
53
+ function lowerText(...parts) {
54
+ return normalizeText(parts.filter(Boolean).join(" ")).toLowerCase();
55
+ }
56
+
57
+ function hasActiveClass(text) {
58
+ return /(?:^|\s)(?:active|curr|current|selected|checked)(?:\s|$)/i.test(text);
59
+ }
60
+
61
+ function hasDisabledSignal(text) {
62
+ return /(?:^|\s)(?:disabled|disable|forbidden|is-disabled)(?:\s|$)/i.test(text);
63
+ }
64
+
65
+ function rectArea(control) {
66
+ const rect = control?.rect || {};
67
+ return Math.max(0, Number(rect.width) || 0) * Math.max(0, Number(rect.height) || 0);
68
+ }
69
+
70
+ function isCompactLabel(control, limit = 80) {
71
+ const label = normalizeText(control?.label || "");
72
+ return label.length > 0 && label.length <= limit;
73
+ }
74
+
75
+ function controlRank(control, exactLabelPattern) {
76
+ const label = normalizeText(control?.label || "");
77
+ const selector = String(control?.selector || "");
78
+ const className = String(control?.class_name || "");
79
+ let score = 0;
80
+ if (exactLabelPattern.test(label)) score -= 1000;
81
+ if (/button|\[role=|\.btn/.test(selector) || /btn|button/i.test(className)) score -= 250;
82
+ if (selector === "div") score += 300;
83
+ if (!isCompactLabel(control)) score += 500;
84
+ score += Math.min(rectArea(control), 100000) / 1000;
85
+ score += label.length / 10;
86
+ return score;
87
+ }
88
+
89
+ function bestControl(controls, exactLabelPattern) {
90
+ return [...controls].sort((left, right) => (
91
+ controlRank(left, exactLabelPattern) - controlRank(right, exactLabelPattern)
92
+ ))[0] || null;
93
+ }
94
+
95
+ export function normalizeRecommendPostAction(value) {
96
+ const normalized = normalizeText(value).toLowerCase();
97
+ if (["", "none", "skip", "no", "不执行", "无"].includes(normalized)) return "none";
98
+ if (["greet", "chat", "打招呼", "直接沟通", "沟通"].includes(normalized)) return "greet";
99
+ return POST_ACTIONS.has(normalized) ? normalized : "";
100
+ }
101
+
102
+ export function resolveRecommendPostAction({
103
+ postAction = "none",
104
+ greetCount = 0,
105
+ maxGreetCount = null
106
+ } = {}) {
107
+ const requested = normalizeRecommendPostAction(postAction) || "none";
108
+ const currentGreetCount = Number.isInteger(greetCount) && greetCount >= 0 ? greetCount : 0;
109
+ const limit = Number.isInteger(maxGreetCount) && maxGreetCount > 0 ? maxGreetCount : null;
110
+ if (requested === "greet" && limit !== null && currentGreetCount >= limit) {
111
+ return {
112
+ requested,
113
+ effective: "none",
114
+ reason: "greet_limit_reached",
115
+ greet_count: currentGreetCount,
116
+ max_greet_count: limit
117
+ };
118
+ }
119
+ return {
120
+ requested,
121
+ effective: requested,
122
+ reason: "requested_action",
123
+ greet_count: currentGreetCount,
124
+ max_greet_count: limit
125
+ };
126
+ }
127
+
128
+ export function classifyFavoriteControl({
129
+ outerHTML = "",
130
+ attributes = {}
131
+ } = {}) {
132
+ const label = htmlToText(outerHTML);
133
+ const labelText = normalizeText(label);
134
+ const className = normalizeText(attributes.class || "");
135
+ const title = normalizeText(attributes.title || attributes["aria-label"] || "");
136
+ const combined = lowerText(className, title);
137
+ const labelMatches = /^(?:收藏|已收藏|感兴趣|已感兴趣)$/.test(labelText);
138
+ const classMatches = /favorite|collect|interest|like/.test(combined);
139
+ const matches = labelMatches || classMatches;
140
+ const active = (
141
+ /已收藏|已感兴趣/.test(label)
142
+ || /like-icon-active|favorite-active|collect-active/i.test(outerHTML)
143
+ || hasActiveClass(className)
144
+ );
145
+ const disabled = (
146
+ Object.prototype.hasOwnProperty.call(attributes, "disabled")
147
+ || hasDisabledSignal(className)
148
+ || /disabled/i.test(outerHTML)
149
+ );
150
+ return {
151
+ kind: "favorite",
152
+ matches,
153
+ active,
154
+ disabled,
155
+ label: label || title || null,
156
+ class_name: className || null
157
+ };
158
+ }
159
+
160
+ export function classifyGreetControl({
161
+ outerHTML = "",
162
+ attributes = {}
163
+ } = {}) {
164
+ const label = htmlToText(outerHTML);
165
+ const labelText = normalizeText(label);
166
+ const className = normalizeText(attributes.class || "");
167
+ const title = normalizeText(attributes.title || attributes["aria-label"] || "");
168
+ const combined = lowerText(className, title);
169
+ const continueChat = labelText.length <= 40 && /继续沟通/.test(labelText);
170
+ const greetQuota = parseGreetQuota(labelText || title);
171
+ const greetEntry = (
172
+ GREET_EXACT_LABEL_PATTERN.test(labelText)
173
+ || greetQuota.found
174
+ || /greet/i.test(combined)
175
+ );
176
+ const disabled = (
177
+ Object.prototype.hasOwnProperty.call(attributes, "disabled")
178
+ || hasDisabledSignal(className)
179
+ || /disabled/i.test(outerHTML)
180
+ );
181
+ return {
182
+ kind: "greet",
183
+ matches: greetEntry || continueChat,
184
+ available: greetEntry && !continueChat && !disabled,
185
+ continue_chat: continueChat,
186
+ disabled,
187
+ label: label || title || null,
188
+ greet_quota: greetQuota.found ? greetQuota : null,
189
+ class_name: className || null
190
+ };
191
+ }
192
+
193
+ async function readActionNode(client, {
194
+ root,
195
+ selector,
196
+ nodeId,
197
+ kind
198
+ }) {
199
+ const [attributes, outerHTML] = await Promise.all([
200
+ getAttributesMap(client, nodeId),
201
+ getOuterHTML(client, nodeId)
202
+ ]);
203
+ let box = null;
204
+ let visible = false;
205
+ try {
206
+ box = await getNodeBox(client, nodeId);
207
+ visible = box.rect.width > 2 && box.rect.height > 2;
208
+ } catch {}
209
+ const classification = kind === "favorite"
210
+ ? classifyFavoriteControl({ outerHTML, attributes })
211
+ : classifyGreetControl({ outerHTML, attributes });
212
+ return {
213
+ kind,
214
+ root: root.name,
215
+ root_node_id: root.nodeId,
216
+ selector,
217
+ node_id: nodeId,
218
+ visible,
219
+ center: box?.center || null,
220
+ rect: box?.rect || null,
221
+ attributes,
222
+ outer_html_length: outerHTML.length,
223
+ html_preview: outerHTML.slice(0, 500),
224
+ ...classification
225
+ };
226
+ }
227
+
228
+ export async function collectRecommendActionControls(client, roots, {
229
+ favoriteSelectors = FAVORITE_BUTTON_SELECTORS,
230
+ greetSelectors = GREET_BUTTON_RECOMMEND_SELECTORS,
231
+ detailTextFallback = false
232
+ } = {}) {
233
+ const candidates = [];
234
+ const favoriteScanSelectors = detailTextFallback
235
+ ? uniqueSelectors(favoriteSelectors, RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS)
236
+ : favoriteSelectors;
237
+ const greetScanSelectors = detailTextFallback
238
+ ? uniqueSelectors(greetSelectors, RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS)
239
+ : greetSelectors;
240
+ for (const root of roots) {
241
+ if (!root?.nodeId) continue;
242
+ for (const [kind, selectors] of [
243
+ ["favorite", favoriteScanSelectors],
244
+ ["greet", greetScanSelectors]
245
+ ]) {
246
+ for (const selector of selectors) {
247
+ const nodeIds = await querySelectorAll(client, root.nodeId, selector);
248
+ for (const nodeId of nodeIds) {
249
+ candidates.push(await readActionNode(client, {
250
+ root,
251
+ selector,
252
+ nodeId,
253
+ kind
254
+ }));
255
+ }
256
+ }
257
+ }
258
+ }
259
+ return uniqueByNode(candidates);
260
+ }
261
+
262
+ export function summarizeRecommendActionControls(controls = []) {
263
+ const visibleControls = controls.filter((item) => item.visible && item.matches);
264
+ const favoriteControls = visibleControls.filter((item) => item.kind === "favorite");
265
+ const greetControls = visibleControls.filter((item) => item.kind === "greet");
266
+ const favorite = bestControl(
267
+ favoriteControls.filter((item) => item.matches),
268
+ /^(?:收藏|已收藏|感兴趣|已感兴趣)$/i
269
+ );
270
+ const greet = bestControl(
271
+ greetControls.filter((item) => item.available),
272
+ GREET_EXACT_LABEL_PATTERN
273
+ ) || bestControl(
274
+ greetControls.filter((item) => item.continue_chat),
275
+ /^继续沟通$/i
276
+ );
277
+ return {
278
+ favorite: favorite
279
+ ? {
280
+ found: true,
281
+ active: favorite.active,
282
+ disabled: favorite.disabled,
283
+ label: favorite.label,
284
+ selector: favorite.selector,
285
+ root: favorite.root,
286
+ node_id: favorite.node_id,
287
+ center: favorite.center
288
+ }
289
+ : { found: false },
290
+ greet: greet
291
+ ? {
292
+ found: true,
293
+ available: greet.available,
294
+ continue_chat: greet.continue_chat,
295
+ disabled: greet.disabled,
296
+ label: greet.label,
297
+ greet_quota: greet.greet_quota || null,
298
+ selector: greet.selector,
299
+ root: greet.root,
300
+ node_id: greet.node_id,
301
+ center: greet.center
302
+ }
303
+ : { found: false },
304
+ counts: {
305
+ total: controls.length,
306
+ visible_matching: visibleControls.length,
307
+ favorite: favoriteControls.length,
308
+ greet: greetControls.length
309
+ }
310
+ };
311
+ }
312
+
313
+ export async function discoverRecommendActionControls(client, {
314
+ roots = null,
315
+ selectors = {},
316
+ detailTextFallback = false
317
+ } = {}) {
318
+ const rootState = roots ? { roots } : await getRecommendRoots(client);
319
+ const controls = await collectRecommendActionControls(client, rootState.roots, {
320
+ ...selectors,
321
+ detailTextFallback
322
+ });
323
+ return {
324
+ controls,
325
+ summary: summarizeRecommendActionControls(controls)
326
+ };
327
+ }
328
+
329
+ export async function waitForRecommendActionControls(client, {
330
+ timeoutMs = 6000,
331
+ intervalMs = 250,
332
+ requireAny = true,
333
+ ...discoveryOptions
334
+ } = {}) {
335
+ const started = Date.now();
336
+ let lastDiscovery = null;
337
+ while (Date.now() - started <= timeoutMs) {
338
+ lastDiscovery = await discoverRecommendActionControls(client, discoveryOptions);
339
+ const hasControl = Boolean(
340
+ lastDiscovery.summary.favorite.found
341
+ || lastDiscovery.summary.greet.found
342
+ );
343
+ if (!requireAny || hasControl) {
344
+ return {
345
+ ...lastDiscovery,
346
+ elapsed_ms: Date.now() - started,
347
+ timed_out: false
348
+ };
349
+ }
350
+ await sleep(intervalMs);
351
+ }
352
+ return {
353
+ ...(lastDiscovery || { controls: [], summary: summarizeRecommendActionControls([]) }),
354
+ elapsed_ms: Date.now() - started,
355
+ timed_out: true
356
+ };
357
+ }
358
+
359
+ export async function getRecommendDetailActionRoots(client, detailState) {
360
+ const roots = [];
361
+ if (detailState?.popup?.node_id) {
362
+ roots.push({
363
+ name: `${detailState.popup.root || "unknown"}:detail-popup`,
364
+ nodeId: detailState.popup.node_id
365
+ });
366
+ }
367
+ if (detailState?.resumeIframe?.node_id) {
368
+ try {
369
+ roots.push({
370
+ name: `${detailState.resumeIframe.root || "unknown"}:resume-iframe-document`,
371
+ nodeId: await getFrameDocumentNodeId(client, detailState.resumeIframe.node_id)
372
+ });
373
+ } catch {
374
+ roots.push({
375
+ name: `${detailState.resumeIframe.root || "unknown"}:resume-iframe-node`,
376
+ nodeId: detailState.resumeIframe.node_id
377
+ });
378
+ }
379
+ }
380
+ return roots;
381
+ }
382
+
383
+ export async function waitForRecommendDetailActionControls(client, {
384
+ timeoutMs = 8000,
385
+ intervalMs = 350,
386
+ selectors = {},
387
+ requireAny = true
388
+ } = {}) {
389
+ const started = Date.now();
390
+ let lastDiscovery = null;
391
+ let lastError = null;
392
+ let lastRootCount = 0;
393
+ while (Date.now() - started <= timeoutMs) {
394
+ const detailState = await waitForRecommendDetail(client, {
395
+ timeoutMs: Math.min(intervalMs, 500),
396
+ intervalMs: 100
397
+ });
398
+ const roots = await getRecommendDetailActionRoots(client, detailState);
399
+ lastRootCount = roots.length;
400
+ if (roots.length) {
401
+ try {
402
+ lastDiscovery = await discoverRecommendActionControls(client, {
403
+ roots,
404
+ selectors,
405
+ detailTextFallback: true
406
+ });
407
+ const hasControl = Boolean(
408
+ lastDiscovery.summary.favorite.found
409
+ || lastDiscovery.summary.greet.found
410
+ );
411
+ if (!requireAny || hasControl) {
412
+ return {
413
+ ...lastDiscovery,
414
+ elapsed_ms: Date.now() - started,
415
+ timed_out: false,
416
+ detail_root_count: roots.length
417
+ };
418
+ }
419
+ } catch (error) {
420
+ lastError = error?.message || String(error);
421
+ }
422
+ }
423
+ await sleep(intervalMs);
424
+ }
425
+ return {
426
+ ...(lastDiscovery || { controls: [], summary: summarizeRecommendActionControls([]) }),
427
+ elapsed_ms: Date.now() - started,
428
+ timed_out: true,
429
+ detail_root_count: lastRootCount,
430
+ last_error: lastError
431
+ };
432
+ }
433
+
434
+ export async function clickRecommendActionControl(client, control, {
435
+ allowDisabled = false
436
+ } = {}) {
437
+ let clickCenter = control?.center || null;
438
+ let clickRect = control?.rect || null;
439
+ if (control?.node_id) {
440
+ try {
441
+ await scrollNodeIntoView(client, control.node_id);
442
+ await sleep(150);
443
+ const box = await getNodeBox(client, control.node_id);
444
+ clickCenter = box.center;
445
+ clickRect = box.rect;
446
+ } catch {
447
+ // Fall back to the discovered center below; callers still get a clear
448
+ // error if no usable click point exists.
449
+ }
450
+ }
451
+ if (!clickCenter) {
452
+ throw new Error("Action control has no clickable center");
453
+ }
454
+ const greetQuota = control.kind === "greet"
455
+ ? assertGreetQuotaAvailable(control.greet_quota || control.label || "")
456
+ : null;
457
+ if (control.disabled && !allowDisabled) {
458
+ throw new Error(`Action control is disabled: ${control.kind}`);
459
+ }
460
+ await clickPoint(client, clickCenter.x, clickCenter.y);
461
+ return {
462
+ clicked: true,
463
+ kind: control.kind,
464
+ label: control.label,
465
+ greet_quota: greetQuota?.found ? greetQuota : null,
466
+ selector: control.selector,
467
+ root: control.root,
468
+ node_id: control.node_id,
469
+ center: clickCenter,
470
+ rect: clickRect
471
+ };
472
+ }