@reconcrap/boss-recommend-mcp 2.1.24 → 2.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,10 @@
1
- import { sleep } from "../../core/browser/index.js";
1
+ import {
2
+ getNodeBox,
3
+ getOuterHTML,
4
+ querySelectorAll,
5
+ sleep
6
+ } from "../../core/browser/index.js";
7
+ import { htmlToText, normalizeText } from "../../core/screening/index.js";
2
8
  import {
3
9
  buildRecommendSelfHealConfig,
4
10
  resolveRecommendSelfHealRoots
@@ -11,10 +17,11 @@ import {
11
17
  clickRecommendEndRefreshButton,
12
18
  waitForRecommendCardNodeIds
13
19
  } from "./cards.js";
14
- import {
15
- RECOMMEND_RECENT_NOT_VIEW_LABEL,
16
- RECOMMEND_TARGET_URL
17
- } from "./constants.js";
20
+ import {
21
+ RECOMMEND_BOTTOM_MARKER_SELECTORS,
22
+ RECOMMEND_RECENT_NOT_VIEW_LABEL,
23
+ RECOMMEND_TARGET_URL
24
+ } from "./constants.js";
18
25
  import { selectAndConfirmFirstSafeFilter } from "./filters.js";
19
26
  import { ensureRecommendCurrentCityOnly } from "./location.js";
20
27
  import {
@@ -52,38 +59,43 @@ export function buildRecommendFilterGroups(filter = {}, {
52
59
  ? filter.groups
53
60
  : [];
54
61
 
55
- for (const spec of sourceGroups) {
56
- const group = normalizeFilterGroup(spec);
57
- if (group.group || group.labels.length) groups.push(group);
58
- }
62
+ for (const spec of sourceGroups) {
63
+ const group = normalizeFilterGroup(spec);
64
+ if (group.group || group.labels.length) {
65
+ group.verifySticky = true;
66
+ groups.push(group);
67
+ }
68
+ }
59
69
 
60
70
  const rootGroup = normalizeFilterGroup(filter);
61
- if ((rootGroup.group || rootGroup.labels.length) && !groups.length) {
62
- groups.push(rootGroup);
63
- }
71
+ if ((rootGroup.group || rootGroup.labels.length) && !groups.length) {
72
+ rootGroup.verifySticky = true;
73
+ groups.push(rootGroup);
74
+ }
64
75
 
65
76
  if (forceRecentNotView) {
66
77
  const recentGroup = groups.find((item) => item.group === "recentNotView");
67
78
  if (recentGroup) {
68
79
  if (!recentGroup.labels.some((label) => label.replace(/\s+/g, "") === RECOMMEND_RECENT_NOT_VIEW_LABEL)) {
69
80
  recentGroup.labels.push(RECOMMEND_RECENT_NOT_VIEW_LABEL);
70
- }
71
- recentGroup.selectAllLabels = true;
72
- } else {
73
- groups.unshift({
74
- group: "recentNotView",
75
- labels: [RECOMMEND_RECENT_NOT_VIEW_LABEL],
76
- selectAllLabels: true,
77
- allowUnlimited: false,
78
- verifySticky: false
79
- });
81
+ }
82
+ recentGroup.selectAllLabels = true;
83
+ recentGroup.verifySticky = true;
84
+ } else {
85
+ groups.unshift({
86
+ group: "recentNotView",
87
+ labels: [RECOMMEND_RECENT_NOT_VIEW_LABEL],
88
+ selectAllLabels: true,
89
+ allowUnlimited: false,
90
+ verifySticky: true
91
+ });
80
92
  }
81
93
  }
82
94
 
83
95
  return groups;
84
96
  }
85
97
 
86
- export function buildRecommendFilterSelectionOptions(filter = {}, {
98
+ export function buildRecommendFilterSelectionOptions(filter = {}, {
87
99
  forceRecentNotView = false
88
100
  } = {}) {
89
101
  const filterGroups = buildRecommendFilterGroups(filter, { forceRecentNotView });
@@ -134,9 +146,232 @@ export async function applyRecommendFilterEnvelopeStages(filter = {}, {
134
146
  };
135
147
  }
136
148
 
137
- function refreshFailureReason(method = "") {
138
- return method === "page_navigate" ? "page_navigate_failed" : "page_reload_failed";
139
- }
149
+ function refreshFailureReason(method = "") {
150
+ return method === "page_navigate" ? "page_navigate_failed" : "page_reload_failed";
151
+ }
152
+
153
+ export function isVerifiedRecommendFilterApplication(filterResult, filterOptions = {}) {
154
+ if (filterResult?.confirmed !== true) return false;
155
+ const requestedGroups = Array.isArray(filterOptions.filterGroups) && filterOptions.filterGroups.length
156
+ ? filterOptions.filterGroups
157
+ : (filterOptions.group || filterOptions.labels?.length ? [filterOptions] : []);
158
+ const normalizedRequested = requestedGroups
159
+ .filter((group) => group && String(group.group || "").trim() && Array.isArray(group.labels) && group.labels.length)
160
+ .map((group) => ({
161
+ group: String(group.group || "").trim(),
162
+ labels: [...new Set(normalizeLabels(group.labels).map((label) => label.replace(/\s+/g, "")))],
163
+ allowUnlimited: group.allowUnlimited === true,
164
+ selectAllLabels: group.selectAllLabels !== false
165
+ }));
166
+ if (!normalizedRequested.length) return true;
167
+ if (filterResult?.sticky_verification?.verified !== true) return false;
168
+ const verifiedGroups = Array.isArray(filterResult.sticky_verification.groups)
169
+ ? filterResult.sticky_verification.groups
170
+ : [];
171
+ if (verifiedGroups.length !== normalizedRequested.length) return false;
172
+ return normalizedRequested.every((requested) => {
173
+ const matches = verifiedGroups.filter((group) => String(group?.group || "") === requested.group);
174
+ if (matches.length !== 1 || matches[0]?.verified !== true) return false;
175
+ const verifiedLabels = normalizeLabels(matches[0].requested_labels || [])
176
+ .map((label) => label.replace(/\s+/g, ""));
177
+ const uniqueVerifiedLabels = [...new Set(verifiedLabels)];
178
+ if (
179
+ uniqueVerifiedLabels.length !== requested.labels.length
180
+ || !requested.labels.every((label) => uniqueVerifiedLabels.includes(label))
181
+ ) {
182
+ return false;
183
+ }
184
+ const activeLabels = [...new Set(normalizeLabels(matches[0].active_labels || [])
185
+ .map((label) => label.replace(/\s+/g, "")))];
186
+ if (matches[0].unavailable === true) {
187
+ return requested.allowUnlimited
188
+ && requested.labels.length === 1
189
+ && requested.labels[0] === "不限"
190
+ && activeLabels.length === 0;
191
+ }
192
+ if (!requested.selectAllLabels) {
193
+ return activeLabels.length === 1 && requested.labels.includes(activeLabels[0]);
194
+ }
195
+ return activeLabels.length === requested.labels.length
196
+ && requested.labels.every((label) => activeLabels.includes(label));
197
+ });
198
+ }
199
+
200
+ const RECOMMEND_FILTERED_EMPTY_TEXTS = Object.freeze([
201
+ "没有相关数据",
202
+ "暂无相关数据"
203
+ ]);
204
+
205
+ const RECOMMEND_FILTERED_EMPTY_TEXT_SCAN_SELECTORS = Object.freeze([
206
+ ...RECOMMEND_BOTTOM_MARKER_SELECTORS,
207
+ "[class*=\"empty\"]",
208
+ "[class*=\"no-data\"]",
209
+ "[class*=\"nodata\"]",
210
+ "div, span, p, section, li"
211
+ ]);
212
+
213
+ function normalizeRecommendFilteredEmptyText(value = "") {
214
+ return normalizeText(value).replace(/\s+/g, "");
215
+ }
216
+
217
+ function isUsableVisibleBox(box) {
218
+ return Number(box?.rect?.width || 0) > 2 && Number(box?.rect?.height || 0) > 2;
219
+ }
220
+
221
+ async function inspectRecommendEmptyStateAccessibility(client, nodeId, expectedText) {
222
+ if (typeof client?.Accessibility?.getPartialAXTree !== "function") {
223
+ return {
224
+ available: false,
225
+ verified: false,
226
+ reason: "accessibility_unavailable"
227
+ };
228
+ }
229
+ try {
230
+ const result = await client.Accessibility.getPartialAXTree({
231
+ nodeId,
232
+ fetchRelatives: true
233
+ });
234
+ const expected = normalizeRecommendFilteredEmptyText(expectedText);
235
+ const nodes = (result?.nodes || []).map((node) => ({
236
+ ignored: node?.ignored === true,
237
+ role: node?.role?.value || "",
238
+ name: node?.name?.value || "",
239
+ value: node?.value?.value || ""
240
+ }));
241
+ const match = nodes.find((node) => (
242
+ !node.ignored
243
+ && [node.name, node.value].some((value) => (
244
+ normalizeRecommendFilteredEmptyText(value) === expected
245
+ ))
246
+ ));
247
+ return {
248
+ available: true,
249
+ verified: Boolean(match),
250
+ reason: match ? "exact_accessible_text" : "exact_accessible_text_missing",
251
+ match: match || null,
252
+ node_count: nodes.length
253
+ };
254
+ } catch (error) {
255
+ return {
256
+ available: true,
257
+ verified: false,
258
+ reason: "accessibility_probe_failed",
259
+ error: error?.message || String(error)
260
+ };
261
+ }
262
+ }
263
+
264
+ export async function inspectRecommendFilteredEmptyState(client, rootNodeId, {
265
+ selectors = RECOMMEND_FILTERED_EMPTY_TEXT_SCAN_SELECTORS,
266
+ exactTexts = RECOMMEND_FILTERED_EMPTY_TEXTS,
267
+ maxNodes = 2500
268
+ } = {}) {
269
+ if (!client || !rootNodeId) {
270
+ return {
271
+ verified: false,
272
+ reason: "missing_client_or_root",
273
+ text: null,
274
+ node_id: null
275
+ };
276
+ }
277
+ const expectedTexts = new Set(
278
+ (exactTexts || []).map(normalizeRecommendFilteredEmptyText).filter(Boolean)
279
+ );
280
+ const selectorCounts = {};
281
+ const queryErrors = [];
282
+ const nodeIds = [];
283
+ for (const selector of selectors || []) {
284
+ try {
285
+ const matches = await querySelectorAll(client, rootNodeId, selector);
286
+ selectorCounts[selector] = matches.length;
287
+ nodeIds.push(...matches);
288
+ } catch (error) {
289
+ selectorCounts[selector] = null;
290
+ queryErrors.push({
291
+ selector,
292
+ error: error?.message || String(error)
293
+ });
294
+ }
295
+ }
296
+
297
+ const uniqueNodeIds = Array.from(new Set(nodeIds.filter(Boolean)))
298
+ .slice(0, Math.max(0, Number(maxNodes) || 0));
299
+ let checkedNodeCount = 0;
300
+ for (const nodeId of uniqueNodeIds) {
301
+ let text = "";
302
+ try {
303
+ text = normalizeRecommendFilteredEmptyText(htmlToText(await getOuterHTML(client, nodeId)));
304
+ } catch {
305
+ continue;
306
+ }
307
+ if (!expectedTexts.has(text)) continue;
308
+ checkedNodeCount += 1;
309
+ let box = null;
310
+ try {
311
+ box = await getNodeBox(client, nodeId);
312
+ } catch {
313
+ continue;
314
+ }
315
+ if (!isUsableVisibleBox(box)) continue;
316
+ const accessibility = await inspectRecommendEmptyStateAccessibility(client, nodeId, text);
317
+ if (accessibility.verified !== true) continue;
318
+ return {
319
+ verified: true,
320
+ reason: "exact_visible_filtered_empty_state",
321
+ text,
322
+ node_id: nodeId,
323
+ box: {
324
+ x: box.rect.x,
325
+ y: box.rect.y,
326
+ width: box.rect.width,
327
+ height: box.rect.height
328
+ },
329
+ accessibility,
330
+ selector_counts: selectorCounts,
331
+ checked_node_count: checkedNodeCount,
332
+ candidate_node_count: uniqueNodeIds.length,
333
+ query_errors: queryErrors.slice(0, 20)
334
+ };
335
+ }
336
+
337
+ return {
338
+ verified: false,
339
+ reason: checkedNodeCount > 0
340
+ ? "exact_empty_text_not_visible_or_accessible"
341
+ : "exact_empty_text_not_found",
342
+ text: null,
343
+ node_id: null,
344
+ selector_counts: selectorCounts,
345
+ checked_node_count: checkedNodeCount,
346
+ candidate_node_count: uniqueNodeIds.length,
347
+ query_errors: queryErrors.slice(0, 20)
348
+ };
349
+ }
350
+
351
+ export function isVerifiedRecommendRefreshExhaustion({
352
+ cardCount = 0,
353
+ filter = {},
354
+ filterResult = null,
355
+ pageScopeResult = null,
356
+ currentCityOnlyResult = null,
357
+ emptyState = null
358
+ } = {}) {
359
+ if (
360
+ Number(cardCount) !== 0
361
+ || pageScopeResult?.selected !== true
362
+ || emptyState?.verified !== true
363
+ ) return false;
364
+ const filterEnabled = filter?.enabled !== false;
365
+ if (filterEnabled && !isVerifiedRecommendFilterApplication(
366
+ filterResult,
367
+ buildRecommendFilterSelectionOptions(filter)
368
+ )) {
369
+ return false;
370
+ }
371
+ const currentCityOnlyRequested = filter?.currentCityOnly === true || filter?.current_city_only === true;
372
+ if (currentCityOnlyRequested && currentCityOnlyResult?.effective !== true) return false;
373
+ return true;
374
+ }
140
375
 
141
376
  function safeDiagnosticText(value, maxLength = 4000) {
142
377
  const text = String(value ?? "");
@@ -216,8 +451,8 @@ export function isRetryableRecommendFilterReapplyError(error) {
216
451
  current = current?.cause;
217
452
  }
218
453
  const message = messages.join("\n");
219
- return /Recommend filter panel did not open|Recommend filter trigger was not found|Recommend filter confirm button was not found|No matching recommend filter option|Invalid (?:backend )?node(?:\s*id)?|Node with given id does not exist|No node found for given backend id/i.test(message);
220
- }
454
+ return /Recommend filter panel did not open|Recommend filter trigger was not found|Recommend filter confirm button was not found|Recommend filter application was not verified|No matching recommend filter option|Invalid (?:backend )?node(?:\s*id)?|Node with given id does not exist|No node found for given backend id/i.test(message);
455
+ }
221
456
 
222
457
  function compactFilterReapplyError(error) {
223
458
  return error?.message || String(error || "Recommend filter reapply failed");
@@ -371,22 +606,29 @@ export async function selectRecommendJobWithRootRefresh(client, rootState, {
371
606
  throw wrapped;
372
607
  }
373
608
 
374
- async function selectAndConfirmRefreshFilter(client, rootState, filterOptions, {
375
- maxAttempts = 3,
376
- retryDelayMs = 1500
377
- } = {}) {
609
+ export async function selectAndConfirmRefreshFilter(client, rootState, filterOptions, {
610
+ maxAttempts = 3,
611
+ retryDelayMs = 1500,
612
+ selectFilter = selectAndConfirmFirstSafeFilter
613
+ } = {}) {
378
614
  const attempts = [];
379
615
  let currentRootState = rootState;
380
616
  let lastError = null;
381
617
 
382
618
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
383
619
  try {
384
- const filter = await selectAndConfirmFirstSafeFilter(
385
- client,
386
- currentRootState.iframe.documentNodeId,
387
- filterOptions
388
- );
389
- attempts.push({
620
+ const filter = await selectFilter(
621
+ client,
622
+ currentRootState.iframe.documentNodeId,
623
+ filterOptions
624
+ );
625
+ if (!isVerifiedRecommendFilterApplication(filter, filterOptions)) {
626
+ const verificationError = new Error("Recommend filter application was not verified after refresh");
627
+ verificationError.code = "RECOMMEND_FILTER_APPLICATION_UNVERIFIED";
628
+ verificationError.filter_result = filter;
629
+ throw verificationError;
630
+ }
631
+ attempts.push({
390
632
  ok: true,
391
633
  method: "filter_reapply",
392
634
  attempt
@@ -576,15 +818,28 @@ async function applyRefreshMethod(client, method, {
576
818
  });
577
819
  currentCityOnlyResult = filterStages.current_city_only;
578
820
  filterResult = filterStages.filter;
579
- const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
580
- timeoutMs: cardTimeoutMs,
581
- intervalMs: 500
582
- });
583
- if (!cardNodeIds.length) {
584
- throw new Error("No recommend candidate cards were found after refresh reload");
585
- }
586
- return {
587
- ok: true,
821
+ const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
822
+ timeoutMs: cardTimeoutMs,
823
+ intervalMs: 500
824
+ });
825
+ const emptyState = cardNodeIds.length === 0
826
+ ? await inspectRecommendFilteredEmptyState(client, currentRootState.iframe.documentNodeId)
827
+ : null;
828
+ const exhausted = isVerifiedRecommendRefreshExhaustion({
829
+ cardCount: cardNodeIds.length,
830
+ filter,
831
+ filterResult,
832
+ pageScopeResult,
833
+ currentCityOnlyResult,
834
+ emptyState
835
+ });
836
+ if (!cardNodeIds.length && !exhausted) {
837
+ throw new Error("No recommend candidate cards were found after refresh reload");
838
+ }
839
+ return {
840
+ ok: true,
841
+ exhausted,
842
+ reason: exhausted ? "filtered_list_exhausted" : null,
588
843
  method,
589
844
  target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
590
845
  job_selection: jobSelection,
@@ -593,10 +848,11 @@ async function applyRefreshMethod(client, method, {
593
848
  page_scope: pageScopeResult,
594
849
  current_city_only: currentCityOnlyResult,
595
850
  current_city_only_attempts: currentCityOnlyAttempts,
596
- filter: filterResult,
597
- filter_reapply_attempts: filterReapplyAttempts,
598
- card_count: cardNodeIds.length,
599
- root_state: currentRootState,
851
+ filter: filterResult,
852
+ filter_reapply_attempts: filterReapplyAttempts,
853
+ card_count: cardNodeIds.length,
854
+ empty_state: emptyState,
855
+ root_state: currentRootState,
600
856
  forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false),
601
857
  elapsed_ms: Date.now() - started
602
858
  };
@@ -696,24 +952,53 @@ export async function refreshRecommendListAtEnd(client, {
696
952
  });
697
953
  currentCityOnlyResult = filterStages.current_city_only;
698
954
  filterResult = filterStages.filter;
699
- const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
700
- timeoutMs: cardTimeoutMs,
701
- intervalMs: 500
702
- });
703
- return {
704
- ok: cardNodeIds.length > 0,
705
- method: "end_refresh_button",
706
- attempts,
707
- page_scope: pageScopeResult,
708
- current_city_only: currentCityOnlyResult,
709
- current_city_only_attempts: currentCityOnlyAttempts,
710
- filter: filterResult,
711
- filter_reapply_attempts: filterReapplyAttempts,
712
- card_count: cardNodeIds.length,
713
- root_state: currentRootState,
714
- forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false)
715
- };
716
- } catch (error) {
955
+ const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
956
+ timeoutMs: cardTimeoutMs,
957
+ intervalMs: 500
958
+ });
959
+ const emptyState = cardNodeIds.length === 0
960
+ ? await inspectRecommendFilteredEmptyState(client, currentRootState.iframe.documentNodeId)
961
+ : null;
962
+ const exhausted = isVerifiedRecommendRefreshExhaustion({
963
+ cardCount: cardNodeIds.length,
964
+ filter,
965
+ filterResult,
966
+ pageScopeResult,
967
+ currentCityOnlyResult,
968
+ emptyState
969
+ });
970
+ if (cardNodeIds.length > 0 || exhausted) {
971
+ return {
972
+ ok: true,
973
+ exhausted,
974
+ reason: exhausted ? "filtered_list_exhausted" : null,
975
+ method: "end_refresh_button",
976
+ attempts,
977
+ page_scope: pageScopeResult,
978
+ current_city_only: currentCityOnlyResult,
979
+ current_city_only_attempts: currentCityOnlyAttempts,
980
+ filter: filterResult,
981
+ filter_reapply_attempts: filterReapplyAttempts,
982
+ card_count: cardNodeIds.length,
983
+ empty_state: emptyState,
984
+ root_state: currentRootState,
985
+ forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false)
986
+ };
987
+ }
988
+ attempts.push({
989
+ ok: false,
990
+ method: "end_refresh_button_after_click",
991
+ reason: "end_refresh_empty_state_unverified",
992
+ page_scope: pageScopeResult,
993
+ current_city_only: currentCityOnlyResult,
994
+ current_city_only_attempts: currentCityOnlyAttempts,
995
+ filter: filterResult,
996
+ filter_reapply_attempts: filterReapplyAttempts,
997
+ card_count: 0,
998
+ empty_state: emptyState,
999
+ forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false)
1000
+ });
1001
+ } catch (error) {
717
1002
  attempts.push({
718
1003
  ok: false,
719
1004
  method: "end_refresh_button_after_click",