@reconcrap/boss-recommend-mcp 1.3.38 → 2.0.0

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 (85) hide show
  1. package/README.md +53 -33
  2. package/package.json +61 -9
  3. package/skills/boss-recommend-pipeline/SKILL.md +4 -0
  4. package/src/chat-mcp.js +1333 -0
  5. package/src/chat-runtime-config.js +559 -0
  6. package/src/cli.js +1095 -196
  7. package/src/core/browser/index.js +378 -0
  8. package/src/core/capture/index.js +298 -0
  9. package/src/core/cv-acquisition/index.js +219 -0
  10. package/src/core/greet-quota/index.js +54 -0
  11. package/src/core/infinite-list/index.js +459 -0
  12. package/src/core/reporting/legacy-csv.js +332 -0
  13. package/src/core/run/index.js +286 -0
  14. package/src/core/screening/index.js +1166 -0
  15. package/src/core/self-heal/index.js +848 -0
  16. package/src/domains/chat/cards.js +129 -0
  17. package/src/domains/chat/constants.js +183 -0
  18. package/src/domains/chat/detail.js +1369 -0
  19. package/src/domains/chat/index.js +7 -0
  20. package/src/domains/chat/jobs.js +334 -0
  21. package/src/domains/chat/page-guard.js +88 -0
  22. package/src/domains/chat/roots.js +56 -0
  23. package/src/domains/chat/run-service.js +1101 -0
  24. package/src/domains/recommend/actions.js +457 -0
  25. package/src/domains/recommend/cards.js +228 -0
  26. package/src/domains/recommend/constants.js +141 -0
  27. package/src/domains/recommend/detail.js +341 -0
  28. package/src/domains/recommend/filters.js +581 -0
  29. package/src/domains/recommend/index.js +10 -0
  30. package/src/domains/recommend/jobs.js +232 -0
  31. package/src/domains/recommend/refresh.js +204 -0
  32. package/src/domains/recommend/roots.js +78 -0
  33. package/src/domains/recommend/run-service.js +903 -0
  34. package/src/domains/recommend/scopes.js +245 -0
  35. package/src/domains/recruit/actions.js +277 -0
  36. package/src/domains/recruit/cards.js +67 -0
  37. package/src/domains/recruit/constants.js +130 -0
  38. package/src/domains/recruit/detail.js +414 -0
  39. package/src/domains/recruit/index.js +9 -0
  40. package/src/domains/recruit/instruction-parser.js +451 -0
  41. package/src/domains/recruit/refresh.js +40 -0
  42. package/src/domains/recruit/roots.js +68 -0
  43. package/src/domains/recruit/run-service.js +580 -0
  44. package/src/domains/recruit/search.js +1149 -0
  45. package/src/index.js +578 -419
  46. package/src/recommend-mcp.js +1257 -0
  47. package/src/recruit-mcp.js +1035 -0
  48. package/src/adapters.js +0 -3079
  49. package/src/boss-chat.js +0 -1037
  50. package/src/pipeline.js +0 -2249
  51. package/src/recommend-healing-config.js +0 -131
  52. package/src/recommend-healing-rules.json +0 -261
  53. package/src/self-heal.js +0 -2237
  54. package/src/test-adapters-runtime.js +0 -628
  55. package/src/test-boss-chat.js +0 -3196
  56. package/src/test-index-async.js +0 -498
  57. package/src/test-parser.js +0 -742
  58. package/src/test-pipeline.js +0 -2703
  59. package/src/test-run-state.js +0 -152
  60. package/src/test-self-heal.js +0 -224
  61. package/vendor/boss-chat-cli/README.md +0 -134
  62. package/vendor/boss-chat-cli/package.json +0 -53
  63. package/vendor/boss-chat-cli/src/app.js +0 -1501
  64. package/vendor/boss-chat-cli/src/browser/chat-page.js +0 -3562
  65. package/vendor/boss-chat-cli/src/cli.js +0 -1713
  66. package/vendor/boss-chat-cli/src/mcp/server.js +0 -149
  67. package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +0 -193
  68. package/vendor/boss-chat-cli/src/runtime/async-run-state.js +0 -260
  69. package/vendor/boss-chat-cli/src/runtime/interaction.js +0 -102
  70. package/vendor/boss-chat-cli/src/runtime/run-control.js +0 -102
  71. package/vendor/boss-chat-cli/src/services/chrome-client.js +0 -107
  72. package/vendor/boss-chat-cli/src/services/llm.js +0 -1292
  73. package/vendor/boss-chat-cli/src/services/llm.test.js +0 -326
  74. package/vendor/boss-chat-cli/src/services/profile-store.js +0 -173
  75. package/vendor/boss-chat-cli/src/services/report-store.js +0 -317
  76. package/vendor/boss-chat-cli/src/services/resume-capture.js +0 -469
  77. package/vendor/boss-chat-cli/src/services/resume-network.js +0 -727
  78. package/vendor/boss-chat-cli/src/services/state-store.js +0 -90
  79. package/vendor/boss-chat-cli/src/utils/customer-key.js +0 -82
  80. package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +0 -6927
  81. package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +0 -817
  82. package/vendor/boss-recommend-screen-cli/scripts/stitch_resume_chunks.py +0 -141
  83. package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +0 -2294
  84. package/vendor/boss-recommend-search-cli/src/cli.js +0 -1698
  85. package/vendor/boss-recommend-search-cli/src/test-job-selection.js +0 -211
@@ -0,0 +1,457 @@
1
+ import {
2
+ clickPoint,
3
+ getFrameDocumentNodeId,
4
+ getAttributesMap,
5
+ getNodeBox,
6
+ getOuterHTML,
7
+ querySelectorAll,
8
+ sleep
9
+ } from "../../core/browser/index.js";
10
+ import {
11
+ htmlToText,
12
+ normalizeText
13
+ } from "../../core/screening/index.js";
14
+ import {
15
+ assertGreetQuotaAvailable,
16
+ parseGreetQuota
17
+ } from "../../core/greet-quota/index.js";
18
+ import {
19
+ FAVORITE_BUTTON_SELECTORS,
20
+ GREET_BUTTON_RECOMMEND_SELECTORS
21
+ } from "./constants.js";
22
+ import { waitForRecommendDetail } from "./detail.js";
23
+ import { getRecommendRoots } from "./roots.js";
24
+
25
+ const POST_ACTIONS = new Set(["none", "favorite", "greet"]);
26
+ const GREET_EXACT_LABEL_PATTERN = /^(?:打招呼|聊一聊|立即沟通(?:[\((]\d+\s*[//]\s*\d+[\))])?|沟通)$/i;
27
+ export const RECOMMEND_DETAIL_ACTION_TEXT_SELECTORS = Object.freeze([
28
+ "button",
29
+ ".btn",
30
+ '[role="button"]',
31
+ "a",
32
+ "span",
33
+ "div"
34
+ ]);
35
+
36
+ function uniqueSelectors(...selectorGroups) {
37
+ return [...new Set(selectorGroups.flat().filter(Boolean))];
38
+ }
39
+
40
+ function uniqueByNode(candidates = []) {
41
+ const seen = new Set();
42
+ const result = [];
43
+ for (const item of candidates) {
44
+ const key = `${item.kind}:${item.root}:${item.node_id}`;
45
+ if (seen.has(key)) continue;
46
+ seen.add(key);
47
+ result.push(item);
48
+ }
49
+ return result;
50
+ }
51
+
52
+ function lowerText(...parts) {
53
+ return normalizeText(parts.filter(Boolean).join(" ")).toLowerCase();
54
+ }
55
+
56
+ function hasActiveClass(text) {
57
+ return /(?:^|\s)(?:active|curr|current|selected|checked)(?:\s|$)/i.test(text);
58
+ }
59
+
60
+ function hasDisabledSignal(text) {
61
+ return /(?:^|\s)(?:disabled|disable|forbidden|is-disabled)(?:\s|$)/i.test(text);
62
+ }
63
+
64
+ function rectArea(control) {
65
+ const rect = control?.rect || {};
66
+ return Math.max(0, Number(rect.width) || 0) * Math.max(0, Number(rect.height) || 0);
67
+ }
68
+
69
+ function isCompactLabel(control, limit = 80) {
70
+ const label = normalizeText(control?.label || "");
71
+ return label.length > 0 && label.length <= limit;
72
+ }
73
+
74
+ function controlRank(control, exactLabelPattern) {
75
+ const label = normalizeText(control?.label || "");
76
+ const selector = String(control?.selector || "");
77
+ const className = String(control?.class_name || "");
78
+ let score = 0;
79
+ if (exactLabelPattern.test(label)) score -= 1000;
80
+ if (/button|\[role=|\.btn/.test(selector) || /btn|button/i.test(className)) score -= 250;
81
+ if (selector === "div") score += 300;
82
+ if (!isCompactLabel(control)) score += 500;
83
+ score += Math.min(rectArea(control), 100000) / 1000;
84
+ score += label.length / 10;
85
+ return score;
86
+ }
87
+
88
+ function bestControl(controls, exactLabelPattern) {
89
+ return [...controls].sort((left, right) => (
90
+ controlRank(left, exactLabelPattern) - controlRank(right, exactLabelPattern)
91
+ ))[0] || null;
92
+ }
93
+
94
+ export function normalizeRecommendPostAction(value) {
95
+ const normalized = normalizeText(value).toLowerCase();
96
+ if (["", "none", "skip", "no", "不执行", "无"].includes(normalized)) return "none";
97
+ if (["favorite", "fav", "collect", "收藏", "感兴趣"].includes(normalized)) return "favorite";
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: "favorite",
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
+ if (!control?.center) {
438
+ throw new Error("Action control has no clickable center");
439
+ }
440
+ const greetQuota = control.kind === "greet"
441
+ ? assertGreetQuotaAvailable(control.greet_quota || control.label || "")
442
+ : null;
443
+ if (control.disabled && !allowDisabled) {
444
+ throw new Error(`Action control is disabled: ${control.kind}`);
445
+ }
446
+ await clickPoint(client, control.center.x, control.center.y);
447
+ return {
448
+ clicked: true,
449
+ kind: control.kind,
450
+ label: control.label,
451
+ greet_quota: greetQuota?.found ? greetQuota : null,
452
+ selector: control.selector,
453
+ root: control.root,
454
+ node_id: control.node_id,
455
+ center: control.center
456
+ };
457
+ }
@@ -0,0 +1,228 @@
1
+ import {
2
+ clickNodeCenter,
3
+ getAttributesMap,
4
+ getNodeBox,
5
+ getOuterHTML,
6
+ querySelectorAll,
7
+ sleep
8
+ } from "../../core/browser/index.js";
9
+ import {
10
+ htmlToText,
11
+ normalizeCandidateFromHtml,
12
+ normalizeText
13
+ } from "../../core/screening/index.js";
14
+ import {
15
+ RECOMMEND_CARD_SELECTOR,
16
+ RECOMMEND_END_REFRESH_SELECTOR
17
+ } from "./constants.js";
18
+
19
+ function uniqueNodeIds(nodeIds = []) {
20
+ return Array.from(new Set(nodeIds.filter(Boolean)));
21
+ }
22
+
23
+ function normalizeRefreshButtonLabel(outerHTML = "") {
24
+ return normalizeText(htmlToText(outerHTML)).replace(/\s+/g, "");
25
+ }
26
+
27
+ function isRefreshButtonLabel(label = "") {
28
+ const normalized = String(label || "").trim();
29
+ if (!normalized || normalized.length > 80) return false;
30
+ return /刷新|refresh/i.test(normalized);
31
+ }
32
+
33
+ function refreshButtonRank(candidate) {
34
+ const label = String(candidate.label || "").toLowerCase();
35
+ if (label === "刷新" || label === "refresh") return 0;
36
+ if (/^刷新$|^refresh$/i.test(label)) return 0;
37
+ if (/刷新/.test(label) || /refresh/i.test(label)) return 1;
38
+ return 2;
39
+ }
40
+
41
+ async function searchTextNodeIds(client, query, {
42
+ maxResults = 200
43
+ } = {}) {
44
+ if (typeof client?.DOM?.performSearch !== "function") return [];
45
+ const search = await client.DOM.performSearch({
46
+ query,
47
+ includeUserAgentShadowDOM: false
48
+ });
49
+ const searchId = search.searchId;
50
+ const resultCount = Math.min(search.resultCount || 0, maxResults);
51
+ if (!searchId || resultCount <= 0) return [];
52
+ try {
53
+ const results = await client.DOM.getSearchResults({
54
+ searchId,
55
+ fromIndex: 0,
56
+ toIndex: resultCount
57
+ });
58
+ return results.nodeIds || [];
59
+ } finally {
60
+ await client.DOM.discardSearchResults({ searchId });
61
+ }
62
+ }
63
+
64
+ export async function findRecommendCardNodeIds(client, frameNodeId, {
65
+ selector = RECOMMEND_CARD_SELECTOR
66
+ } = {}) {
67
+ return querySelectorAll(client, frameNodeId, selector);
68
+ }
69
+
70
+ export async function waitForRecommendCardNodeIds(client, frameNodeId, {
71
+ selector = RECOMMEND_CARD_SELECTOR,
72
+ timeoutMs = 10000,
73
+ intervalMs = 300
74
+ } = {}) {
75
+ const started = Date.now();
76
+ let nodeIds = [];
77
+ while (Date.now() - started <= timeoutMs) {
78
+ nodeIds = await findRecommendCardNodeIds(client, frameNodeId, { selector });
79
+ if (nodeIds.length) return nodeIds;
80
+ await sleep(intervalMs);
81
+ }
82
+ return nodeIds;
83
+ }
84
+
85
+ export async function readRecommendCardCandidate(client, cardNodeId, {
86
+ targetUrl = "",
87
+ source = "recommend-domain-card",
88
+ metadata = {}
89
+ } = {}) {
90
+ const [attributes, outerHTML] = await Promise.all([
91
+ getAttributesMap(client, cardNodeId),
92
+ getOuterHTML(client, cardNodeId)
93
+ ]);
94
+ return normalizeCandidateFromHtml({
95
+ domain: "recommend",
96
+ source,
97
+ html: outerHTML,
98
+ attributes,
99
+ metadata: {
100
+ target_url: targetUrl,
101
+ card_node_id: cardNodeId,
102
+ ...metadata
103
+ }
104
+ });
105
+ }
106
+
107
+ export async function readFirstRecommendCardCandidate(client, frameNodeId, options = {}) {
108
+ const cardNodeIds = await findRecommendCardNodeIds(client, frameNodeId, options);
109
+ if (!cardNodeIds.length) {
110
+ throw new Error("No recommend candidate cards found");
111
+ }
112
+
113
+ const candidate = await readRecommendCardCandidate(client, cardNodeIds[0], options);
114
+ return {
115
+ card_count: cardNodeIds.length,
116
+ first_card_node_id: cardNodeIds[0],
117
+ card_node_ids: cardNodeIds,
118
+ candidate
119
+ };
120
+ }
121
+
122
+ export async function findRecommendEndRefreshButtons(client, frameNodeId, {
123
+ selector = RECOMMEND_END_REFRESH_SELECTOR,
124
+ maxCandidates = 1200
125
+ } = {}) {
126
+ const textNodeIds = [
127
+ ...await searchTextNodeIds(client, "刷新", { maxResults: 200 }),
128
+ ...await searchTextNodeIds(client, "refresh", { maxResults: 50 })
129
+ ];
130
+ const selectorNodeIds = textNodeIds.length
131
+ ? await querySelectorAll(client, frameNodeId, selector)
132
+ : [];
133
+ const nodeIds = uniqueNodeIds([...textNodeIds, ...selectorNodeIds]).slice(0, maxCandidates);
134
+ const candidates = [];
135
+ for (let index = 0; index < nodeIds.length; index += 1) {
136
+ const nodeId = nodeIds[index];
137
+ let outerHTML = "";
138
+ try {
139
+ outerHTML = await getOuterHTML(client, nodeId);
140
+ } catch {
141
+ continue;
142
+ }
143
+ const label = normalizeRefreshButtonLabel(outerHTML);
144
+ if (!isRefreshButtonLabel(label)) continue;
145
+
146
+ let box = null;
147
+ try {
148
+ box = await getNodeBox(client, nodeId);
149
+ } catch {
150
+ // Some text matches can be hidden or stale. Keep the label out of the click set.
151
+ continue;
152
+ }
153
+ candidates.push({
154
+ node_id: nodeId,
155
+ index,
156
+ label,
157
+ box,
158
+ rank: refreshButtonRank({ label })
159
+ });
160
+ }
161
+
162
+ return candidates.sort((left, right) => {
163
+ const rankDiff = left.rank - right.rank;
164
+ if (rankDiff !== 0) return rankDiff;
165
+ return (right.box?.rect?.y || 0) - (left.box?.rect?.y || 0);
166
+ });
167
+ }
168
+
169
+ export async function clickRecommendEndRefreshButton(client, frameNodeId, {
170
+ settleMs = 5000
171
+ } = {}) {
172
+ const beforeCardCount = (await findRecommendCardNodeIds(client, frameNodeId)).length;
173
+ const candidates = await findRecommendEndRefreshButtons(client, frameNodeId);
174
+ if (!candidates.length) {
175
+ return {
176
+ ok: false,
177
+ method: "end_refresh_button",
178
+ reason: "refresh_button_not_found",
179
+ before_card_count: beforeCardCount,
180
+ candidates: []
181
+ };
182
+ }
183
+
184
+ const attempts = [];
185
+ for (const candidate of candidates) {
186
+ try {
187
+ const box = await clickNodeCenter(client, candidate.node_id, { scrollIntoView: true });
188
+ if (settleMs > 0) await sleep(settleMs);
189
+ const afterCardCount = (await findRecommendCardNodeIds(client, frameNodeId)).length;
190
+ return {
191
+ ok: true,
192
+ method: "end_refresh_button",
193
+ clicked: true,
194
+ node_id: candidate.node_id,
195
+ label: candidate.label,
196
+ box,
197
+ before_card_count: beforeCardCount,
198
+ after_card_count: afterCardCount,
199
+ settle_ms: settleMs,
200
+ candidates: candidates.map((item) => ({
201
+ node_id: item.node_id,
202
+ label: item.label,
203
+ y: item.box?.rect?.y || null
204
+ })).slice(0, 10),
205
+ attempts
206
+ };
207
+ } catch (error) {
208
+ attempts.push({
209
+ node_id: candidate.node_id,
210
+ label: candidate.label,
211
+ error: error?.message || String(error)
212
+ });
213
+ }
214
+ }
215
+
216
+ return {
217
+ ok: false,
218
+ method: "end_refresh_button",
219
+ reason: "refresh_button_click_failed",
220
+ before_card_count: beforeCardCount,
221
+ attempts,
222
+ candidates: candidates.map((item) => ({
223
+ node_id: item.node_id,
224
+ label: item.label,
225
+ y: item.box?.rect?.y || null
226
+ })).slice(0, 10)
227
+ };
228
+ }