@reconcrap/boss-recommend-mcp 2.1.23 → 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.
Files changed (67) hide show
  1. package/README.md +5 -0
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/package.json +6 -1
  4. package/scripts/install-macos.sh +280 -280
  5. package/scripts/postinstall.cjs +44 -44
  6. package/skills/boss-chat/README.md +43 -42
  7. package/skills/boss-chat/SKILL.md +107 -106
  8. package/skills/boss-recommend-pipeline/README.md +13 -13
  9. package/skills/boss-recommend-pipeline/SKILL.md +47 -47
  10. package/skills/boss-recruit-pipeline/README.md +19 -19
  11. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  12. package/src/chat-mcp.js +301 -127
  13. package/src/chat-runtime-config.js +7 -5
  14. package/src/core/boss-cards/index.js +199 -199
  15. package/src/core/browser/index.js +302 -145
  16. package/src/core/capture/index.js +2930 -1201
  17. package/src/core/cv-acquisition/index.js +512 -238
  18. package/src/core/cv-capture-target/index.js +513 -299
  19. package/src/core/greet-quota/index.js +71 -71
  20. package/src/core/infinite-list/index.js +31 -31
  21. package/src/core/reporting/legacy-csv.js +12 -12
  22. package/src/core/run/detached-launcher.js +305 -0
  23. package/src/core/run/index.js +109 -55
  24. package/src/core/run/timing.js +33 -33
  25. package/src/core/run/windows-detached-worker.ps1 +106 -0
  26. package/src/core/screening/index.js +2400 -2135
  27. package/src/core/self-heal/index.js +989 -973
  28. package/src/core/self-heal/viewport.js +1505 -564
  29. package/src/detached-worker.js +99 -99
  30. package/src/domains/chat/action-journal.js +536 -0
  31. package/src/domains/chat/cards.js +137 -137
  32. package/src/domains/chat/constants.js +9 -9
  33. package/src/domains/chat/detail.js +1684 -401
  34. package/src/domains/chat/index.js +8 -7
  35. package/src/domains/chat/jobs.js +620 -620
  36. package/src/domains/chat/page-guard.js +157 -122
  37. package/src/domains/chat/roots.js +56 -56
  38. package/src/domains/chat/run-service.js +1801 -760
  39. package/src/domains/common/account-rights-panel.js +314 -314
  40. package/src/domains/common/recovery-settle.js +159 -159
  41. package/src/domains/recommend/actions.js +1219 -472
  42. package/src/domains/recommend/cards.js +243 -243
  43. package/src/domains/recommend/colleague-contact.js +1079 -333
  44. package/src/domains/recommend/constants.js +92 -92
  45. package/src/domains/recommend/detail.js +4037 -136
  46. package/src/domains/recommend/filters.js +608 -590
  47. package/src/domains/recommend/index.js +9 -9
  48. package/src/domains/recommend/jobs.js +571 -542
  49. package/src/domains/recommend/location.js +754 -707
  50. package/src/domains/recommend/refresh.js +677 -392
  51. package/src/domains/recommend/roots.js +80 -80
  52. package/src/domains/recommend/run-service.js +4048 -1447
  53. package/src/domains/recommend/scopes.js +246 -246
  54. package/src/domains/recruit/actions.js +277 -277
  55. package/src/domains/recruit/cards.js +74 -74
  56. package/src/domains/recruit/constants.js +236 -236
  57. package/src/domains/recruit/detail.js +588 -588
  58. package/src/domains/recruit/index.js +9 -9
  59. package/src/domains/recruit/instruction-parser.js +866 -866
  60. package/src/domains/recruit/refresh.js +45 -45
  61. package/src/domains/recruit/roots.js +68 -68
  62. package/src/domains/recruit/run-service.js +1817 -1620
  63. package/src/domains/recruit/search.js +3229 -3229
  64. package/src/index.js +16 -1
  65. package/src/parser.js +1296 -1296
  66. package/src/recommend-mcp.js +1061 -450
  67. package/src/recommend-scheduler.js +75 -75
@@ -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
@@ -12,16 +18,17 @@ import {
12
18
  waitForRecommendCardNodeIds
13
19
  } from "./cards.js";
14
20
  import {
21
+ RECOMMEND_BOTTOM_MARKER_SELECTORS,
15
22
  RECOMMEND_RECENT_NOT_VIEW_LABEL,
16
23
  RECOMMEND_TARGET_URL
17
24
  } from "./constants.js";
18
- import { selectAndConfirmFirstSafeFilter } from "./filters.js";
19
- import { ensureRecommendCurrentCityOnly } from "./location.js";
20
- import {
21
- selectRecommendJob,
22
- verifyRecommendJobSelection
23
- } from "./jobs.js";
24
- import { selectRecommendPageScope } from "./scopes.js";
25
+ import { selectAndConfirmFirstSafeFilter } from "./filters.js";
26
+ import { ensureRecommendCurrentCityOnly } from "./location.js";
27
+ import {
28
+ selectRecommendJob,
29
+ verifyRecommendJobSelection
30
+ } from "./jobs.js";
31
+ import { selectRecommendPageScope } from "./scopes.js";
25
32
  import {
26
33
  getRecommendRoots,
27
34
  waitForRecommendRoots
@@ -31,19 +38,19 @@ import { isStaleRecommendNodeError } from "./detail.js";
31
38
  function normalizeLabels(labels = []) {
32
39
  return labels.map((label) => String(label || "").trim()).filter(Boolean);
33
40
  }
34
-
35
- function normalizeFilterGroup(spec = {}) {
36
- return {
37
- group: String(spec.group || "").trim(),
38
- labels: normalizeLabels(spec.labels || spec.filterLabels || []),
39
- selectAllLabels: spec.selectAllLabels !== false,
40
- allowUnlimited: spec.allowUnlimited === true,
41
- verifySticky: spec.verifySticky === true
42
- };
43
- }
44
-
45
- export function buildRecommendFilterGroups(filter = {}, {
46
- forceRecentNotView = false
41
+
42
+ function normalizeFilterGroup(spec = {}) {
43
+ return {
44
+ group: String(spec.group || "").trim(),
45
+ labels: normalizeLabels(spec.labels || spec.filterLabels || []),
46
+ selectAllLabels: spec.selectAllLabels !== false,
47
+ allowUnlimited: spec.allowUnlimited === true,
48
+ verifySticky: spec.verifySticky === true
49
+ };
50
+ }
51
+
52
+ export function buildRecommendFilterGroups(filter = {}, {
53
+ forceRecentNotView = false
47
54
  } = {}) {
48
55
  const groups = [];
49
56
  const sourceGroups = Array.isArray(filter.filterGroups)
@@ -52,171 +59,399 @@ 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
- }
81
+ }
71
82
  recentGroup.selectAllLabels = true;
83
+ recentGroup.verifySticky = true;
72
84
  } else {
73
85
  groups.unshift({
74
86
  group: "recentNotView",
75
87
  labels: [RECOMMEND_RECENT_NOT_VIEW_LABEL],
76
88
  selectAllLabels: true,
77
89
  allowUnlimited: false,
78
- verifySticky: false
90
+ verifySticky: true
79
91
  });
80
- }
81
- }
82
-
83
- return groups;
92
+ }
93
+ }
94
+
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 });
90
102
  if (filterGroups.length > 1 || forceRecentNotView || Array.isArray(filter.filterGroups) || Array.isArray(filter.groups)) {
91
103
  return { filterGroups };
92
104
  }
93
- const [singleGroup] = filterGroups;
94
- if (singleGroup) {
95
- return {
96
- group: singleGroup.group,
97
- labels: singleGroup.labels,
98
- selectAllLabels: singleGroup.selectAllLabels,
99
- allowUnlimited: singleGroup.allowUnlimited,
100
- verifySticky: singleGroup.verifySticky
101
- };
102
- }
103
- return {
104
- group: filter.group || "",
105
- labels: normalizeLabels(filter.labels || filter.filterLabels || []),
106
- selectAllLabels: filter.selectAllLabels !== false,
107
- allowUnlimited: filter.allowUnlimited === true,
108
- verifySticky: filter.verifySticky === true
109
- };
105
+ const [singleGroup] = filterGroups;
106
+ if (singleGroup) {
107
+ return {
108
+ group: singleGroup.group,
109
+ labels: singleGroup.labels,
110
+ selectAllLabels: singleGroup.selectAllLabels,
111
+ allowUnlimited: singleGroup.allowUnlimited,
112
+ verifySticky: singleGroup.verifySticky
113
+ };
114
+ }
115
+ return {
116
+ group: filter.group || "",
117
+ labels: normalizeLabels(filter.labels || filter.filterLabels || []),
118
+ selectAllLabels: filter.selectAllLabels !== false,
119
+ allowUnlimited: filter.allowUnlimited === true,
120
+ verifySticky: filter.verifySticky === true
121
+ };
122
+ }
123
+
124
+ export async function applyRecommendFilterEnvelopeStages(filter = {}, {
125
+ applyCurrentCityOnly,
126
+ applyFilterPanel
127
+ } = {}) {
128
+ if (filter.enabled === false) {
129
+ return {
130
+ applied: false,
131
+ skipped: true,
132
+ current_city_only: null,
133
+ filter: null
134
+ };
135
+ }
136
+ if (typeof applyCurrentCityOnly !== "function" || typeof applyFilterPanel !== "function") {
137
+ throw new Error("Recommend native filter stages require location and filter-panel callbacks");
138
+ }
139
+ const currentCityOnlyResult = await applyCurrentCityOnly();
140
+ const filterResult = await applyFilterPanel();
141
+ return {
142
+ applied: true,
143
+ skipped: false,
144
+ current_city_only: currentCityOnlyResult,
145
+ filter: filterResult
146
+ };
147
+ }
148
+
149
+ function refreshFailureReason(method = "") {
150
+ return method === "page_navigate" ? "page_navigate_failed" : "page_reload_failed";
110
151
  }
111
152
 
112
- export async function applyRecommendFilterEnvelopeStages(filter = {}, {
113
- applyCurrentCityOnly,
114
- applyFilterPanel
115
- } = {}) {
116
- if (filter.enabled === false) {
117
- return {
118
- applied: false,
119
- skipped: true,
120
- current_city_only: null,
121
- filter: null
122
- };
123
- }
124
- if (typeof applyCurrentCityOnly !== "function" || typeof applyFilterPanel !== "function") {
125
- throw new Error("Recommend native filter stages require location and filter-panel callbacks");
126
- }
127
- const currentCityOnlyResult = await applyCurrentCityOnly();
128
- const filterResult = await applyFilterPanel();
129
- return {
130
- applied: true,
131
- skipped: false,
132
- current_city_only: currentCityOnlyResult,
133
- filter: filterResult
134
- };
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
+ });
135
198
  }
136
199
 
137
- function refreshFailureReason(method = "") {
138
- return method === "page_navigate" ? "page_navigate_failed" : "page_reload_failed";
139
- }
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
+ ]);
140
212
 
141
- function safeDiagnosticText(value, maxLength = 4000) {
142
- const text = String(value ?? "");
143
- return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
213
+ function normalizeRecommendFilteredEmptyText(value = "") {
214
+ return normalizeText(value).replace(/\s+/g, "");
144
215
  }
145
216
 
146
- function safeDiagnosticStack(stack) {
147
- if (!stack) return null;
148
- return safeDiagnosticText(stack, 8000)
149
- .split(/\r?\n/)
150
- .slice(0, 12)
151
- .join("\n");
217
+ function isUsableVisibleBox(box) {
218
+ return Number(box?.rect?.width || 0) > 2 && Number(box?.rect?.height || 0) > 2;
152
219
  }
153
220
 
154
- function buildErrorDiagnostic(error, {
155
- depth = 0,
156
- seen = new Set()
157
- } = {}) {
158
- if (!error) return null;
159
- const isRecord = typeof error === "object" || typeof error === "function";
160
- if (isRecord && seen.has(error)) {
221
+ async function inspectRecommendEmptyStateAccessibility(client, nodeId, expectedText) {
222
+ if (typeof client?.Accessibility?.getPartialAXTree !== "function") {
161
223
  return {
162
- name: error?.name || "Error",
163
- message: safeDiagnosticText(error?.message || String(error)),
164
- circular: true
224
+ available: false,
225
+ verified: false,
226
+ reason: "accessibility_unavailable"
165
227
  };
166
228
  }
167
- if (isRecord) seen.add(error);
168
- const diagnostic = {
169
- name: error?.name || "Error",
170
- message: safeDiagnosticText(error?.message || String(error))
171
- };
172
- if (error?.code !== undefined && error?.code !== null && error.code !== "") {
173
- diagnostic.code = typeof error.code === "number"
174
- ? error.code
175
- : safeDiagnosticText(error.code, 300);
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
+ };
176
261
  }
177
- if (error?.phase) diagnostic.phase = safeDiagnosticText(error.phase, 300);
178
- if (error?.cdp_method) diagnostic.cdp_method = safeDiagnosticText(error.cdp_method, 300);
179
- if (error?.cdp_at) diagnostic.cdp_at = safeDiagnosticText(error.cdp_at, 100);
180
- if (Number.isInteger(error?.cdp_node_id)) diagnostic.cdp_node_id = error.cdp_node_id;
181
- if (Number.isInteger(error?.cdp_backend_node_id)) {
182
- diagnostic.cdp_backend_node_id = error.cdp_backend_node_id;
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
+ };
183
276
  }
184
- if (Number.isInteger(error?.node_id)) diagnostic.node_id = error.node_id;
185
- if (Number.isInteger(error?.backend_node_id)) diagnostic.backend_node_id = error.backend_node_id;
186
- if (error?.cdp_search_id) diagnostic.cdp_search_id = safeDiagnosticText(error.cdp_search_id, 300);
187
- if (Array.isArray(error?.cdp_param_keys)) {
188
- diagnostic.cdp_param_keys = error.cdp_param_keys
189
- .slice(0, 20)
190
- .map((key) => safeDiagnosticText(key, 100));
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
+ }
191
295
  }
192
- const stack = safeDiagnosticStack(error?.stack);
193
- if (stack) diagnostic.stack = stack;
194
- if (depth < 2 && error?.cause && error.cause !== error) {
195
- diagnostic.cause = buildErrorDiagnostic(error.cause, {
196
- depth: depth + 1,
197
- seen
198
- });
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
+ };
199
335
  }
200
- return diagnostic;
201
- }
202
336
 
203
- export function compactRecommendRefreshErrorDiagnostic(error) {
204
- return buildErrorDiagnostic(error);
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
+ };
205
349
  }
206
350
 
207
- export function isRetryableRecommendFilterReapplyError(error) {
208
- const messages = [];
209
- const seen = new Set();
210
- let current = error;
211
- for (let depth = 0; current && depth < 4; depth += 1) {
212
- if ((typeof current === "object" || typeof current === "function") && seen.has(current)) break;
213
- if (typeof current === "object" || typeof current === "function") seen.add(current);
214
- if (isStaleRecommendNodeError(current)) return true;
215
- messages.push(String(current?.message || current || ""));
216
- current = current?.cause;
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;
217
370
  }
218
- 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);
371
+ const currentCityOnlyRequested = filter?.currentCityOnly === true || filter?.current_city_only === true;
372
+ if (currentCityOnlyRequested && currentCityOnlyResult?.effective !== true) return false;
373
+ return true;
374
+ }
375
+
376
+ function safeDiagnosticText(value, maxLength = 4000) {
377
+ const text = String(value ?? "");
378
+ return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
379
+ }
380
+
381
+ function safeDiagnosticStack(stack) {
382
+ if (!stack) return null;
383
+ return safeDiagnosticText(stack, 8000)
384
+ .split(/\r?\n/)
385
+ .slice(0, 12)
386
+ .join("\n");
387
+ }
388
+
389
+ function buildErrorDiagnostic(error, {
390
+ depth = 0,
391
+ seen = new Set()
392
+ } = {}) {
393
+ if (!error) return null;
394
+ const isRecord = typeof error === "object" || typeof error === "function";
395
+ if (isRecord && seen.has(error)) {
396
+ return {
397
+ name: error?.name || "Error",
398
+ message: safeDiagnosticText(error?.message || String(error)),
399
+ circular: true
400
+ };
401
+ }
402
+ if (isRecord) seen.add(error);
403
+ const diagnostic = {
404
+ name: error?.name || "Error",
405
+ message: safeDiagnosticText(error?.message || String(error))
406
+ };
407
+ if (error?.code !== undefined && error?.code !== null && error.code !== "") {
408
+ diagnostic.code = typeof error.code === "number"
409
+ ? error.code
410
+ : safeDiagnosticText(error.code, 300);
411
+ }
412
+ if (error?.phase) diagnostic.phase = safeDiagnosticText(error.phase, 300);
413
+ if (error?.cdp_method) diagnostic.cdp_method = safeDiagnosticText(error.cdp_method, 300);
414
+ if (error?.cdp_at) diagnostic.cdp_at = safeDiagnosticText(error.cdp_at, 100);
415
+ if (Number.isInteger(error?.cdp_node_id)) diagnostic.cdp_node_id = error.cdp_node_id;
416
+ if (Number.isInteger(error?.cdp_backend_node_id)) {
417
+ diagnostic.cdp_backend_node_id = error.cdp_backend_node_id;
418
+ }
419
+ if (Number.isInteger(error?.node_id)) diagnostic.node_id = error.node_id;
420
+ if (Number.isInteger(error?.backend_node_id)) diagnostic.backend_node_id = error.backend_node_id;
421
+ if (error?.cdp_search_id) diagnostic.cdp_search_id = safeDiagnosticText(error.cdp_search_id, 300);
422
+ if (Array.isArray(error?.cdp_param_keys)) {
423
+ diagnostic.cdp_param_keys = error.cdp_param_keys
424
+ .slice(0, 20)
425
+ .map((key) => safeDiagnosticText(key, 100));
426
+ }
427
+ const stack = safeDiagnosticStack(error?.stack);
428
+ if (stack) diagnostic.stack = stack;
429
+ if (depth < 2 && error?.cause && error.cause !== error) {
430
+ diagnostic.cause = buildErrorDiagnostic(error.cause, {
431
+ depth: depth + 1,
432
+ seen
433
+ });
434
+ }
435
+ return diagnostic;
436
+ }
437
+
438
+ export function compactRecommendRefreshErrorDiagnostic(error) {
439
+ return buildErrorDiagnostic(error);
440
+ }
441
+
442
+ export function isRetryableRecommendFilterReapplyError(error) {
443
+ const messages = [];
444
+ const seen = new Set();
445
+ let current = error;
446
+ for (let depth = 0; current && depth < 4; depth += 1) {
447
+ if ((typeof current === "object" || typeof current === "function") && seen.has(current)) break;
448
+ if (typeof current === "object" || typeof current === "function") seen.add(current);
449
+ if (isStaleRecommendNodeError(current)) return true;
450
+ messages.push(String(current?.message || current || ""));
451
+ current = current?.cause;
452
+ }
453
+ const message = messages.join("\n");
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);
220
455
  }
221
456
 
222
457
  function compactFilterReapplyError(error) {
@@ -239,10 +474,10 @@ function compactJobSelectionAttempt({
239
474
  return {
240
475
  ok: Boolean(ok),
241
476
  method: "job_select",
242
- reason: error ? "job_select_failed" : null,
243
- error: error ? (error?.message || String(error)) : null,
244
- error_diagnostic: error ? compactRecommendRefreshErrorDiagnostic(error) : null,
245
- attempt,
477
+ reason: error ? "job_select_failed" : null,
478
+ error: error ? (error?.message || String(error)) : null,
479
+ error_diagnostic: error ? compactRecommendRefreshErrorDiagnostic(error) : null,
480
+ attempt,
246
481
  iframe_document_node_id: iframeDocumentNodeId || 0,
247
482
  selected: Boolean(selection?.selected),
248
483
  selection_reason: selection?.reason || null,
@@ -371,7 +606,75 @@ export async function selectRecommendJobWithRootRefresh(client, rootState, {
371
606
  throw wrapped;
372
607
  }
373
608
 
374
- async function selectAndConfirmRefreshFilter(client, rootState, filterOptions, {
609
+ export async function selectAndConfirmRefreshFilter(client, rootState, filterOptions, {
610
+ maxAttempts = 3,
611
+ retryDelayMs = 1500,
612
+ selectFilter = selectAndConfirmFirstSafeFilter
613
+ } = {}) {
614
+ const attempts = [];
615
+ let currentRootState = rootState;
616
+ let lastError = null;
617
+
618
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
619
+ try {
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({
632
+ ok: true,
633
+ method: "filter_reapply",
634
+ attempt
635
+ });
636
+ return {
637
+ filter,
638
+ root_state: currentRootState,
639
+ attempts
640
+ };
641
+ } catch (error) {
642
+ lastError = error;
643
+ attempts.push({
644
+ ok: false,
645
+ method: "filter_reapply",
646
+ reason: "filter_reapply_failed",
647
+ error: compactFilterReapplyError(error),
648
+ error_diagnostic: compactRecommendRefreshErrorDiagnostic(error),
649
+ attempt
650
+ });
651
+ if (attempt >= maxAttempts || !isRetryableRecommendFilterReapplyError(error)) {
652
+ break;
653
+ }
654
+ if (retryDelayMs > 0) await sleep(retryDelayMs);
655
+ currentRootState = await getRecommendRoots(client);
656
+ }
657
+ }
658
+
659
+ const wrapped = new Error(compactFilterReapplyError(lastError));
660
+ wrapped.cause = lastError;
661
+ wrapped.filter_reapply_attempts = attempts;
662
+ throw wrapped;
663
+ }
664
+
665
+ function compactCurrentCityOnlyReapplyError(error) {
666
+ return error?.message || String(error || "Recommend current-city-only reapply failed");
667
+ }
668
+
669
+ function isRetryableRecommendCurrentCityOnlyReapplyError(error) {
670
+ if (isStaleRecommendNodeError(error)) return true;
671
+ const message = compactCurrentCityOnlyReapplyError(error);
672
+ return /current-city|current city|location|popover|trigger|checkbox|stale|did not (?:open|mount|close)/i.test(message)
673
+ && !/unavailable/i.test(message);
674
+ }
675
+
676
+ async function ensureRefreshCurrentCityOnly(client, rootState, {
677
+ enabled = false,
375
678
  maxAttempts = 3,
376
679
  retryDelayMs = 1500
377
680
  } = {}) {
@@ -381,18 +684,19 @@ async function selectAndConfirmRefreshFilter(client, rootState, filterOptions, {
381
684
 
382
685
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
383
686
  try {
384
- const filter = await selectAndConfirmFirstSafeFilter(
687
+ const result = await ensureRecommendCurrentCityOnly(
385
688
  client,
386
689
  currentRootState.iframe.documentNodeId,
387
- filterOptions
690
+ { enabled }
388
691
  );
389
692
  attempts.push({
390
693
  ok: true,
391
- method: "filter_reapply",
392
- attempt
694
+ method: "current_city_only_reapply",
695
+ attempt,
696
+ result
393
697
  });
394
698
  return {
395
- filter,
699
+ current_city_only: result,
396
700
  root_state: currentRootState,
397
701
  attempts
398
702
  };
@@ -400,13 +704,13 @@ async function selectAndConfirmRefreshFilter(client, rootState, filterOptions, {
400
704
  lastError = error;
401
705
  attempts.push({
402
706
  ok: false,
403
- method: "filter_reapply",
404
- reason: "filter_reapply_failed",
405
- error: compactFilterReapplyError(error),
406
- error_diagnostic: compactRecommendRefreshErrorDiagnostic(error),
407
- attempt
707
+ method: "current_city_only_reapply",
708
+ reason: "current_city_only_reapply_failed",
709
+ error: compactCurrentCityOnlyReapplyError(error),
710
+ error_diagnostic: compactRecommendRefreshErrorDiagnostic(error),
711
+ attempt
408
712
  });
409
- if (attempt >= maxAttempts || !isRetryableRecommendFilterReapplyError(error)) {
713
+ if (attempt >= maxAttempts || !isRetryableRecommendCurrentCityOnlyReapplyError(error)) {
410
714
  break;
411
715
  }
412
716
  if (retryDelayMs > 0) await sleep(retryDelayMs);
@@ -414,96 +718,34 @@ async function selectAndConfirmRefreshFilter(client, rootState, filterOptions, {
414
718
  }
415
719
  }
416
720
 
417
- const wrapped = new Error(compactFilterReapplyError(lastError));
418
- wrapped.cause = lastError;
419
- wrapped.filter_reapply_attempts = attempts;
420
- throw wrapped;
421
- }
422
-
423
- function compactCurrentCityOnlyReapplyError(error) {
424
- return error?.message || String(error || "Recommend current-city-only reapply failed");
425
- }
426
-
427
- function isRetryableRecommendCurrentCityOnlyReapplyError(error) {
428
- if (isStaleRecommendNodeError(error)) return true;
429
- const message = compactCurrentCityOnlyReapplyError(error);
430
- return /current-city|current city|location|popover|trigger|checkbox|stale|did not (?:open|mount|close)/i.test(message)
431
- && !/unavailable/i.test(message);
432
- }
433
-
434
- async function ensureRefreshCurrentCityOnly(client, rootState, {
435
- enabled = false,
436
- maxAttempts = 3,
437
- retryDelayMs = 1500
438
- } = {}) {
439
- const attempts = [];
440
- let currentRootState = rootState;
441
- let lastError = null;
442
-
443
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
444
- try {
445
- const result = await ensureRecommendCurrentCityOnly(
446
- client,
447
- currentRootState.iframe.documentNodeId,
448
- { enabled }
449
- );
450
- attempts.push({
451
- ok: true,
452
- method: "current_city_only_reapply",
453
- attempt,
454
- result
455
- });
456
- return {
457
- current_city_only: result,
458
- root_state: currentRootState,
459
- attempts
460
- };
461
- } catch (error) {
462
- lastError = error;
463
- attempts.push({
464
- ok: false,
465
- method: "current_city_only_reapply",
466
- reason: "current_city_only_reapply_failed",
467
- error: compactCurrentCityOnlyReapplyError(error),
468
- error_diagnostic: compactRecommendRefreshErrorDiagnostic(error),
469
- attempt
470
- });
471
- if (attempt >= maxAttempts || !isRetryableRecommendCurrentCityOnlyReapplyError(error)) {
472
- break;
473
- }
474
- if (retryDelayMs > 0) await sleep(retryDelayMs);
475
- currentRootState = await getRecommendRoots(client);
476
- }
477
- }
478
-
479
- const wrapped = new Error(compactCurrentCityOnlyReapplyError(lastError));
480
- wrapped.cause = lastError;
481
- wrapped.current_city_only_attempts = attempts;
482
- throw wrapped;
483
- }
484
-
485
- async function applyRefreshMethod(client, method, {
486
- jobLabel = "",
487
- pageScope = "recommend",
488
- fallbackPageScope = "recommend",
489
- filter = {},
721
+ const wrapped = new Error(compactCurrentCityOnlyReapplyError(lastError));
722
+ wrapped.cause = lastError;
723
+ wrapped.current_city_only_attempts = attempts;
724
+ throw wrapped;
725
+ }
726
+
727
+ async function applyRefreshMethod(client, method, {
728
+ jobLabel = "",
729
+ pageScope = "recommend",
730
+ fallbackPageScope = "recommend",
731
+ filter = {},
490
732
  targetUrl = RECOMMEND_TARGET_URL,
491
733
  forceRecentNotView = true,
492
734
  cardTimeoutMs = 30000,
493
735
  reloadSettleMs = 8000
494
736
  } = {}) {
495
- const started = Date.now();
496
- let currentRootState = null;
497
- let jobSelection = null;
498
- let jobSelectionAttempts = [];
499
- let pageScopeResult = null;
500
- let currentCityOnlyResult = null;
501
- let currentCityOnlyAttempts = [];
502
- let filterResult = null;
503
- let filterReapplyAttempts = [];
504
- let recoverySettle = null;
505
- try {
506
- if (method === "page_navigate") {
737
+ const started = Date.now();
738
+ let currentRootState = null;
739
+ let jobSelection = null;
740
+ let jobSelectionAttempts = [];
741
+ let pageScopeResult = null;
742
+ let currentCityOnlyResult = null;
743
+ let currentCityOnlyAttempts = [];
744
+ let filterResult = null;
745
+ let filterReapplyAttempts = [];
746
+ let recoverySettle = null;
747
+ try {
748
+ if (method === "page_navigate") {
507
749
  await client.Page.navigate({ url: targetUrl || RECOMMEND_TARGET_URL });
508
750
  } else {
509
751
  await client.Page.reload({ ignoreCache: true });
@@ -546,84 +788,98 @@ async function applyRefreshMethod(client, method, {
546
788
  settleMs: reloadSettleMs > 10000 ? 3000 : 1200,
547
789
  timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
548
790
  }
549
- );
550
- if (!pageScopeResult.selected) {
551
- throw new Error(`Recommend page scope was not selected after refresh reload: ${pageScopeResult.reason || pageScope}`);
552
- }
553
- currentRootState = await getRecommendRoots(client);
554
- const retryDelayMs = Math.max(1200, Math.min(5000, Math.floor((reloadSettleMs || 8000) / 2)));
555
- const filterStages = await applyRecommendFilterEnvelopeStages(filter, {
556
- applyCurrentCityOnly: async () => {
557
- const selection = await ensureRefreshCurrentCityOnly(client, currentRootState, {
558
- enabled: filter.currentCityOnly === true || filter.current_city_only === true,
559
- retryDelayMs
560
- });
561
- currentCityOnlyAttempts = selection.attempts;
562
- currentRootState = await getRecommendRoots(client);
563
- return selection.current_city_only;
564
- },
565
- applyFilterPanel: async () => {
566
- const selection = await selectAndConfirmRefreshFilter(
567
- client,
568
- currentRootState,
569
- buildRecommendFilterSelectionOptions(filter, { forceRecentNotView }),
570
- { retryDelayMs }
571
- );
572
- filterReapplyAttempts = selection.attempts;
573
- currentRootState = await getRecommendRoots(client);
574
- return selection.filter;
575
- }
576
- });
577
- currentCityOnlyResult = filterStages.current_city_only;
578
- filterResult = filterStages.filter;
791
+ );
792
+ if (!pageScopeResult.selected) {
793
+ throw new Error(`Recommend page scope was not selected after refresh reload: ${pageScopeResult.reason || pageScope}`);
794
+ }
795
+ currentRootState = await getRecommendRoots(client);
796
+ const retryDelayMs = Math.max(1200, Math.min(5000, Math.floor((reloadSettleMs || 8000) / 2)));
797
+ const filterStages = await applyRecommendFilterEnvelopeStages(filter, {
798
+ applyCurrentCityOnly: async () => {
799
+ const selection = await ensureRefreshCurrentCityOnly(client, currentRootState, {
800
+ enabled: filter.currentCityOnly === true || filter.current_city_only === true,
801
+ retryDelayMs
802
+ });
803
+ currentCityOnlyAttempts = selection.attempts;
804
+ currentRootState = await getRecommendRoots(client);
805
+ return selection.current_city_only;
806
+ },
807
+ applyFilterPanel: async () => {
808
+ const selection = await selectAndConfirmRefreshFilter(
809
+ client,
810
+ currentRootState,
811
+ buildRecommendFilterSelectionOptions(filter, { forceRecentNotView }),
812
+ { retryDelayMs }
813
+ );
814
+ filterReapplyAttempts = selection.attempts;
815
+ currentRootState = await getRecommendRoots(client);
816
+ return selection.filter;
817
+ }
818
+ });
819
+ currentCityOnlyResult = filterStages.current_city_only;
820
+ filterResult = filterStages.filter;
579
821
  const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
580
822
  timeoutMs: cardTimeoutMs,
581
823
  intervalMs: 500
582
824
  });
583
- if (!cardNodeIds.length) {
584
- throw new Error("No recommend candidate cards were found after refresh reload");
585
- }
586
- return {
587
- ok: true,
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
- target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
590
- job_selection: jobSelection,
591
- job_selection_attempts: jobSelectionAttempts,
592
- recovery_settle: recoverySettle,
593
- page_scope: pageScopeResult,
594
- current_city_only: currentCityOnlyResult,
595
- current_city_only_attempts: currentCityOnlyAttempts,
844
+ target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
845
+ job_selection: jobSelection,
846
+ job_selection_attempts: jobSelectionAttempts,
847
+ recovery_settle: recoverySettle,
848
+ page_scope: pageScopeResult,
849
+ current_city_only: currentCityOnlyResult,
850
+ current_city_only_attempts: currentCityOnlyAttempts,
596
851
  filter: filterResult,
597
852
  filter_reapply_attempts: filterReapplyAttempts,
598
853
  card_count: cardNodeIds.length,
854
+ empty_state: emptyState,
599
855
  root_state: currentRootState,
600
- forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false),
601
- elapsed_ms: Date.now() - started
602
- };
603
- } catch (error) {
604
- return {
605
- ok: false,
856
+ forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false),
857
+ elapsed_ms: Date.now() - started
858
+ };
859
+ } catch (error) {
860
+ return {
861
+ ok: false,
606
862
  method,
607
- reason: refreshFailureReason(method),
608
- error: error?.message || String(error),
609
- error_diagnostic: compactRecommendRefreshErrorDiagnostic(error),
610
- target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
611
- job_selection: jobSelection,
612
- job_selection_attempts: error?.job_selection_attempts || jobSelectionAttempts,
613
- recovery_settle: error?.recovery_settle || recoverySettle,
614
- page_scope: pageScopeResult,
615
- current_city_only: currentCityOnlyResult,
616
- current_city_only_attempts: error?.current_city_only_attempts || currentCityOnlyAttempts,
617
- filter: filterResult,
618
- filter_reapply_attempts: error?.filter_reapply_attempts || filterReapplyAttempts,
619
- card_count: 0,
620
- root_state: currentRootState,
621
- forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false),
622
- elapsed_ms: Date.now() - started
623
- };
624
- }
625
- }
626
-
863
+ reason: refreshFailureReason(method),
864
+ error: error?.message || String(error),
865
+ error_diagnostic: compactRecommendRefreshErrorDiagnostic(error),
866
+ target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
867
+ job_selection: jobSelection,
868
+ job_selection_attempts: error?.job_selection_attempts || jobSelectionAttempts,
869
+ recovery_settle: error?.recovery_settle || recoverySettle,
870
+ page_scope: pageScopeResult,
871
+ current_city_only: currentCityOnlyResult,
872
+ current_city_only_attempts: error?.current_city_only_attempts || currentCityOnlyAttempts,
873
+ filter: filterResult,
874
+ filter_reapply_attempts: error?.filter_reapply_attempts || filterReapplyAttempts,
875
+ card_count: 0,
876
+ root_state: currentRootState,
877
+ forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false),
878
+ elapsed_ms: Date.now() - started
879
+ };
880
+ }
881
+ }
882
+
627
883
  export async function refreshRecommendListAtEnd(client, {
628
884
  rootState = null,
629
885
  jobLabel = "",
@@ -645,92 +901,121 @@ export async function refreshRecommendListAtEnd(client, {
645
901
  currentRootState = currentRootState || await getRecommendRoots(client);
646
902
  const buttonResult = await clickRecommendEndRefreshButton(
647
903
  client,
648
- currentRootState.iframe.documentNodeId,
649
- { settleMs: buttonSettleMs }
650
- );
651
- attempts.push(buttonResult);
652
- if (buttonResult.ok) {
653
- let pageScopeResult = null;
654
- let currentCityOnlyResult = null;
655
- let currentCityOnlyAttempts = [];
656
- let filterResult = null;
657
- let filterReapplyAttempts = [];
658
- try {
659
- currentRootState = await getRecommendRoots(client);
660
- pageScopeResult = await selectRecommendPageScope(
661
- client,
662
- currentRootState.iframe.documentNodeId,
663
- {
664
- pageScope,
665
- fallbackScope: fallbackPageScope,
904
+ currentRootState.iframe.documentNodeId,
905
+ { settleMs: buttonSettleMs }
906
+ );
907
+ attempts.push(buttonResult);
908
+ if (buttonResult.ok) {
909
+ let pageScopeResult = null;
910
+ let currentCityOnlyResult = null;
911
+ let currentCityOnlyAttempts = [];
912
+ let filterResult = null;
913
+ let filterReapplyAttempts = [];
914
+ try {
915
+ currentRootState = await getRecommendRoots(client);
916
+ pageScopeResult = await selectRecommendPageScope(
917
+ client,
918
+ currentRootState.iframe.documentNodeId,
919
+ {
920
+ pageScope,
921
+ fallbackScope: fallbackPageScope,
666
922
  settleMs: buttonSettleMs > 10000 ? 3000 : 1200,
667
923
  timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
668
924
  }
669
- );
670
- if (!pageScopeResult.selected) {
671
- throw new Error(`Recommend page scope was not selected after end refresh: ${pageScopeResult.reason || pageScope}`);
672
- }
673
- currentRootState = await getRecommendRoots(client);
674
- const retryDelayMs = Math.max(1200, Math.min(5000, Math.floor((buttonSettleMs || 8000) / 2)));
675
- const filterStages = await applyRecommendFilterEnvelopeStages(filter, {
676
- applyCurrentCityOnly: async () => {
677
- const selection = await ensureRefreshCurrentCityOnly(client, currentRootState, {
678
- enabled: filter.currentCityOnly === true || filter.current_city_only === true,
679
- retryDelayMs
680
- });
681
- currentCityOnlyAttempts = selection.attempts;
682
- currentRootState = await getRecommendRoots(client);
683
- return selection.current_city_only;
684
- },
685
- applyFilterPanel: async () => {
686
- const selection = await selectAndConfirmRefreshFilter(
687
- client,
688
- currentRootState,
689
- buildRecommendFilterSelectionOptions(filter, { forceRecentNotView }),
690
- { retryDelayMs }
691
- );
692
- filterReapplyAttempts = selection.attempts;
693
- currentRootState = await getRecommendRoots(client);
694
- return selection.filter;
695
- }
696
- });
697
- currentCityOnlyResult = filterStages.current_city_only;
698
- filterResult = filterStages.filter;
925
+ );
926
+ if (!pageScopeResult.selected) {
927
+ throw new Error(`Recommend page scope was not selected after end refresh: ${pageScopeResult.reason || pageScope}`);
928
+ }
929
+ currentRootState = await getRecommendRoots(client);
930
+ const retryDelayMs = Math.max(1200, Math.min(5000, Math.floor((buttonSettleMs || 8000) / 2)));
931
+ const filterStages = await applyRecommendFilterEnvelopeStages(filter, {
932
+ applyCurrentCityOnly: async () => {
933
+ const selection = await ensureRefreshCurrentCityOnly(client, currentRootState, {
934
+ enabled: filter.currentCityOnly === true || filter.current_city_only === true,
935
+ retryDelayMs
936
+ });
937
+ currentCityOnlyAttempts = selection.attempts;
938
+ currentRootState = await getRecommendRoots(client);
939
+ return selection.current_city_only;
940
+ },
941
+ applyFilterPanel: async () => {
942
+ const selection = await selectAndConfirmRefreshFilter(
943
+ client,
944
+ currentRootState,
945
+ buildRecommendFilterSelectionOptions(filter, { forceRecentNotView }),
946
+ { retryDelayMs }
947
+ );
948
+ filterReapplyAttempts = selection.attempts;
949
+ currentRootState = await getRecommendRoots(client);
950
+ return selection.filter;
951
+ }
952
+ });
953
+ currentCityOnlyResult = filterStages.current_city_only;
954
+ filterResult = filterStages.filter;
699
955
  const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
700
956
  timeoutMs: cardTimeoutMs,
701
957
  intervalMs: 500
702
958
  });
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) {
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
+ }
717
988
  attempts.push({
718
989
  ok: false,
719
990
  method: "end_refresh_button_after_click",
720
- reason: "end_refresh_reapply_failed",
721
- error: error?.message || String(error),
722
- error_diagnostic: compactRecommendRefreshErrorDiagnostic(error),
991
+ reason: "end_refresh_empty_state_unverified",
723
992
  page_scope: pageScopeResult,
724
993
  current_city_only: currentCityOnlyResult,
725
- current_city_only_attempts: error?.current_city_only_attempts || currentCityOnlyAttempts,
994
+ current_city_only_attempts: currentCityOnlyAttempts,
726
995
  filter: filterResult,
727
- filter_reapply_attempts: error?.filter_reapply_attempts || filterReapplyAttempts,
996
+ filter_reapply_attempts: filterReapplyAttempts,
997
+ card_count: 0,
998
+ empty_state: emptyState,
728
999
  forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false)
729
1000
  });
730
- }
731
- }
732
- }
733
-
1001
+ } catch (error) {
1002
+ attempts.push({
1003
+ ok: false,
1004
+ method: "end_refresh_button_after_click",
1005
+ reason: "end_refresh_reapply_failed",
1006
+ error: error?.message || String(error),
1007
+ error_diagnostic: compactRecommendRefreshErrorDiagnostic(error),
1008
+ page_scope: pageScopeResult,
1009
+ current_city_only: currentCityOnlyResult,
1010
+ current_city_only_attempts: error?.current_city_only_attempts || currentCityOnlyAttempts,
1011
+ filter: filterResult,
1012
+ filter_reapply_attempts: error?.filter_reapply_attempts || filterReapplyAttempts,
1013
+ forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false)
1014
+ });
1015
+ }
1016
+ }
1017
+ }
1018
+
734
1019
  const fallbackMethods = [];
735
1020
  if (forceNavigate && typeof client?.Page?.navigate === "function") {
736
1021
  fallbackMethods.push("page_navigate");
@@ -767,13 +1052,13 @@ export async function refreshRecommendListAtEnd(client, {
767
1052
  return {
768
1053
  ...(lastRefreshResult || {
769
1054
  ok: false,
770
- method: fallbackMethods[fallbackMethods.length - 1] || "page_reload",
771
- reason: "refresh_failed",
772
- error: "Recommend refresh did not run",
773
- card_count: 0,
774
- root_state: currentRootState,
775
- forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false)
776
- }),
777
- attempts
778
- };
779
- }
1055
+ method: fallbackMethods[fallbackMethods.length - 1] || "page_reload",
1056
+ reason: "refresh_failed",
1057
+ error: "Recommend refresh did not run",
1058
+ card_count: 0,
1059
+ root_state: currentRootState,
1060
+ forced_recent_not_view: Boolean(forceRecentNotView && filter.enabled !== false)
1061
+ }),
1062
+ attempts
1063
+ };
1064
+ }