@reconcrap/boss-recommend-mcp 2.0.34 → 2.0.36

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.34",
3
+ "version": "2.0.36",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -4,7 +4,8 @@ import {
4
4
  waitForRecommendCardNodeIds
5
5
  } from "./cards.js";
6
6
  import {
7
- RECOMMEND_RECENT_NOT_VIEW_LABEL
7
+ RECOMMEND_RECENT_NOT_VIEW_LABEL,
8
+ RECOMMEND_TARGET_URL
8
9
  } from "./constants.js";
9
10
  import { selectAndConfirmFirstSafeFilter } from "./filters.js";
10
11
  import { selectRecommendJob } from "./jobs.js";
@@ -87,6 +88,105 @@ export function buildRecommendFilterSelectionOptions(filter = {}, {
87
88
  };
88
89
  }
89
90
 
91
+ function refreshFailureReason(method = "") {
92
+ return method === "page_navigate" ? "page_navigate_failed" : "page_reload_failed";
93
+ }
94
+
95
+ async function applyRefreshMethod(client, method, {
96
+ jobLabel = "",
97
+ pageScope = "recommend",
98
+ fallbackPageScope = "recommend",
99
+ filter = {},
100
+ targetUrl = RECOMMEND_TARGET_URL,
101
+ forceRecentNotView = true,
102
+ cardTimeoutMs = 30000,
103
+ reloadSettleMs = 8000
104
+ } = {}) {
105
+ const started = Date.now();
106
+ let currentRootState = null;
107
+ let jobSelection = null;
108
+ let pageScopeResult = null;
109
+ let filterResult = null;
110
+ try {
111
+ if (method === "page_navigate") {
112
+ await client.Page.navigate({ url: targetUrl || RECOMMEND_TARGET_URL });
113
+ } else {
114
+ await client.Page.reload({ ignoreCache: true });
115
+ }
116
+ if (reloadSettleMs > 0) await sleep(reloadSettleMs);
117
+ currentRootState = await waitForRecommendRoots(client, {
118
+ timeoutMs: Math.max(45000, reloadSettleMs * 6),
119
+ intervalMs: 500
120
+ });
121
+ if (!currentRootState?.iframe?.documentNodeId) {
122
+ throw new Error("Recommend iframe was not ready after refresh reload");
123
+ }
124
+ if (jobLabel) {
125
+ jobSelection = await selectRecommendJob(client, currentRootState.iframe.documentNodeId, {
126
+ jobLabel,
127
+ settleMs: reloadSettleMs > 10000 ? 12000 : 6000
128
+ });
129
+ if (!jobSelection.selected) {
130
+ throw new Error(`Requested recommend job was not selected after refresh reload: ${jobSelection.reason}`);
131
+ }
132
+ currentRootState = await getRecommendRoots(client);
133
+ }
134
+ pageScopeResult = await selectRecommendPageScope(
135
+ client,
136
+ currentRootState.iframe.documentNodeId,
137
+ {
138
+ pageScope,
139
+ fallbackScope: fallbackPageScope,
140
+ settleMs: reloadSettleMs > 10000 ? 3000 : 1200,
141
+ timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
142
+ }
143
+ );
144
+ if (!pageScopeResult.selected) {
145
+ throw new Error(`Recommend page scope was not selected after refresh reload: ${pageScopeResult.reason || pageScope}`);
146
+ }
147
+ currentRootState = await getRecommendRoots(client);
148
+ filterResult = await selectAndConfirmFirstSafeFilter(
149
+ client,
150
+ currentRootState.iframe.documentNodeId,
151
+ buildRecommendFilterSelectionOptions(filter, { forceRecentNotView })
152
+ );
153
+ const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
154
+ timeoutMs: cardTimeoutMs,
155
+ intervalMs: 500
156
+ });
157
+ if (!cardNodeIds.length) {
158
+ throw new Error("No recommend candidate cards were found after refresh reload");
159
+ }
160
+ return {
161
+ ok: true,
162
+ method,
163
+ target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
164
+ job_selection: jobSelection,
165
+ page_scope: pageScopeResult,
166
+ filter: filterResult,
167
+ card_count: cardNodeIds.length,
168
+ root_state: currentRootState,
169
+ forced_recent_not_view: forceRecentNotView,
170
+ elapsed_ms: Date.now() - started
171
+ };
172
+ } catch (error) {
173
+ return {
174
+ ok: false,
175
+ method,
176
+ reason: refreshFailureReason(method),
177
+ error: error?.message || String(error),
178
+ target_url: method === "page_navigate" ? (targetUrl || RECOMMEND_TARGET_URL) : null,
179
+ job_selection: jobSelection,
180
+ page_scope: pageScopeResult,
181
+ filter: filterResult,
182
+ card_count: 0,
183
+ root_state: currentRootState,
184
+ forced_recent_not_view: forceRecentNotView,
185
+ elapsed_ms: Date.now() - started
186
+ };
187
+ }
188
+ }
189
+
90
190
  export async function refreshRecommendListAtEnd(client, {
91
191
  rootState = null,
92
192
  jobLabel = "",
@@ -94,15 +194,18 @@ export async function refreshRecommendListAtEnd(client, {
94
194
  fallbackPageScope = "recommend",
95
195
  filter = {},
96
196
  preferEndRefreshButton = true,
197
+ forceNavigate = false,
198
+ targetUrl = RECOMMEND_TARGET_URL,
97
199
  forceRecentNotView = true,
98
200
  cardTimeoutMs = 30000,
99
201
  buttonSettleMs = 8000,
100
202
  reloadSettleMs = 8000
101
203
  } = {}) {
102
204
  const attempts = [];
103
- let currentRootState = rootState || await getRecommendRoots(client);
205
+ let currentRootState = rootState || null;
104
206
 
105
207
  if (preferEndRefreshButton) {
208
+ currentRootState = currentRootState || await getRecommendRoots(client);
106
209
  const buttonResult = await clickRecommendEndRefreshButton(
107
210
  client,
108
211
  currentRootState.iframe.documentNodeId,
@@ -156,71 +259,49 @@ export async function refreshRecommendListAtEnd(client, {
156
259
  }
157
260
  }
158
261
 
159
- try {
160
- await client.Page.reload({ ignoreCache: true });
161
- if (reloadSettleMs > 0) await sleep(reloadSettleMs);
162
- currentRootState = await waitForRecommendRoots(client, {
163
- timeoutMs: Math.max(30000, reloadSettleMs * 4),
164
- intervalMs: 500
262
+ const fallbackMethods = [];
263
+ if (forceNavigate && typeof client?.Page?.navigate === "function") {
264
+ fallbackMethods.push("page_navigate");
265
+ }
266
+ if (typeof client?.Page?.reload === "function") {
267
+ fallbackMethods.push("page_reload");
268
+ }
269
+ if (!fallbackMethods.length) {
270
+ fallbackMethods.push("page_reload");
271
+ }
272
+
273
+ let lastRefreshResult = null;
274
+ for (const method of fallbackMethods) {
275
+ const refreshResult = await applyRefreshMethod(client, method, {
276
+ jobLabel,
277
+ pageScope,
278
+ fallbackPageScope,
279
+ filter,
280
+ targetUrl,
281
+ forceRecentNotView,
282
+ cardTimeoutMs,
283
+ reloadSettleMs
165
284
  });
166
- if (!currentRootState?.iframe?.documentNodeId) {
167
- throw new Error("Recommend iframe was not ready after refresh reload");
285
+ if (refreshResult.ok) {
286
+ return {
287
+ ...refreshResult,
288
+ attempts
289
+ };
168
290
  }
169
- let jobSelection = null;
170
- if (jobLabel) {
171
- jobSelection = await selectRecommendJob(client, currentRootState.iframe.documentNodeId, {
172
- jobLabel,
173
- settleMs: reloadSettleMs > 10000 ? 12000 : 6000
174
- });
175
- if (!jobSelection.selected) {
176
- throw new Error(`Requested recommend job was not selected after refresh reload: ${jobSelection.reason}`);
177
- }
178
- currentRootState = await getRecommendRoots(client);
179
- }
180
- const pageScopeResult = await selectRecommendPageScope(
181
- client,
182
- currentRootState.iframe.documentNodeId,
183
- {
184
- pageScope,
185
- fallbackScope: fallbackPageScope,
186
- settleMs: reloadSettleMs > 10000 ? 3000 : 1200,
187
- timeoutMs: Math.max(10000, Math.min(cardTimeoutMs, 60000))
188
- }
189
- );
190
- if (!pageScopeResult.selected) {
191
- throw new Error(`Recommend page scope was not selected after refresh reload: ${pageScopeResult.reason || pageScope}`);
192
- }
193
- currentRootState = await getRecommendRoots(client);
194
- const filterResult = await selectAndConfirmFirstSafeFilter(
195
- client,
196
- currentRootState.iframe.documentNodeId,
197
- buildRecommendFilterSelectionOptions(filter, { forceRecentNotView })
198
- );
199
- const cardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
200
- timeoutMs: cardTimeoutMs,
201
- intervalMs: 500
202
- });
203
- return {
204
- ok: cardNodeIds.length > 0,
205
- method: "page_reload",
206
- attempts,
207
- job_selection: jobSelection,
208
- page_scope: pageScopeResult,
209
- filter: filterResult,
210
- card_count: cardNodeIds.length,
211
- root_state: currentRootState,
212
- forced_recent_not_view: forceRecentNotView
213
- };
214
- } catch (error) {
215
- return {
291
+ attempts.push(refreshResult);
292
+ lastRefreshResult = refreshResult;
293
+ }
294
+
295
+ return {
296
+ ...(lastRefreshResult || {
216
297
  ok: false,
217
- method: "page_reload",
218
- reason: "page_reload_failed",
219
- error: error?.message || String(error),
220
- attempts,
298
+ method: fallbackMethods[fallbackMethods.length - 1] || "page_reload",
299
+ reason: "refresh_failed",
300
+ error: "Recommend refresh did not run",
221
301
  card_count: 0,
222
302
  root_state: currentRootState,
223
303
  forced_recent_not_view: forceRecentNotView
224
- };
225
- }
304
+ }),
305
+ attempts
306
+ };
226
307
  }
@@ -65,7 +65,8 @@ import {
65
65
  RECOMMEND_BOTTOM_MARKER_SELECTORS,
66
66
  RECOMMEND_CARD_SELECTOR,
67
67
  RECOMMEND_END_REFRESH_SELECTOR,
68
- RECOMMEND_LIST_CONTAINER_SELECTORS
68
+ RECOMMEND_LIST_CONTAINER_SELECTORS,
69
+ RECOMMEND_TARGET_URL
69
70
  } from "./constants.js";
70
71
  import {
71
72
  clickRecommendActionControl,
@@ -354,23 +355,56 @@ function compactRefreshAttempt(refreshAttempt) {
354
355
  return {
355
356
  ok: Boolean(refreshAttempt.ok),
356
357
  method: refreshAttempt.method || "",
358
+ reason: refreshAttempt.reason || null,
359
+ error: refreshAttempt.error || null,
357
360
  forced_recent_not_view: Boolean(refreshAttempt.forced_recent_not_view),
361
+ target_url: refreshAttempt.target_url || null,
358
362
  card_count: refreshAttempt.card_count || 0,
363
+ elapsed_ms: refreshAttempt.elapsed_ms || 0,
359
364
  attempts: (refreshAttempt.attempts || []).map((attempt) => ({
360
365
  ok: Boolean(attempt.ok),
361
366
  method: attempt.method || "",
362
367
  reason: attempt.reason || null,
368
+ error: attempt.error || null,
363
369
  label: attempt.label || null,
364
370
  before_card_count: attempt.before_card_count || 0,
365
- after_card_count: attempt.after_card_count || 0
371
+ after_card_count: attempt.after_card_count || 0,
372
+ card_count: attempt.card_count || 0,
373
+ elapsed_ms: attempt.elapsed_ms || 0
366
374
  })),
375
+ job_selection: compactJobSelection(refreshAttempt.job_selection),
367
376
  page_scope: compactPageScopeSelection(refreshAttempt.page_scope),
368
377
  filter: compactFilterResult(refreshAttempt.filter)
369
378
  };
370
379
  }
371
380
 
381
+ export function countRecommendResultStatuses(results = [], {
382
+ greetCount = 0
383
+ } = {}) {
384
+ return {
385
+ processed: results.length,
386
+ screened: results.length,
387
+ detail_opened: results.filter((item) => item.detail).length,
388
+ passed: results.filter((item) => item.screening?.passed).length,
389
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
390
+ greet_count: greetCount,
391
+ post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
392
+ image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
393
+ detail_open_failed: results.filter((item) => (
394
+ item.error?.code === "DETAIL_STALE_NODE"
395
+ || item.error?.code === "DETAIL_OPEN_FAILED"
396
+ )).length,
397
+ transient_recovered: results.filter((item) => (
398
+ item.error?.code === "DETAIL_STALE_NODE"
399
+ || item.error?.code === "IMAGE_CAPTURE_STALE_NODE"
400
+ || item.error?.code === "IMAGE_CAPTURE_TIMEOUT"
401
+ || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT"
402
+ )).length
403
+ };
404
+ }
405
+
372
406
  function countPassedResults(results = []) {
373
- return results.filter((item) => item?.screening?.passed).length;
407
+ return countRecommendResultStatuses(results).passed;
374
408
  }
375
409
 
376
410
  function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
@@ -381,6 +415,13 @@ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
381
415
  };
382
416
  }
383
417
 
418
+ function createRecommendCloseFailureError(closeResult) {
419
+ const error = new Error(closeResult?.reason || "Recommend detail did not close before recovery");
420
+ error.code = "DETAIL_CLOSE_FAILED";
421
+ error.close_result = closeResult || null;
422
+ return error;
423
+ }
424
+
384
425
  export function isRecoverableImageCaptureError(error) {
385
426
  const code = String(error?.code || "");
386
427
  if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
@@ -535,7 +576,9 @@ export async function runRecommendWorkflow({
535
576
  const results = [];
536
577
  const refreshAttempts = [];
537
578
  let refreshRounds = 0;
579
+ let contextRecoveryAttempts = 0;
538
580
  let greetCount = 0;
581
+ const candidateRecoveryCounts = new Map();
539
582
  let jobSelection = null;
540
583
  let pageScopeSelection = null;
541
584
  let filterResult = null;
@@ -550,6 +593,121 @@ export async function runRecommendWorkflow({
550
593
  validateViewportPoint: true
551
594
  }));
552
595
 
596
+ function updateRecommendProgress(extra = {}) {
597
+ const counts = countRecommendResultStatuses(results, { greetCount });
598
+ const listSnapshot = compactInfiniteListState(listState);
599
+ runControl.updateProgress({
600
+ card_count: cardNodeIds.length,
601
+ target_count: targetPassCount,
602
+ target_count_semantics: "passed_candidates",
603
+ ...counts,
604
+ screening_mode: normalizedScreeningMode,
605
+ unique_seen: listSnapshot.seen_count,
606
+ scroll_count: listSnapshot.scroll_count,
607
+ refresh_rounds: refreshRounds,
608
+ refresh_attempts: refreshAttempts.length,
609
+ context_recoveries: contextRecoveryAttempts,
610
+ list_end_reason: listEndReason || null,
611
+ viewport_checks: viewportGuard.getStats().checks,
612
+ viewport_recoveries: viewportGuard.getStats().recoveries,
613
+ ...extra
614
+ });
615
+ }
616
+
617
+ function checkpointInProgressCandidate({
618
+ index = results.length,
619
+ candidateKey = "",
620
+ cardNodeId = null,
621
+ detailStep = "",
622
+ error = null
623
+ } = {}) {
624
+ runControl.checkpoint({
625
+ in_progress_candidate: {
626
+ index,
627
+ key: candidateKey,
628
+ card_node_id: cardNodeId,
629
+ detail_step: detailStep || null,
630
+ counters: countRecommendResultStatuses(results, { greetCount }),
631
+ error: compactError(error, "RECOMMEND_IN_PROGRESS_ERROR")
632
+ },
633
+ candidate_list: compactInfiniteListState(listState)
634
+ });
635
+ }
636
+
637
+ async function recoverAndReapplyRecommendContext(reason = "context_recovery", error = null, {
638
+ forceRecentNotView = true
639
+ } = {}) {
640
+ await runControl.waitIfPaused();
641
+ runControl.throwIfCanceled();
642
+ const started = Date.now();
643
+ runControl.setPhase("recommend:recover-context");
644
+ contextRecoveryAttempts += 1;
645
+ const refreshResult = await refreshRecommendListAtEnd(client, {
646
+ rootState,
647
+ jobLabel,
648
+ pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
649
+ fallbackPageScope: normalizedFallbackPageScope,
650
+ filter: normalizedFilter,
651
+ preferEndRefreshButton: false,
652
+ forceNavigate: true,
653
+ targetUrl: targetUrl || RECOMMEND_TARGET_URL,
654
+ forceRecentNotView,
655
+ cardTimeoutMs,
656
+ buttonSettleMs: refreshButtonSettleMs,
657
+ reloadSettleMs: refreshReloadSettleMs
658
+ });
659
+ const compactRefresh = {
660
+ ...compactRefreshAttempt(refreshResult),
661
+ context_recovery: true,
662
+ recovery_reason: reason,
663
+ trigger_error: compactError(error, "RECOMMEND_CONTEXT_RECOVERY_TRIGGER"),
664
+ elapsed_ms: Date.now() - started
665
+ };
666
+ refreshAttempts.push(compactRefresh);
667
+ runControl.checkpoint({
668
+ context_recovery: {
669
+ attempt: contextRecoveryAttempts,
670
+ reason,
671
+ trigger_error: compactError(error, "RECOMMEND_CONTEXT_RECOVERY_TRIGGER"),
672
+ refresh: compactRefresh,
673
+ counters: countRecommendResultStatuses(results, { greetCount })
674
+ },
675
+ candidate_list: compactInfiniteListState(listState)
676
+ });
677
+ if (!refreshResult.ok) {
678
+ updateRecommendProgress({
679
+ refresh_method: refreshResult.method || null,
680
+ refresh_forced_recent_not_view: forceRecentNotView,
681
+ recovery_reason: reason
682
+ });
683
+ throw new Error(`Recommend context recovery failed after ${reason}: ${refreshResult.reason || refreshResult.error || "refresh returned no cards"}`);
684
+ }
685
+ rootState = refreshResult.root_state || await getRecommendRoots(client);
686
+ rootState = await ensureRecommendViewport(rootState, "recover_after");
687
+ cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
688
+ timeoutMs: cardTimeoutMs,
689
+ intervalMs: 300
690
+ });
691
+ resetInfiniteListForRefreshRound(listState, {
692
+ reason: `context_recovery:${reason}`,
693
+ round: contextRecoveryAttempts,
694
+ method: refreshResult.method,
695
+ metadata: {
696
+ card_count: cardNodeIds.length,
697
+ forced_recent_not_view: forceRecentNotView,
698
+ counters: countRecommendResultStatuses(results, { greetCount })
699
+ }
700
+ });
701
+ listEndReason = "";
702
+ updateRecommendProgress({
703
+ card_count: cardNodeIds.length,
704
+ refresh_method: refreshResult.method || null,
705
+ refresh_forced_recent_not_view: forceRecentNotView,
706
+ recovery_reason: reason
707
+ });
708
+ return refreshResult;
709
+ }
710
+
553
711
  runControl.setPhase("recommend:cleanup");
554
712
  await closeRecommendDetail(client, { attemptsLimit: 2 });
555
713
 
@@ -630,24 +788,8 @@ export async function runRecommendWorkflow({
630
788
  throw new Error("No recommend candidate cards found for run service");
631
789
  }
632
790
 
633
- runControl.updateProgress({
634
- card_count: cardNodeIds.length,
635
- target_count: targetPassCount,
636
- target_count_semantics: "passed_candidates",
637
- processed: 0,
638
- screened: 0,
639
- detail_opened: 0,
640
- passed: 0,
641
- greet_count: 0,
642
- post_action_clicked: 0,
643
- screening_mode: normalizedScreeningMode,
644
- llm_screened: 0,
645
- unique_seen: compactInfiniteListState(listState).seen_count,
646
- scroll_count: 0,
647
- refresh_rounds: 0,
648
- refresh_attempts: 0,
649
- viewport_checks: viewportGuard.getStats().checks,
650
- viewport_recoveries: viewportGuard.getStats().recoveries
791
+ updateRecommendProgress({
792
+ list_end_reason: null
651
793
  });
652
794
 
653
795
  while (countPassedResults(results) < targetPassCount) {
@@ -722,24 +864,11 @@ export async function runRecommendWorkflow({
722
864
  refresh_round: refreshRounds,
723
865
  refresh: compactRefresh
724
866
  });
725
- runControl.updateProgress({
867
+ updateRecommendProgress({
726
868
  card_count: refreshResult.card_count || cardNodeIds.length,
727
- target_count: targetPassCount,
728
- target_count_semantics: "passed_candidates",
729
- processed: results.length,
730
- screened: results.length,
731
- detail_opened: results.filter((item) => item.detail).length,
732
- passed: results.filter((item) => item.screening.passed).length,
733
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
734
- unique_seen: compactInfiniteListState(listState).seen_count,
735
- scroll_count: compactInfiniteListState(listState).scroll_count,
736
- refresh_rounds: refreshRounds,
737
- refresh_attempts: refreshAttempts.length,
738
869
  refresh_method: refreshResult.method || null,
739
870
  refresh_forced_recent_not_view: true,
740
- list_end_reason: listEndReason,
741
- viewport_checks: viewportGuard.getStats().checks,
742
- viewport_recoveries: viewportGuard.getStats().recoveries
871
+ list_end_reason: listEndReason
743
872
  });
744
873
  if (refreshResult.ok) {
745
874
  rootState = refreshResult.root_state || await getRecommendRoots(client);
@@ -772,12 +901,16 @@ export async function runRecommendWorkflow({
772
901
  let screeningCandidate = cardCandidate;
773
902
  let detailResult = null;
774
903
  let recoverableDetailError = null;
904
+ let detailStep = "not_started";
775
905
  if (index < effectiveDetailLimit) {
776
906
  try {
777
907
  await runControl.waitIfPaused();
778
908
  runControl.throwIfCanceled();
779
909
  runControl.setPhase("recommend:detail");
910
+ detailStep = "ensure_viewport";
780
911
  rootState = await ensureRecommendViewport(rootState, "detail");
912
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
913
+ detailStep = "open_detail";
781
914
  networkRecorder.clear();
782
915
  const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
783
916
  cardNodeId,
@@ -794,6 +927,7 @@ export async function runRecommendWorkflow({
794
927
  cardCandidate = openedDetail.card_candidate || cardCandidate;
795
928
  screeningCandidate = cardCandidate;
796
929
  const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
930
+ detailStep = "wait_network";
797
931
  const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
798
932
  waitForRecommendDetailNetworkEvents,
799
933
  networkRecorder,
@@ -807,6 +941,7 @@ export async function runRecommendWorkflow({
807
941
  if (networkWait?.elapsed_ms != null) {
808
942
  timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
809
943
  }
944
+ detailStep = "extract_detail";
810
945
  detailResult = await extractRecommendDetailCandidate(client, {
811
946
  cardCandidate,
812
947
  cardNodeId,
@@ -830,6 +965,7 @@ export async function runRecommendWorkflow({
830
965
  waitResult: networkWait
831
966
  });
832
967
  } else {
968
+ detailStep = "wait_capture_target";
833
969
  captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
834
970
  domain: "recommend",
835
971
  timeoutMs: 6000,
@@ -846,6 +982,7 @@ export async function runRecommendWorkflow({
846
982
  extension: "jpg"
847
983
  });
848
984
  try {
985
+ detailStep = "capture_image";
849
986
  imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
850
987
  filePath: imageEvidencePath,
851
988
  format: "jpeg",
@@ -879,6 +1016,17 @@ export async function runRecommendWorkflow({
879
1016
  source = "image";
880
1017
  } catch (error) {
881
1018
  if (!isRecoverableImageCaptureError(error)) throw error;
1019
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1020
+ if (recoveryCount < 1) {
1021
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1022
+ timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
1023
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1024
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1025
+ await recoverAndReapplyRecommendContext(`image_capture:${detailStep}`, error, {
1026
+ forceRecentNotView: true
1027
+ });
1028
+ continue;
1029
+ }
882
1030
  imageEvidence = createRecoverableImageCaptureEvidence(error, {
883
1031
  elapsedMs: timings.screenshot_capture_ms,
884
1032
  filePath: imageEvidencePath,
@@ -919,6 +1067,17 @@ export async function runRecommendWorkflow({
919
1067
  screeningCandidate = detailResult.candidate;
920
1068
  } catch (error) {
921
1069
  if (!isRecoverableRecommendDetailError(error)) throw error;
1070
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1071
+ if (recoveryCount < 1) {
1072
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1073
+ timings.detail_recovery_trigger = compactRecoverableDetailError(error);
1074
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1075
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1076
+ await recoverAndReapplyRecommendContext(`detail:${detailStep}`, error, {
1077
+ forceRecentNotView: true
1078
+ });
1079
+ continue;
1080
+ }
922
1081
  recoverableDetailError = error;
923
1082
  detailResult = null;
924
1083
  timings.detail_recovered_error = compactRecoverableDetailError(error);
@@ -994,6 +1153,21 @@ export async function runRecommendWorkflow({
994
1153
  }
995
1154
  if (detailResult && closeDetail) {
996
1155
  detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecommendDetail(client));
1156
+ if (!detailResult.close_result?.closed) {
1157
+ const closeError = createRecommendCloseFailureError(detailResult.close_result);
1158
+ const recovery = await recoverAndReapplyRecommendContext("detail_close_failed", closeError, {
1159
+ forceRecentNotView: true
1160
+ });
1161
+ detailResult.cv_acquisition = {
1162
+ ...(detailResult.cv_acquisition || {}),
1163
+ close_recovery: {
1164
+ ok: Boolean(recovery.ok),
1165
+ method: recovery.method || "",
1166
+ forced_recent_not_view: Boolean(recovery.forced_recent_not_view),
1167
+ card_count: recovery.card_count || 0
1168
+ }
1169
+ };
1170
+ }
997
1171
  }
998
1172
  timings.total_ms = Date.now() - candidateStarted;
999
1173
  const compactResult = {
@@ -1024,27 +1198,7 @@ export async function runRecommendWorkflow({
1024
1198
  }
1025
1199
  });
1026
1200
 
1027
- runControl.updateProgress({
1028
- card_count: cardNodeIds.length,
1029
- target_count: targetPassCount,
1030
- target_count_semantics: "passed_candidates",
1031
- processed: results.length,
1032
- screened: results.length,
1033
- detail_opened: results.filter((item) => item.detail).length,
1034
- passed: results.filter((item) => item.screening.passed).length,
1035
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
1036
- greet_count: greetCount,
1037
- post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
1038
- image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
1039
- detail_open_failed: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "DETAIL_OPEN_FAILED").length,
1040
- transient_recovered: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_TIMEOUT" || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT").length,
1041
- unique_seen: compactInfiniteListState(listState).seen_count,
1042
- scroll_count: compactInfiniteListState(listState).scroll_count,
1043
- refresh_rounds: refreshRounds,
1044
- refresh_attempts: refreshAttempts.length,
1045
- list_end_reason: listEndReason || null,
1046
- viewport_checks: viewportGuard.getStats().checks,
1047
- viewport_recoveries: viewportGuard.getStats().recoveries,
1201
+ updateRecommendProgress({
1048
1202
  last_candidate_id: screeningCandidate.id || null,
1049
1203
  last_candidate_key: candidateKey,
1050
1204
  last_score: screening.score
@@ -1097,16 +1251,8 @@ export async function runRecommendWorkflow({
1097
1251
  list_end_reason: listEndReason || null,
1098
1252
  refresh_rounds: refreshRounds,
1099
1253
  refresh_attempts: refreshAttempts,
1100
- processed: results.length,
1101
- screened: results.length,
1102
- detail_opened: results.filter((item) => item.detail).length,
1103
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
1104
- detail_open_failed: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "DETAIL_OPEN_FAILED").length,
1105
- image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
1106
- transient_recovered: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_TIMEOUT" || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT").length,
1107
- passed: results.filter((item) => item.screening.passed).length,
1108
- greet_count: greetCount,
1109
- post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
1254
+ context_recoveries: contextRecoveryAttempts,
1255
+ ...countRecommendResultStatuses(results, { greetCount }),
1110
1256
  results
1111
1257
  };
1112
1258
  }
@@ -1213,7 +1359,11 @@ export function createRecommendRunService({
1213
1359
  llm_screened: 0,
1214
1360
  passed: 0,
1215
1361
  greet_count: 0,
1216
- post_action_clicked: 0
1362
+ post_action_clicked: 0,
1363
+ image_capture_failed: 0,
1364
+ detail_open_failed: 0,
1365
+ transient_recovered: 0,
1366
+ context_recoveries: 0
1217
1367
  },
1218
1368
  checkpoint: {},
1219
1369
  task: (runControl) => workflow({