@reconcrap/boss-recommend-mcp 2.0.53 → 2.0.54

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,13 +1,13 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { createRunLifecycleManager } from "../../core/run/index.js";
4
- import {
5
- addTiming,
6
- imageEvidenceFilePath,
7
- measureTiming
8
- } from "../../core/run/timing.js";
9
- import { captureScrolledNodeScreenshots } from "../../core/capture/index.js";
10
- import { waitForCvCaptureTarget } from "../../core/cv-capture-target/index.js";
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createRunLifecycleManager } from "../../core/run/index.js";
4
+ import {
5
+ addTiming,
6
+ imageEvidenceFilePath,
7
+ measureTiming
8
+ } from "../../core/run/timing.js";
9
+ import { captureScrolledNodeScreenshots } from "../../core/capture/index.js";
10
+ import { waitForCvCaptureTarget } from "../../core/cv-capture-target/index.js";
11
11
  import {
12
12
  configureHumanInteraction,
13
13
  createHumanRestController,
@@ -15,37 +15,37 @@ import {
15
15
  normalizeHumanBehaviorOptions,
16
16
  sleep
17
17
  } from "../../core/browser/index.js";
18
- import { GREET_CREDITS_EXHAUSTED_CODE } from "../../core/greet-quota/index.js";
19
- import {
20
- compactCvAcquisitionState,
21
- countParsedNetworkProfiles,
22
- createCvAcquisitionState,
23
- DEFAULT_MAX_IMAGE_PAGES,
24
- getCvNetworkWaitPlan,
25
- recordCvImageFallback,
26
- recordCvNetworkHit,
27
- recordCvNetworkMiss,
28
- summarizeImageEvidence,
29
- waitForCvNetworkEvents
30
- } from "../../core/cv-acquisition/index.js";
31
- import {
32
- compactInfiniteListState,
33
- createInfiniteListState,
34
- detectInfiniteListBottomMarker,
35
- getNextInfiniteListCandidate,
36
- markInfiniteListCandidateProcessed,
37
- resetInfiniteListForRefreshRound,
38
- resolveInfiniteListFallbackPoint
39
- } from "../../core/infinite-list/index.js";
40
- import { createViewportRunGuard } from "../../core/self-heal/index.js";
41
- import {
42
- callScreeningLlm,
43
- compactScreeningLlmResult,
44
- createFailedLlmScreeningResult,
45
- llmResultToScreening,
46
- screenCandidate
47
- } from "../../core/screening/index.js";
48
- import {
18
+ import { GREET_CREDITS_EXHAUSTED_CODE } from "../../core/greet-quota/index.js";
19
+ import {
20
+ compactCvAcquisitionState,
21
+ countParsedNetworkProfiles,
22
+ createCvAcquisitionState,
23
+ DEFAULT_MAX_IMAGE_PAGES,
24
+ getCvNetworkWaitPlan,
25
+ recordCvImageFallback,
26
+ recordCvNetworkHit,
27
+ recordCvNetworkMiss,
28
+ summarizeImageEvidence,
29
+ waitForCvNetworkEvents
30
+ } from "../../core/cv-acquisition/index.js";
31
+ import {
32
+ compactInfiniteListState,
33
+ createInfiniteListState,
34
+ detectInfiniteListBottomMarker,
35
+ getNextInfiniteListCandidate,
36
+ markInfiniteListCandidateProcessed,
37
+ resetInfiniteListForRefreshRound,
38
+ resolveInfiniteListFallbackPoint
39
+ } from "../../core/infinite-list/index.js";
40
+ import { createViewportRunGuard } from "../../core/self-heal/index.js";
41
+ import {
42
+ callScreeningLlm,
43
+ compactScreeningLlmResult,
44
+ createFailedLlmScreeningResult,
45
+ llmResultToScreening,
46
+ screenCandidate
47
+ } from "../../core/screening/index.js";
48
+ import {
49
49
  closeRecommendDetail,
50
50
  createRecommendDetailNetworkRecorder,
51
51
  extractRecommendDetailCandidate,
@@ -54,96 +54,96 @@ import {
54
54
  openRecommendCardDetailWithFreshRetry,
55
55
  waitForRecommendDetailNetworkEvents
56
56
  } from "./detail.js";
57
- import {
58
- readRecommendCardCandidate,
59
- waitForRecommendCardNodeIds
60
- } from "./cards.js";
61
- import { selectAndConfirmFirstSafeFilter } from "./filters.js";
62
- import {
63
- buildRecommendFilterSelectionOptions,
64
- refreshRecommendListAtEnd
65
- } from "./refresh.js";
66
- import { selectRecommendJob } from "./jobs.js";
67
- import {
68
- normalizeRecommendPageScope,
69
- selectRecommendPageScope
70
- } from "./scopes.js";
71
- import {
72
- RECOMMEND_BOTTOM_MARKER_SELECTORS,
73
- RECOMMEND_CARD_SELECTOR,
74
- RECOMMEND_END_REFRESH_SELECTOR,
75
- RECOMMEND_LIST_CONTAINER_SELECTORS,
76
- RECOMMEND_TARGET_URL
77
- } from "./constants.js";
78
- import {
79
- clickRecommendActionControl,
80
- normalizeRecommendPostAction,
81
- resolveRecommendPostAction,
82
- waitForRecommendDetailActionControls
83
- } from "./actions.js";
84
- import { getRecommendRoots } from "./roots.js";
85
-
86
- function normalizeLabels(labels = []) {
87
- return labels.map((label) => String(label || "").trim()).filter(Boolean);
88
- }
89
-
90
- function isRefreshableListStall(reason = "") {
91
- return new Set([
92
- "stable_visible_signature",
93
- "max_scrolls_exhausted",
94
- "scroll_failed",
95
- "scroll_anchor_unavailable"
96
- ]).has(String(reason || ""));
97
- }
98
-
99
- function normalizeFilter(filter = {}) {
100
- const filterGroups = Array.isArray(filter.filterGroups)
101
- ? filter.filterGroups
102
- : Array.isArray(filter.groups)
103
- ? filter.groups
104
- : [];
105
- return {
106
- enabled: filter.enabled !== false,
107
- group: String(filter.group || ""),
108
- labels: normalizeLabels(filter.labels || filter.filterLabels || []),
109
- selectAllLabels: Boolean(filter.selectAllLabels),
110
- filterGroups: filterGroups.map((group) => ({
111
- group: String(group?.group || ""),
112
- labels: normalizeLabels(group?.labels || group?.filterLabels || []),
113
- selectAllLabels: group?.selectAllLabels !== false
114
- })).filter((group) => group.group || group.labels.length)
115
- };
116
- }
117
-
118
- function compactFilterResult(filterResult) {
119
- if (!filterResult) return null;
120
- return {
121
- opened_panel: Boolean(filterResult.opened_panel),
122
- selected_option: filterResult.selected_option
123
- ? {
124
- group: filterResult.selected_option.group,
125
- label: filterResult.selected_option.label,
126
- was_active: Boolean(filterResult.selected_option.was_active),
127
- clicked: filterResult.selected_option.clicked !== false
128
- }
129
- : null,
130
- selected_options: (filterResult.selected_options || []).map((option) => ({
131
- group: option.group,
132
- label: option.label,
133
- was_active: Boolean(option.was_active),
134
- clicked: option.clicked !== false
135
- })),
136
- confirmed: Boolean(filterResult.confirmed),
137
- before_counts: filterResult.before_counts,
138
- after_confirm_counts: filterResult.after_confirm_counts
139
- };
140
- }
141
-
142
- function compactJobSelection(jobSelection) {
143
- if (!jobSelection) return null;
144
- return {
145
- requested: jobSelection.requested || "",
146
- selected: Boolean(jobSelection.selected),
57
+ import {
58
+ readRecommendCardCandidate,
59
+ waitForRecommendCardNodeIds
60
+ } from "./cards.js";
61
+ import { selectAndConfirmFirstSafeFilter } from "./filters.js";
62
+ import {
63
+ buildRecommendFilterSelectionOptions,
64
+ refreshRecommendListAtEnd
65
+ } from "./refresh.js";
66
+ import { selectRecommendJob } from "./jobs.js";
67
+ import {
68
+ normalizeRecommendPageScope,
69
+ selectRecommendPageScope
70
+ } from "./scopes.js";
71
+ import {
72
+ RECOMMEND_BOTTOM_MARKER_SELECTORS,
73
+ RECOMMEND_CARD_SELECTOR,
74
+ RECOMMEND_END_REFRESH_SELECTOR,
75
+ RECOMMEND_LIST_CONTAINER_SELECTORS,
76
+ RECOMMEND_TARGET_URL
77
+ } from "./constants.js";
78
+ import {
79
+ clickRecommendActionControl,
80
+ normalizeRecommendPostAction,
81
+ resolveRecommendPostAction,
82
+ waitForRecommendDetailActionControls
83
+ } from "./actions.js";
84
+ import { getRecommendRoots } from "./roots.js";
85
+
86
+ function normalizeLabels(labels = []) {
87
+ return labels.map((label) => String(label || "").trim()).filter(Boolean);
88
+ }
89
+
90
+ function isRefreshableListStall(reason = "") {
91
+ return new Set([
92
+ "stable_visible_signature",
93
+ "max_scrolls_exhausted",
94
+ "scroll_failed",
95
+ "scroll_anchor_unavailable"
96
+ ]).has(String(reason || ""));
97
+ }
98
+
99
+ function normalizeFilter(filter = {}) {
100
+ const filterGroups = Array.isArray(filter.filterGroups)
101
+ ? filter.filterGroups
102
+ : Array.isArray(filter.groups)
103
+ ? filter.groups
104
+ : [];
105
+ return {
106
+ enabled: filter.enabled !== false,
107
+ group: String(filter.group || ""),
108
+ labels: normalizeLabels(filter.labels || filter.filterLabels || []),
109
+ selectAllLabels: Boolean(filter.selectAllLabels),
110
+ filterGroups: filterGroups.map((group) => ({
111
+ group: String(group?.group || ""),
112
+ labels: normalizeLabels(group?.labels || group?.filterLabels || []),
113
+ selectAllLabels: group?.selectAllLabels !== false
114
+ })).filter((group) => group.group || group.labels.length)
115
+ };
116
+ }
117
+
118
+ function compactFilterResult(filterResult) {
119
+ if (!filterResult) return null;
120
+ return {
121
+ opened_panel: Boolean(filterResult.opened_panel),
122
+ selected_option: filterResult.selected_option
123
+ ? {
124
+ group: filterResult.selected_option.group,
125
+ label: filterResult.selected_option.label,
126
+ was_active: Boolean(filterResult.selected_option.was_active),
127
+ clicked: filterResult.selected_option.clicked !== false
128
+ }
129
+ : null,
130
+ selected_options: (filterResult.selected_options || []).map((option) => ({
131
+ group: option.group,
132
+ label: option.label,
133
+ was_active: Boolean(option.was_active),
134
+ clicked: option.clicked !== false
135
+ })),
136
+ confirmed: Boolean(filterResult.confirmed),
137
+ before_counts: filterResult.before_counts,
138
+ after_confirm_counts: filterResult.after_confirm_counts
139
+ };
140
+ }
141
+
142
+ function compactJobSelection(jobSelection) {
143
+ if (!jobSelection) return null;
144
+ return {
145
+ requested: jobSelection.requested || "",
146
+ selected: Boolean(jobSelection.selected),
147
147
  already_current: Boolean(jobSelection.already_current),
148
148
  reason: jobSelection.reason || null,
149
149
  selected_option: jobSelection.selected_option || null,
@@ -171,222 +171,222 @@ function compactJobSelection(jobSelection) {
171
171
  }
172
172
  : null,
173
173
  options: (jobSelection.options || []).map((option) => ({
174
- label: option.label,
175
- label_without_salary: option.label_without_salary,
176
- current: Boolean(option.current),
177
- visible: Boolean(option.visible),
178
- class_name: option.class_name
179
- }))
180
- };
181
- }
182
-
183
- function compactPageScopeSelection(pageScopeSelection) {
184
- if (!pageScopeSelection) return null;
185
- return {
186
- requested_scope: pageScopeSelection.requested_scope || null,
187
- effective_scope: pageScopeSelection.effective_scope || null,
188
- fallback_scope: pageScopeSelection.fallback_scope || null,
189
- fallback_applied: Boolean(pageScopeSelection.fallback_applied),
190
- selected: Boolean(pageScopeSelection.selected),
191
- already_current: Boolean(pageScopeSelection.already_current),
192
- reason: pageScopeSelection.reason || null,
193
- selected_tab: pageScopeSelection.selected_tab || null,
194
- available_scopes: pageScopeSelection.available_scopes || [],
195
- card_count: pageScopeSelection.after?.card_count || null
196
- };
197
- }
198
-
199
- function compactScreening(screening) {
200
- return {
201
- status: screening.status,
202
- passed: screening.passed,
203
- score: screening.score,
204
- reasons: screening.reasons,
205
- candidate: {
206
- domain: screening.candidate?.domain || "recommend",
207
- source: screening.candidate?.source || "",
208
- id: screening.candidate?.id || null,
209
- identity: screening.candidate?.identity || {}
210
- }
211
- };
212
- }
213
-
214
- function compactCandidate(candidate) {
215
- return {
216
- id: candidate?.id || null,
217
- identity: candidate?.identity || {},
218
- text_length: candidate?.text?.raw?.length || 0,
219
- tag_count: candidate?.tags?.length || 0
220
- };
221
- }
222
-
223
- function compactDetail(detailResult) {
224
- if (!detailResult) return null;
225
- return {
226
- popup_text_length: detailResult.detail?.popup_text?.length || 0,
227
- resume_text_length: detailResult.detail?.resume_text?.length || 0,
228
- network_body_count: detailResult.network_bodies?.filter((item) => item.body).length || 0,
229
- parsed_network_profile_count: detailResult.parsed_network_profiles?.filter((item) => item.ok).length || 0,
230
- cv_acquisition: detailResult.cv_acquisition || null,
231
- image_evidence: summarizeImageEvidence(detailResult.image_evidence),
232
- llm_screening: compactScreeningLlmResult(detailResult.llm_result),
233
- close_result: detailResult.close_result
234
- };
235
- }
236
-
237
- function normalizeScreeningMode(value) {
238
- const normalized = String(value || "llm").trim().toLowerCase();
239
- return ["deterministic", "local", "local_scorer"].includes(normalized)
240
- ? "deterministic"
241
- : "llm";
242
- }
243
-
244
- function createMissingLlmConfigResult() {
245
- return createFailedLlmScreeningResult(new Error("LLM screening config is required for production recommend runs"));
246
- }
247
-
248
- function compactActionDiscovery(discovery) {
249
- if (!discovery) return null;
250
- return {
251
- elapsed_ms: discovery.elapsed_ms,
252
- timed_out: Boolean(discovery.timed_out),
253
- detail_root_count: discovery.detail_root_count || 0,
254
- summary: discovery.summary || null
255
- };
256
- }
257
-
258
- async function runRecommendPostAction({
259
- client,
260
- screening,
261
- actionDiscovery,
262
- postAction = "none",
263
- greetCount = 0,
264
- maxGreetCount = null,
265
- executePostAction = true,
266
- afterClickDelayMs = 900
267
- } = {}) {
268
- const plan = resolveRecommendPostAction({
269
- postAction,
270
- greetCount,
271
- maxGreetCount
272
- });
273
- const result = {
274
- requested: postAction,
275
- execute_post_action: Boolean(executePostAction),
276
- plan,
277
- eligible: Boolean(screening?.passed),
278
- action_attempted: false,
279
- action_clicked: false,
280
- counted_as_greet: false,
281
- reason: ""
282
- };
283
-
284
- if (!screening?.passed) {
285
- result.reason = "screening_not_passed";
286
- return result;
287
- }
288
- if (plan.effective === "none") {
289
- result.reason = "post_action_none";
290
- return result;
291
- }
292
-
293
- const summary = actionDiscovery?.summary || {};
294
- const control = plan.effective === "favorite" ? summary.favorite : summary.greet;
295
- if (!control?.found) {
296
- result.reason = `${plan.effective}_control_not_found`;
297
- return result;
298
- }
299
- result.control = control;
300
-
301
- if (plan.effective === "greet" && control.continue_chat) {
302
- result.reason = "already_connected_continue_chat";
303
- result.already_connected = true;
304
- return result;
305
- }
306
- if (plan.effective === "greet" && control.greet_quota?.exhausted) {
307
- result.reason = "greet_credits_exhausted";
308
- result.out_of_greet_credits = true;
309
- result.stop_run = true;
310
- return result;
311
- }
312
- if (plan.effective === "greet" && control.available === false) {
313
- result.reason = "greet_control_not_available";
314
- return result;
315
- }
316
- if (plan.effective === "favorite" && control.active) {
317
- result.reason = "already_favorited";
318
- result.already_favorited = true;
319
- return result;
320
- }
321
- if (control.disabled) {
322
- result.reason = `${plan.effective}_control_disabled`;
323
- return result;
324
- }
325
- if (!executePostAction) {
326
- result.reason = "dry_run_post_action";
327
- result.would_click = true;
328
- return result;
329
- }
330
-
331
- result.action_attempted = true;
332
- result.control_before = control;
333
- let clickResult;
334
- try {
335
- clickResult = await clickRecommendActionControl(client, {
336
- ...control,
337
- kind: plan.effective
338
- });
339
- } catch (error) {
340
- if (error?.code === GREET_CREDITS_EXHAUSTED_CODE) {
341
- result.reason = "greet_credits_exhausted";
342
- result.out_of_greet_credits = true;
343
- result.stop_run = true;
344
- result.greet_quota = error.greet_quota || control.greet_quota || null;
345
- return result;
346
- }
347
- throw error;
348
- }
349
- result.click_result = clickResult;
350
- result.action_clicked = true;
351
- result.counted_as_greet = plan.effective === "greet";
352
- result.reason = "clicked";
353
- if (afterClickDelayMs > 0) await sleep(afterClickDelayMs);
354
- try {
355
- const afterDiscovery = await waitForRecommendDetailActionControls(client, {
356
- timeoutMs: 2500,
357
- intervalMs: 300,
358
- requireAny: false
359
- });
360
- const afterSummary = afterDiscovery?.summary || {};
361
- const afterControl = plan.effective === "favorite" ? afterSummary.favorite : afterSummary.greet;
362
- result.action_discovery_after = compactActionDiscovery(afterDiscovery);
363
- result.control_after = afterControl || null;
364
- if (plan.effective === "greet") {
365
- result.verified_after_click = Boolean(
366
- afterControl?.continue_chat
367
- || String(afterControl?.label || "").includes("继续沟通")
368
- );
369
- } else if (plan.effective === "favorite") {
370
- result.verified_after_click = Boolean(
371
- afterControl?.active
372
- || String(afterControl?.label || "").includes("已")
373
- );
374
- }
375
- } catch (error) {
376
- result.verify_error = {
377
- message: error?.message || String(error)
378
- };
379
- }
380
- return result;
381
- }
382
-
383
- function compactRefreshAttempt(refreshAttempt) {
384
- if (!refreshAttempt) return null;
385
- return {
386
- ok: Boolean(refreshAttempt.ok),
387
- method: refreshAttempt.method || "",
388
- reason: refreshAttempt.reason || null,
389
- error: refreshAttempt.error || null,
174
+ label: option.label,
175
+ label_without_salary: option.label_without_salary,
176
+ current: Boolean(option.current),
177
+ visible: Boolean(option.visible),
178
+ class_name: option.class_name
179
+ }))
180
+ };
181
+ }
182
+
183
+ function compactPageScopeSelection(pageScopeSelection) {
184
+ if (!pageScopeSelection) return null;
185
+ return {
186
+ requested_scope: pageScopeSelection.requested_scope || null,
187
+ effective_scope: pageScopeSelection.effective_scope || null,
188
+ fallback_scope: pageScopeSelection.fallback_scope || null,
189
+ fallback_applied: Boolean(pageScopeSelection.fallback_applied),
190
+ selected: Boolean(pageScopeSelection.selected),
191
+ already_current: Boolean(pageScopeSelection.already_current),
192
+ reason: pageScopeSelection.reason || null,
193
+ selected_tab: pageScopeSelection.selected_tab || null,
194
+ available_scopes: pageScopeSelection.available_scopes || [],
195
+ card_count: pageScopeSelection.after?.card_count || null
196
+ };
197
+ }
198
+
199
+ function compactScreening(screening) {
200
+ return {
201
+ status: screening.status,
202
+ passed: screening.passed,
203
+ score: screening.score,
204
+ reasons: screening.reasons,
205
+ candidate: {
206
+ domain: screening.candidate?.domain || "recommend",
207
+ source: screening.candidate?.source || "",
208
+ id: screening.candidate?.id || null,
209
+ identity: screening.candidate?.identity || {}
210
+ }
211
+ };
212
+ }
213
+
214
+ function compactCandidate(candidate) {
215
+ return {
216
+ id: candidate?.id || null,
217
+ identity: candidate?.identity || {},
218
+ text_length: candidate?.text?.raw?.length || 0,
219
+ tag_count: candidate?.tags?.length || 0
220
+ };
221
+ }
222
+
223
+ function compactDetail(detailResult) {
224
+ if (!detailResult) return null;
225
+ return {
226
+ popup_text_length: detailResult.detail?.popup_text?.length || 0,
227
+ resume_text_length: detailResult.detail?.resume_text?.length || 0,
228
+ network_body_count: detailResult.network_bodies?.filter((item) => item.body).length || 0,
229
+ parsed_network_profile_count: detailResult.parsed_network_profiles?.filter((item) => item.ok).length || 0,
230
+ cv_acquisition: detailResult.cv_acquisition || null,
231
+ image_evidence: summarizeImageEvidence(detailResult.image_evidence),
232
+ llm_screening: compactScreeningLlmResult(detailResult.llm_result),
233
+ close_result: detailResult.close_result
234
+ };
235
+ }
236
+
237
+ function normalizeScreeningMode(value) {
238
+ const normalized = String(value || "llm").trim().toLowerCase();
239
+ return ["deterministic", "local", "local_scorer"].includes(normalized)
240
+ ? "deterministic"
241
+ : "llm";
242
+ }
243
+
244
+ function createMissingLlmConfigResult() {
245
+ return createFailedLlmScreeningResult(new Error("LLM screening config is required for production recommend runs"));
246
+ }
247
+
248
+ function compactActionDiscovery(discovery) {
249
+ if (!discovery) return null;
250
+ return {
251
+ elapsed_ms: discovery.elapsed_ms,
252
+ timed_out: Boolean(discovery.timed_out),
253
+ detail_root_count: discovery.detail_root_count || 0,
254
+ summary: discovery.summary || null
255
+ };
256
+ }
257
+
258
+ async function runRecommendPostAction({
259
+ client,
260
+ screening,
261
+ actionDiscovery,
262
+ postAction = "none",
263
+ greetCount = 0,
264
+ maxGreetCount = null,
265
+ executePostAction = true,
266
+ afterClickDelayMs = 900
267
+ } = {}) {
268
+ const plan = resolveRecommendPostAction({
269
+ postAction,
270
+ greetCount,
271
+ maxGreetCount
272
+ });
273
+ const result = {
274
+ requested: postAction,
275
+ execute_post_action: Boolean(executePostAction),
276
+ plan,
277
+ eligible: Boolean(screening?.passed),
278
+ action_attempted: false,
279
+ action_clicked: false,
280
+ counted_as_greet: false,
281
+ reason: ""
282
+ };
283
+
284
+ if (!screening?.passed) {
285
+ result.reason = "screening_not_passed";
286
+ return result;
287
+ }
288
+ if (plan.effective === "none") {
289
+ result.reason = "post_action_none";
290
+ return result;
291
+ }
292
+
293
+ const summary = actionDiscovery?.summary || {};
294
+ const control = plan.effective === "favorite" ? summary.favorite : summary.greet;
295
+ if (!control?.found) {
296
+ result.reason = `${plan.effective}_control_not_found`;
297
+ return result;
298
+ }
299
+ result.control = control;
300
+
301
+ if (plan.effective === "greet" && control.continue_chat) {
302
+ result.reason = "already_connected_continue_chat";
303
+ result.already_connected = true;
304
+ return result;
305
+ }
306
+ if (plan.effective === "greet" && control.greet_quota?.exhausted) {
307
+ result.reason = "greet_credits_exhausted";
308
+ result.out_of_greet_credits = true;
309
+ result.stop_run = true;
310
+ return result;
311
+ }
312
+ if (plan.effective === "greet" && control.available === false) {
313
+ result.reason = "greet_control_not_available";
314
+ return result;
315
+ }
316
+ if (plan.effective === "favorite" && control.active) {
317
+ result.reason = "already_favorited";
318
+ result.already_favorited = true;
319
+ return result;
320
+ }
321
+ if (control.disabled) {
322
+ result.reason = `${plan.effective}_control_disabled`;
323
+ return result;
324
+ }
325
+ if (!executePostAction) {
326
+ result.reason = "dry_run_post_action";
327
+ result.would_click = true;
328
+ return result;
329
+ }
330
+
331
+ result.action_attempted = true;
332
+ result.control_before = control;
333
+ let clickResult;
334
+ try {
335
+ clickResult = await clickRecommendActionControl(client, {
336
+ ...control,
337
+ kind: plan.effective
338
+ });
339
+ } catch (error) {
340
+ if (error?.code === GREET_CREDITS_EXHAUSTED_CODE) {
341
+ result.reason = "greet_credits_exhausted";
342
+ result.out_of_greet_credits = true;
343
+ result.stop_run = true;
344
+ result.greet_quota = error.greet_quota || control.greet_quota || null;
345
+ return result;
346
+ }
347
+ throw error;
348
+ }
349
+ result.click_result = clickResult;
350
+ result.action_clicked = true;
351
+ result.counted_as_greet = plan.effective === "greet";
352
+ result.reason = "clicked";
353
+ if (afterClickDelayMs > 0) await sleep(afterClickDelayMs);
354
+ try {
355
+ const afterDiscovery = await waitForRecommendDetailActionControls(client, {
356
+ timeoutMs: 2500,
357
+ intervalMs: 300,
358
+ requireAny: false
359
+ });
360
+ const afterSummary = afterDiscovery?.summary || {};
361
+ const afterControl = plan.effective === "favorite" ? afterSummary.favorite : afterSummary.greet;
362
+ result.action_discovery_after = compactActionDiscovery(afterDiscovery);
363
+ result.control_after = afterControl || null;
364
+ if (plan.effective === "greet") {
365
+ result.verified_after_click = Boolean(
366
+ afterControl?.continue_chat
367
+ || String(afterControl?.label || "").includes("继续沟通")
368
+ );
369
+ } else if (plan.effective === "favorite") {
370
+ result.verified_after_click = Boolean(
371
+ afterControl?.active
372
+ || String(afterControl?.label || "").includes("已")
373
+ );
374
+ }
375
+ } catch (error) {
376
+ result.verify_error = {
377
+ message: error?.message || String(error)
378
+ };
379
+ }
380
+ return result;
381
+ }
382
+
383
+ function compactRefreshAttempt(refreshAttempt) {
384
+ if (!refreshAttempt) return null;
385
+ return {
386
+ ok: Boolean(refreshAttempt.ok),
387
+ method: refreshAttempt.method || "",
388
+ reason: refreshAttempt.reason || null,
389
+ error: refreshAttempt.error || null,
390
390
  forced_recent_not_view: Boolean(refreshAttempt.forced_recent_not_view),
391
391
  target_url: refreshAttempt.target_url || null,
392
392
  card_count: refreshAttempt.card_count || 0,
@@ -400,11 +400,11 @@ function compactRefreshAttempt(refreshAttempt) {
400
400
  }
401
401
  : null,
402
402
  attempts: (refreshAttempt.attempts || []).map((attempt) => ({
403
- ok: Boolean(attempt.ok),
404
- method: attempt.method || "",
405
- reason: attempt.reason || null,
406
- error: attempt.error || null,
407
- label: attempt.label || null,
403
+ ok: Boolean(attempt.ok),
404
+ method: attempt.method || "",
405
+ reason: attempt.reason || null,
406
+ error: attempt.error || null,
407
+ label: attempt.label || null,
408
408
  before_card_count: attempt.before_card_count || 0,
409
409
  after_card_count: attempt.after_card_count || 0,
410
410
  card_count: attempt.card_count || 0,
@@ -432,22 +432,22 @@ function compactRefreshAttempt(refreshAttempt) {
432
432
  filter: compactFilterResult(refreshAttempt.filter)
433
433
  };
434
434
  }
435
-
436
- export function countRecommendResultStatuses(results = [], {
437
- greetCount = 0
438
- } = {}) {
439
- return {
440
- processed: results.length,
441
- screened: results.length,
442
- detail_opened: results.filter((item) => item.detail).length,
443
- passed: results.filter((item) => item.screening?.passed).length,
444
- llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
445
- greet_count: greetCount,
446
- post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
447
- image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
448
- detail_open_failed: results.filter((item) => (
449
- item.error?.code === "DETAIL_STALE_NODE"
450
- || item.error?.code === "DETAIL_OPEN_FAILED"
435
+
436
+ export function countRecommendResultStatuses(results = [], {
437
+ greetCount = 0
438
+ } = {}) {
439
+ return {
440
+ processed: results.length,
441
+ screened: results.length,
442
+ detail_opened: results.filter((item) => item.detail).length,
443
+ passed: results.filter((item) => item.screening?.passed).length,
444
+ llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
445
+ greet_count: greetCount,
446
+ post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
447
+ image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
448
+ detail_open_failed: results.filter((item) => (
449
+ item.error?.code === "DETAIL_STALE_NODE"
450
+ || item.error?.code === "DETAIL_OPEN_FAILED"
451
451
  )).length,
452
452
  transient_recovered: results.filter((item) => (
453
453
  item.error?.code === "DETAIL_STALE_NODE"
@@ -455,10 +455,10 @@ export function countRecommendResultStatuses(results = [], {
455
455
  || item.error?.code === "IMAGE_CAPTURE_STALE_NODE"
456
456
  || item.error?.code === "IMAGE_CAPTURE_TIMEOUT"
457
457
  || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT"
458
- )).length
459
- };
460
- }
461
-
458
+ )).length
459
+ };
460
+ }
461
+
462
462
  function countPassedResults(results = []) {
463
463
  return countRecommendResultStatuses(results).passed;
464
464
  }
@@ -499,7 +499,7 @@ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
499
499
  }
500
500
  return result;
501
501
  }
502
-
502
+
503
503
  function createRecommendCloseFailureError(closeResult) {
504
504
  const error = new Error(closeResult?.reason || "Recommend detail did not close before recovery");
505
505
  error.code = "DETAIL_CLOSE_FAILED";
@@ -522,124 +522,124 @@ function createRecommendRefreshFailureError(refreshAttempt, {
522
522
  error.passed_count = passedCount;
523
523
  return error;
524
524
  }
525
-
526
- export function isRecoverableImageCaptureError(error) {
527
- const code = String(error?.code || "");
528
- if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
529
- if (isStaleRecommendNodeError(error)) return true;
530
- return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
531
- }
532
-
533
- function collectPartialImageEvidencePaths(basePath = "", extension = "jpg", maxCount = 12) {
534
- const resolved = String(basePath || "").trim();
535
- if (!resolved) return [];
536
- const parsed = path.parse(resolved);
537
- const ext = parsed.ext || `.${String(extension || "jpg").replace(/^\./, "") || "jpg"}`;
538
- const files = [];
539
- for (let index = 0; index < Math.max(1, Number(maxCount) || 1); index += 1) {
540
- const page = String(index + 1).padStart(2, "0");
541
- const candidatePath = path.join(parsed.dir, `${parsed.name}-page-${page}${ext}`);
542
- if (fs.existsSync(candidatePath)) files.push(candidatePath);
543
- }
544
- return files;
545
- }
546
-
547
- export function createRecoverableImageCaptureEvidence(error, {
548
- elapsedMs = 0,
549
- filePath = "",
550
- extension = "jpg",
551
- maxScreenshots = DEFAULT_MAX_IMAGE_PAGES
552
- } = {}) {
553
- const filePaths = collectPartialImageEvidencePaths(filePath, extension, maxScreenshots);
554
- return {
555
- schema_version: 1,
556
- ok: false,
557
- source: "image-scroll-sequence",
558
- elapsed_ms: Math.max(0, Math.round(Number(error?.elapsed_ms ?? elapsedMs) || 0)),
559
- capture_count: filePaths.length,
560
- screenshot_count: filePaths.length,
561
- unique_screenshot_count: filePaths.length,
562
- dropped_duplicate_count: 0,
563
- total_byte_length: 0,
564
- original_total_byte_length: 0,
565
- llm_screenshot_count: 0,
566
- llm_total_byte_length: 0,
567
- llm_original_total_byte_length: 0,
568
- llm_composition_error: null,
569
- error_code: error?.code || (isStaleRecommendNodeError(error) ? "IMAGE_CAPTURE_STALE_NODE" : "IMAGE_CAPTURE_FAILED"),
570
- error: error?.message || String(error || "Image capture failed"),
571
- file_paths: filePaths,
572
- llm_file_paths: []
573
- };
574
- }
575
-
576
- function createImageCaptureFailureScreening(candidate, error) {
577
- return {
578
- status: "fail",
579
- passed: false,
580
- score: 0,
581
- reasons: ["image_capture_failed"],
582
- error: compactError(error, "IMAGE_CAPTURE_FAILED"),
583
- candidate
584
- };
585
- }
586
-
525
+
526
+ export function isRecoverableImageCaptureError(error) {
527
+ const code = String(error?.code || "");
528
+ if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
529
+ if (isStaleRecommendNodeError(error)) return true;
530
+ return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
531
+ }
532
+
533
+ function collectPartialImageEvidencePaths(basePath = "", extension = "jpg", maxCount = 12) {
534
+ const resolved = String(basePath || "").trim();
535
+ if (!resolved) return [];
536
+ const parsed = path.parse(resolved);
537
+ const ext = parsed.ext || `.${String(extension || "jpg").replace(/^\./, "") || "jpg"}`;
538
+ const files = [];
539
+ for (let index = 0; index < Math.max(1, Number(maxCount) || 1); index += 1) {
540
+ const page = String(index + 1).padStart(2, "0");
541
+ const candidatePath = path.join(parsed.dir, `${parsed.name}-page-${page}${ext}`);
542
+ if (fs.existsSync(candidatePath)) files.push(candidatePath);
543
+ }
544
+ return files;
545
+ }
546
+
547
+ export function createRecoverableImageCaptureEvidence(error, {
548
+ elapsedMs = 0,
549
+ filePath = "",
550
+ extension = "jpg",
551
+ maxScreenshots = DEFAULT_MAX_IMAGE_PAGES
552
+ } = {}) {
553
+ const filePaths = collectPartialImageEvidencePaths(filePath, extension, maxScreenshots);
554
+ return {
555
+ schema_version: 1,
556
+ ok: false,
557
+ source: "image-scroll-sequence",
558
+ elapsed_ms: Math.max(0, Math.round(Number(error?.elapsed_ms ?? elapsedMs) || 0)),
559
+ capture_count: filePaths.length,
560
+ screenshot_count: filePaths.length,
561
+ unique_screenshot_count: filePaths.length,
562
+ dropped_duplicate_count: 0,
563
+ total_byte_length: 0,
564
+ original_total_byte_length: 0,
565
+ llm_screenshot_count: 0,
566
+ llm_total_byte_length: 0,
567
+ llm_original_total_byte_length: 0,
568
+ llm_composition_error: null,
569
+ error_code: error?.code || (isStaleRecommendNodeError(error) ? "IMAGE_CAPTURE_STALE_NODE" : "IMAGE_CAPTURE_FAILED"),
570
+ error: error?.message || String(error || "Image capture failed"),
571
+ file_paths: filePaths,
572
+ llm_file_paths: []
573
+ };
574
+ }
575
+
576
+ function createImageCaptureFailureScreening(candidate, error) {
577
+ return {
578
+ status: "fail",
579
+ passed: false,
580
+ score: 0,
581
+ reasons: ["image_capture_failed"],
582
+ error: compactError(error, "IMAGE_CAPTURE_FAILED"),
583
+ candidate
584
+ };
585
+ }
586
+
587
587
  export function isRecoverableRecommendDetailError(error) {
588
588
  return isStaleRecommendNodeError(error) || isRecommendDetailOpenMissError(error);
589
589
  }
590
-
591
- function compactRecoverableDetailError(error) {
592
- return compactError(error, isStaleRecommendNodeError(error) ? "DETAIL_STALE_NODE" : "DETAIL_OPEN_FAILED");
593
- }
594
-
595
- function createRecoverableDetailFailureScreening(candidate, error) {
596
- return {
597
- status: "fail",
598
- passed: false,
590
+
591
+ function compactRecoverableDetailError(error) {
592
+ return compactError(error, isStaleRecommendNodeError(error) ? "DETAIL_STALE_NODE" : "DETAIL_OPEN_FAILED");
593
+ }
594
+
595
+ function createRecoverableDetailFailureScreening(candidate, error) {
596
+ return {
597
+ status: "fail",
598
+ passed: false,
599
599
  score: 0,
600
600
  reasons: isStaleRecommendNodeError(error)
601
601
  ? ["detail_open_failed", "stale_node"]
602
602
  : isRecommendDetailOpenMissError(error)
603
603
  ? ["detail_open_failed", "detail_open_miss"]
604
604
  : ["detail_open_failed"],
605
- error: compactRecoverableDetailError(error),
606
- candidate
607
- };
608
- }
609
-
610
- export async function runRecommendWorkflow({
611
- client,
612
- targetUrl = "",
613
- criteria = "",
614
- jobLabel = "",
615
- pageScope = "recommend",
616
- fallbackPageScope = "recommend",
617
- filter = {},
618
- maxCandidates = 5,
619
- detailLimit,
620
- closeDetail = true,
621
- delayMs = 0,
622
- cardTimeoutMs = 10000,
623
- maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
624
- imageWheelDeltaY = 650,
625
- cvAcquisitionMode = "unknown",
626
- listMaxScrolls = 20,
627
- listStableSignatureLimit = 5,
628
- listWheelDeltaY = 850,
629
- listSettleMs = 2200,
630
- listFallbackPoint = null,
631
- refreshOnEnd = true,
632
- maxRefreshRounds = 2,
633
- refreshButtonSettleMs = 8000,
634
- refreshReloadSettleMs = 8000,
635
- postAction = "none",
636
- maxGreetCount = null,
637
- executePostAction = true,
638
- actionTimeoutMs = 8000,
639
- actionIntervalMs = 500,
640
- actionAfterClickDelayMs = 900,
641
- screeningMode = "llm",
642
- llmConfig = null,
605
+ error: compactRecoverableDetailError(error),
606
+ candidate
607
+ };
608
+ }
609
+
610
+ export async function runRecommendWorkflow({
611
+ client,
612
+ targetUrl = "",
613
+ criteria = "",
614
+ jobLabel = "",
615
+ pageScope = "recommend",
616
+ fallbackPageScope = "recommend",
617
+ filter = {},
618
+ maxCandidates = 5,
619
+ detailLimit,
620
+ closeDetail = true,
621
+ delayMs = 0,
622
+ cardTimeoutMs = 10000,
623
+ maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
624
+ imageWheelDeltaY = 650,
625
+ cvAcquisitionMode = "unknown",
626
+ listMaxScrolls = 20,
627
+ listStableSignatureLimit = 5,
628
+ listWheelDeltaY = 850,
629
+ listSettleMs = 2200,
630
+ listFallbackPoint = null,
631
+ refreshOnEnd = true,
632
+ maxRefreshRounds = 2,
633
+ refreshButtonSettleMs = 8000,
634
+ refreshReloadSettleMs = 8000,
635
+ postAction = "none",
636
+ maxGreetCount = null,
637
+ executePostAction = true,
638
+ actionTimeoutMs = 8000,
639
+ actionIntervalMs = 500,
640
+ actionAfterClickDelayMs = 900,
641
+ screeningMode = "llm",
642
+ llmConfig = null,
643
643
  llmTimeoutMs = 120000,
644
644
  llmImageLimit = 8,
645
645
  llmImageDetail = "high",
@@ -665,54 +665,54 @@ export async function runRecommendWorkflow({
665
665
  batchRestEnabled: effectiveHumanBehavior.batchRest
666
666
  });
667
667
  const normalizedFilter = normalizeFilter(filter);
668
- const normalizedPostAction = normalizeRecommendPostAction(postAction) || "none";
669
- const requestedPageScope = normalizeRecommendPageScope(pageScope) || "recommend";
670
- const normalizedFallbackPageScope = normalizeRecommendPageScope(fallbackPageScope) || "recommend";
671
- const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
672
- const useLlmScreening = normalizedScreeningMode !== "deterministic";
673
- const postActionEnabled = normalizedPostAction !== "none";
674
- const targetPassCount = Math.max(1, Number(maxCandidates) || 1);
675
- const detailCountLimit = detailLimit == null ? Number.POSITIVE_INFINITY : Math.max(0, Number(detailLimit) || 0);
676
- const effectiveDetailLimit = postActionEnabled ? Number.POSITIVE_INFINITY : detailCountLimit;
677
- const networkRecorder = effectiveDetailLimit > 0
678
- ? createRecommendDetailNetworkRecorder(client)
679
- : null;
680
- const cvAcquisitionState = createCvAcquisitionState({ mode: cvAcquisitionMode });
681
- const listState = createInfiniteListState({
682
- domain: "recommend",
683
- listName: "recommend-candidates"
684
- });
685
- const viewportGuard = createViewportRunGuard({
686
- client,
687
- domain: "recommend",
688
- root: "frame",
689
- frameOwnerRoot: "frameOwner",
690
- runControl,
691
- getRoots: getRecommendRoots
692
- });
693
- async function ensureRecommendViewport(rootState, phase) {
694
- const result = await viewportGuard.ensure(rootState, { phase });
695
- return result.rootState || rootState;
696
- }
697
- const results = [];
698
- const refreshAttempts = [];
699
- let refreshRounds = 0;
700
- let contextRecoveryAttempts = 0;
701
- let greetCount = 0;
702
- const candidateRecoveryCounts = new Map();
703
- let jobSelection = null;
668
+ const normalizedPostAction = normalizeRecommendPostAction(postAction) || "none";
669
+ const requestedPageScope = normalizeRecommendPageScope(pageScope) || "recommend";
670
+ const normalizedFallbackPageScope = normalizeRecommendPageScope(fallbackPageScope) || "recommend";
671
+ const normalizedScreeningMode = normalizeScreeningMode(screeningMode);
672
+ const useLlmScreening = normalizedScreeningMode !== "deterministic";
673
+ const postActionEnabled = normalizedPostAction !== "none";
674
+ const targetPassCount = Math.max(1, Number(maxCandidates) || 1);
675
+ const detailCountLimit = detailLimit == null ? Number.POSITIVE_INFINITY : Math.max(0, Number(detailLimit) || 0);
676
+ const effectiveDetailLimit = postActionEnabled ? Number.POSITIVE_INFINITY : detailCountLimit;
677
+ const networkRecorder = effectiveDetailLimit > 0
678
+ ? createRecommendDetailNetworkRecorder(client)
679
+ : null;
680
+ const cvAcquisitionState = createCvAcquisitionState({ mode: cvAcquisitionMode });
681
+ const listState = createInfiniteListState({
682
+ domain: "recommend",
683
+ listName: "recommend-candidates"
684
+ });
685
+ const viewportGuard = createViewportRunGuard({
686
+ client,
687
+ domain: "recommend",
688
+ root: "frame",
689
+ frameOwnerRoot: "frameOwner",
690
+ runControl,
691
+ getRoots: getRecommendRoots
692
+ });
693
+ async function ensureRecommendViewport(rootState, phase) {
694
+ const result = await viewportGuard.ensure(rootState, { phase });
695
+ return result.rootState || rootState;
696
+ }
697
+ const results = [];
698
+ const refreshAttempts = [];
699
+ let refreshRounds = 0;
700
+ let contextRecoveryAttempts = 0;
701
+ let greetCount = 0;
702
+ const candidateRecoveryCounts = new Map();
703
+ let jobSelection = null;
704
704
  let pageScopeSelection = null;
705
705
  let filterResult = null;
706
706
  let cardNodeIds = [];
707
707
  let listEndReason = "";
708
708
  let lastHumanEvent = null;
709
709
  const listFallbackResolver = listFallbackPoint || (async ({ items = [] } = {}) => resolveInfiniteListFallbackPoint(client, {
710
- rootNodeId: rootState?.iframe?.documentNodeId,
711
- containerSelectors: RECOMMEND_LIST_CONTAINER_SELECTORS,
712
- itemNodeIds: items.map((item) => item.node_id).filter(Boolean),
713
- itemSelectors: [RECOMMEND_CARD_SELECTOR],
714
- viewportPoint: { xRatio: 0.28, yRatio: 0.5 },
715
- validateViewportPoint: true
710
+ rootNodeId: rootState?.iframe?.documentNodeId,
711
+ containerSelectors: RECOMMEND_LIST_CONTAINER_SELECTORS,
712
+ itemNodeIds: items.map((item) => item.node_id).filter(Boolean),
713
+ itemSelectors: [RECOMMEND_CARD_SELECTOR],
714
+ viewportPoint: { xRatio: 0.28, yRatio: 0.5 },
715
+ validateViewportPoint: true
716
716
  }));
717
717
 
718
718
  function recordHumanEvent(event = null) {
@@ -749,13 +749,13 @@ export async function runRecommendWorkflow({
749
749
  card_count: cardNodeIds.length,
750
750
  target_count: targetPassCount,
751
751
  target_count_semantics: "passed_candidates",
752
- ...counts,
753
- screening_mode: normalizedScreeningMode,
754
- unique_seen: listSnapshot.seen_count,
755
- scroll_count: listSnapshot.scroll_count,
756
- refresh_rounds: refreshRounds,
757
- refresh_attempts: refreshAttempts.length,
758
- context_recoveries: contextRecoveryAttempts,
752
+ ...counts,
753
+ screening_mode: normalizedScreeningMode,
754
+ unique_seen: listSnapshot.seen_count,
755
+ scroll_count: listSnapshot.scroll_count,
756
+ refresh_rounds: refreshRounds,
757
+ refresh_attempts: refreshAttempts.length,
758
+ context_recoveries: contextRecoveryAttempts,
759
759
  list_end_reason: listEndReason || null,
760
760
  viewport_checks: viewportGuard.getStats().checks,
761
761
  viewport_recoveries: viewportGuard.getStats().recoveries,
@@ -768,280 +768,280 @@ export async function runRecommendWorkflow({
768
768
  ...extra
769
769
  });
770
770
  }
771
-
772
- function checkpointInProgressCandidate({
773
- index = results.length,
774
- candidateKey = "",
775
- cardNodeId = null,
776
- detailStep = "",
777
- error = null
778
- } = {}) {
779
- runControl.checkpoint({
780
- in_progress_candidate: {
781
- index,
782
- key: candidateKey,
783
- card_node_id: cardNodeId,
784
- detail_step: detailStep || null,
785
- counters: countRecommendResultStatuses(results, { greetCount }),
786
- error: compactError(error, "RECOMMEND_IN_PROGRESS_ERROR")
787
- },
788
- candidate_list: compactInfiniteListState(listState)
789
- });
790
- }
791
-
792
- async function recoverAndReapplyRecommendContext(reason = "context_recovery", error = null, {
793
- forceRecentNotView = true
794
- } = {}) {
795
- await runControl.waitIfPaused();
796
- runControl.throwIfCanceled();
797
- const started = Date.now();
798
- runControl.setPhase("recommend:recover-context");
799
- contextRecoveryAttempts += 1;
800
- const refreshResult = await refreshRecommendListAtEnd(client, {
801
- rootState,
802
- jobLabel,
803
- pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
804
- fallbackPageScope: normalizedFallbackPageScope,
805
- filter: normalizedFilter,
806
- preferEndRefreshButton: false,
807
- forceNavigate: true,
808
- targetUrl: targetUrl || RECOMMEND_TARGET_URL,
809
- forceRecentNotView,
810
- cardTimeoutMs,
811
- buttonSettleMs: refreshButtonSettleMs,
812
- reloadSettleMs: refreshReloadSettleMs
813
- });
814
- const compactRefresh = {
815
- ...compactRefreshAttempt(refreshResult),
816
- context_recovery: true,
817
- recovery_reason: reason,
818
- trigger_error: compactError(error, "RECOMMEND_CONTEXT_RECOVERY_TRIGGER"),
819
- elapsed_ms: Date.now() - started
820
- };
821
- refreshAttempts.push(compactRefresh);
822
- runControl.checkpoint({
823
- context_recovery: {
824
- attempt: contextRecoveryAttempts,
825
- reason,
826
- trigger_error: compactError(error, "RECOMMEND_CONTEXT_RECOVERY_TRIGGER"),
827
- refresh: compactRefresh,
828
- counters: countRecommendResultStatuses(results, { greetCount })
829
- },
830
- candidate_list: compactInfiniteListState(listState)
831
- });
832
- if (!refreshResult.ok) {
833
- updateRecommendProgress({
834
- refresh_method: refreshResult.method || null,
835
- refresh_forced_recent_not_view: forceRecentNotView,
836
- recovery_reason: reason
837
- });
838
- throw new Error(`Recommend context recovery failed after ${reason}: ${refreshResult.reason || refreshResult.error || "refresh returned no cards"}`);
839
- }
840
- rootState = refreshResult.root_state || await getRecommendRoots(client);
841
- rootState = await ensureRecommendViewport(rootState, "recover_after");
842
- cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
843
- timeoutMs: cardTimeoutMs,
844
- intervalMs: 300
845
- });
846
- resetInfiniteListForRefreshRound(listState, {
847
- reason: `context_recovery:${reason}`,
848
- round: contextRecoveryAttempts,
849
- method: refreshResult.method,
850
- metadata: {
851
- card_count: cardNodeIds.length,
852
- forced_recent_not_view: forceRecentNotView,
853
- counters: countRecommendResultStatuses(results, { greetCount })
854
- }
855
- });
856
- listEndReason = "";
857
- updateRecommendProgress({
858
- card_count: cardNodeIds.length,
859
- refresh_method: refreshResult.method || null,
860
- refresh_forced_recent_not_view: forceRecentNotView,
861
- recovery_reason: reason
862
- });
863
- return refreshResult;
864
- }
865
-
866
- runControl.setPhase("recommend:cleanup");
867
- await closeRecommendDetail(client, { attemptsLimit: 2 });
868
-
869
- await runControl.waitIfPaused();
870
- runControl.throwIfCanceled();
871
- runControl.setPhase("recommend:roots");
872
- let rootState = await getRecommendRoots(client);
873
- rootState = await ensureRecommendViewport(rootState, "roots");
874
- runControl.checkpoint({
875
- iframe_selector: rootState.iframe.selector,
876
- iframe_document_node_id: rootState.iframe.documentNodeId
877
- });
878
-
879
- if (jobLabel) {
880
- await runControl.waitIfPaused();
881
- runControl.throwIfCanceled();
882
- runControl.setPhase("recommend:job");
883
- jobSelection = await selectRecommendJob(client, rootState.iframe.documentNodeId, {
884
- jobLabel,
885
- settleMs: cardTimeoutMs > 45000 ? 12000 : 6000
886
- });
887
- if (!jobSelection.selected) {
888
- throw new Error(`Requested recommend job was not selected: ${jobSelection.reason}`);
889
- }
890
- rootState = await getRecommendRoots(client);
891
- rootState = await ensureRecommendViewport(rootState, "job");
892
- runControl.checkpoint({
893
- job_selection: compactJobSelection(jobSelection)
894
- });
895
- }
896
-
897
- await runControl.waitIfPaused();
898
- runControl.throwIfCanceled();
899
- runControl.setPhase("recommend:page-scope");
900
- pageScopeSelection = await selectRecommendPageScope(client, rootState.iframe.documentNodeId, {
901
- pageScope: requestedPageScope,
902
- fallbackScope: normalizedFallbackPageScope,
903
- settleMs: cardTimeoutMs > 45000 ? 3000 : 1200,
904
- timeoutMs: Math.min(Math.max(cardTimeoutMs, 10000), 60000)
905
- });
906
- if (!pageScopeSelection.selected) {
907
- throw new Error(`Recommend page scope was not selected: ${pageScopeSelection.reason || pageScopeSelection.effective_scope || requestedPageScope}`);
908
- }
909
- rootState = await getRecommendRoots(client);
910
- rootState = await ensureRecommendViewport(rootState, "page_scope");
911
- runControl.checkpoint({
912
- page_scope: compactPageScopeSelection(pageScopeSelection)
913
- });
914
-
915
- if (normalizedFilter.enabled) {
916
- await runControl.waitIfPaused();
917
- runControl.throwIfCanceled();
918
- runControl.setPhase("recommend:filter");
919
- filterResult = await selectAndConfirmFirstSafeFilter(
920
- client,
921
- rootState.iframe.documentNodeId,
922
- buildRecommendFilterSelectionOptions(normalizedFilter)
923
- );
924
- if (!filterResult.confirmed) {
925
- throw new Error("Recommend run filter selection was not confirmed");
926
- }
927
- rootState = await getRecommendRoots(client);
928
- rootState = await ensureRecommendViewport(rootState, "filter");
929
- runControl.checkpoint({
930
- filter: compactFilterResult(filterResult)
931
- });
932
- }
933
-
934
- await runControl.waitIfPaused();
935
- runControl.throwIfCanceled();
936
- runControl.setPhase("recommend:cards");
937
- rootState = await ensureRecommendViewport(rootState, "cards");
938
- cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
939
- timeoutMs: cardTimeoutMs,
940
- intervalMs: 300
941
- });
942
- if (!cardNodeIds.length) {
943
- throw new Error("No recommend candidate cards found for run service");
944
- }
945
-
946
- updateRecommendProgress({
947
- list_end_reason: null
948
- });
949
-
950
- while (countPassedResults(results) < targetPassCount) {
951
- const candidateStarted = Date.now();
952
- const timings = {};
953
- await runControl.waitIfPaused();
954
- runControl.throwIfCanceled();
955
- runControl.setPhase("recommend:candidate");
956
- rootState = await ensureRecommendViewport(rootState, "candidate_loop");
957
-
958
- const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
959
- client,
960
- state: listState,
961
- maxScrolls: listMaxScrolls,
771
+
772
+ function checkpointInProgressCandidate({
773
+ index = results.length,
774
+ candidateKey = "",
775
+ cardNodeId = null,
776
+ detailStep = "",
777
+ error = null
778
+ } = {}) {
779
+ runControl.checkpoint({
780
+ in_progress_candidate: {
781
+ index,
782
+ key: candidateKey,
783
+ card_node_id: cardNodeId,
784
+ detail_step: detailStep || null,
785
+ counters: countRecommendResultStatuses(results, { greetCount }),
786
+ error: compactError(error, "RECOMMEND_IN_PROGRESS_ERROR")
787
+ },
788
+ candidate_list: compactInfiniteListState(listState)
789
+ });
790
+ }
791
+
792
+ async function recoverAndReapplyRecommendContext(reason = "context_recovery", error = null, {
793
+ forceRecentNotView = true
794
+ } = {}) {
795
+ await runControl.waitIfPaused();
796
+ runControl.throwIfCanceled();
797
+ const started = Date.now();
798
+ runControl.setPhase("recommend:recover-context");
799
+ contextRecoveryAttempts += 1;
800
+ const refreshResult = await refreshRecommendListAtEnd(client, {
801
+ rootState,
802
+ jobLabel,
803
+ pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
804
+ fallbackPageScope: normalizedFallbackPageScope,
805
+ filter: normalizedFilter,
806
+ preferEndRefreshButton: false,
807
+ forceNavigate: true,
808
+ targetUrl: targetUrl || RECOMMEND_TARGET_URL,
809
+ forceRecentNotView,
810
+ cardTimeoutMs,
811
+ buttonSettleMs: refreshButtonSettleMs,
812
+ reloadSettleMs: refreshReloadSettleMs
813
+ });
814
+ const compactRefresh = {
815
+ ...compactRefreshAttempt(refreshResult),
816
+ context_recovery: true,
817
+ recovery_reason: reason,
818
+ trigger_error: compactError(error, "RECOMMEND_CONTEXT_RECOVERY_TRIGGER"),
819
+ elapsed_ms: Date.now() - started
820
+ };
821
+ refreshAttempts.push(compactRefresh);
822
+ runControl.checkpoint({
823
+ context_recovery: {
824
+ attempt: contextRecoveryAttempts,
825
+ reason,
826
+ trigger_error: compactError(error, "RECOMMEND_CONTEXT_RECOVERY_TRIGGER"),
827
+ refresh: compactRefresh,
828
+ counters: countRecommendResultStatuses(results, { greetCount })
829
+ },
830
+ candidate_list: compactInfiniteListState(listState)
831
+ });
832
+ if (!refreshResult.ok) {
833
+ updateRecommendProgress({
834
+ refresh_method: refreshResult.method || null,
835
+ refresh_forced_recent_not_view: forceRecentNotView,
836
+ recovery_reason: reason
837
+ });
838
+ throw new Error(`Recommend context recovery failed after ${reason}: ${refreshResult.reason || refreshResult.error || "refresh returned no cards"}`);
839
+ }
840
+ rootState = refreshResult.root_state || await getRecommendRoots(client);
841
+ rootState = await ensureRecommendViewport(rootState, "recover_after");
842
+ cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
843
+ timeoutMs: cardTimeoutMs,
844
+ intervalMs: 300
845
+ });
846
+ resetInfiniteListForRefreshRound(listState, {
847
+ reason: `context_recovery:${reason}`,
848
+ round: contextRecoveryAttempts,
849
+ method: refreshResult.method,
850
+ metadata: {
851
+ card_count: cardNodeIds.length,
852
+ forced_recent_not_view: forceRecentNotView,
853
+ counters: countRecommendResultStatuses(results, { greetCount })
854
+ }
855
+ });
856
+ listEndReason = "";
857
+ updateRecommendProgress({
858
+ card_count: cardNodeIds.length,
859
+ refresh_method: refreshResult.method || null,
860
+ refresh_forced_recent_not_view: forceRecentNotView,
861
+ recovery_reason: reason
862
+ });
863
+ return refreshResult;
864
+ }
865
+
866
+ runControl.setPhase("recommend:cleanup");
867
+ await closeRecommendDetail(client, { attemptsLimit: 2 });
868
+
869
+ await runControl.waitIfPaused();
870
+ runControl.throwIfCanceled();
871
+ runControl.setPhase("recommend:roots");
872
+ let rootState = await getRecommendRoots(client);
873
+ rootState = await ensureRecommendViewport(rootState, "roots");
874
+ runControl.checkpoint({
875
+ iframe_selector: rootState.iframe.selector,
876
+ iframe_document_node_id: rootState.iframe.documentNodeId
877
+ });
878
+
879
+ if (jobLabel) {
880
+ await runControl.waitIfPaused();
881
+ runControl.throwIfCanceled();
882
+ runControl.setPhase("recommend:job");
883
+ jobSelection = await selectRecommendJob(client, rootState.iframe.documentNodeId, {
884
+ jobLabel,
885
+ settleMs: cardTimeoutMs > 45000 ? 12000 : 6000
886
+ });
887
+ if (!jobSelection.selected) {
888
+ throw new Error(`Requested recommend job was not selected: ${jobSelection.reason}`);
889
+ }
890
+ rootState = await getRecommendRoots(client);
891
+ rootState = await ensureRecommendViewport(rootState, "job");
892
+ runControl.checkpoint({
893
+ job_selection: compactJobSelection(jobSelection)
894
+ });
895
+ }
896
+
897
+ await runControl.waitIfPaused();
898
+ runControl.throwIfCanceled();
899
+ runControl.setPhase("recommend:page-scope");
900
+ pageScopeSelection = await selectRecommendPageScope(client, rootState.iframe.documentNodeId, {
901
+ pageScope: requestedPageScope,
902
+ fallbackScope: normalizedFallbackPageScope,
903
+ settleMs: cardTimeoutMs > 45000 ? 3000 : 1200,
904
+ timeoutMs: Math.min(Math.max(cardTimeoutMs, 10000), 60000)
905
+ });
906
+ if (!pageScopeSelection.selected) {
907
+ throw new Error(`Recommend page scope was not selected: ${pageScopeSelection.reason || pageScopeSelection.effective_scope || requestedPageScope}`);
908
+ }
909
+ rootState = await getRecommendRoots(client);
910
+ rootState = await ensureRecommendViewport(rootState, "page_scope");
911
+ runControl.checkpoint({
912
+ page_scope: compactPageScopeSelection(pageScopeSelection)
913
+ });
914
+
915
+ if (normalizedFilter.enabled) {
916
+ await runControl.waitIfPaused();
917
+ runControl.throwIfCanceled();
918
+ runControl.setPhase("recommend:filter");
919
+ filterResult = await selectAndConfirmFirstSafeFilter(
920
+ client,
921
+ rootState.iframe.documentNodeId,
922
+ buildRecommendFilterSelectionOptions(normalizedFilter)
923
+ );
924
+ if (!filterResult.confirmed) {
925
+ throw new Error("Recommend run filter selection was not confirmed");
926
+ }
927
+ rootState = await getRecommendRoots(client);
928
+ rootState = await ensureRecommendViewport(rootState, "filter");
929
+ runControl.checkpoint({
930
+ filter: compactFilterResult(filterResult)
931
+ });
932
+ }
933
+
934
+ await runControl.waitIfPaused();
935
+ runControl.throwIfCanceled();
936
+ runControl.setPhase("recommend:cards");
937
+ rootState = await ensureRecommendViewport(rootState, "cards");
938
+ cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
939
+ timeoutMs: cardTimeoutMs,
940
+ intervalMs: 300
941
+ });
942
+ if (!cardNodeIds.length) {
943
+ throw new Error("No recommend candidate cards found for run service");
944
+ }
945
+
946
+ updateRecommendProgress({
947
+ list_end_reason: null
948
+ });
949
+
950
+ while (countPassedResults(results) < targetPassCount) {
951
+ const candidateStarted = Date.now();
952
+ const timings = {};
953
+ await runControl.waitIfPaused();
954
+ runControl.throwIfCanceled();
955
+ runControl.setPhase("recommend:candidate");
956
+ rootState = await ensureRecommendViewport(rootState, "candidate_loop");
957
+
958
+ const nextCandidateResult = await measureTiming(timings, "card_read_ms", () => getNextInfiniteListCandidate({
959
+ client,
960
+ state: listState,
961
+ maxScrolls: listMaxScrolls,
962
962
  stableSignatureLimit: listStableSignatureLimit,
963
963
  wheelDeltaY: listWheelDeltaY,
964
964
  settleMs: listSettleMs,
965
965
  listScrollJitterEnabled: effectiveHumanBehavior.listScrollJitter,
966
966
  fallbackPoint: listFallbackResolver,
967
- findNodeIds: async () => {
968
- let currentRootState = await getRecommendRoots(client);
969
- currentRootState = await ensureRecommendViewport(currentRootState, "candidate_find_nodes");
970
- rootState = currentRootState;
971
- const currentCardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
972
- timeoutMs: Math.min(cardTimeoutMs, 5000),
973
- intervalMs: 300
974
- });
975
- cardNodeIds = currentCardNodeIds;
976
- return currentCardNodeIds;
977
- },
978
- readCandidate: async (nodeId, { visibleIndex }) => readRecommendCardCandidate(client, nodeId, {
979
- targetUrl,
980
- source: "recommend-run-card",
981
- metadata: {
982
- run_candidate_index: results.length,
983
- visible_index: visibleIndex
984
- }
985
- }),
986
- detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
987
- rootNodeId: rootState?.iframe?.documentNodeId,
988
- markerSelectors: RECOMMEND_BOTTOM_MARKER_SELECTORS,
989
- refreshSelectors: [RECOMMEND_END_REFRESH_SELECTOR],
990
- textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
991
- maxTextScanNodes: 500
992
- })
993
- }));
994
- if (!nextCandidateResult.ok) {
995
- listEndReason = nextCandidateResult.reason || "list_exhausted";
996
- if (
997
- (nextCandidateResult.end_reached || isRefreshableListStall(nextCandidateResult.reason))
998
- && refreshOnEnd
999
- && countPassedResults(results) < targetPassCount
1000
- && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
1001
- ) {
1002
- await runControl.waitIfPaused();
1003
- runControl.throwIfCanceled();
1004
- runControl.setPhase("recommend:refresh");
1005
- refreshRounds += 1;
1006
- const refreshResult = await refreshRecommendListAtEnd(client, {
1007
- rootState,
1008
- jobLabel,
1009
- pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
1010
- fallbackPageScope: normalizedFallbackPageScope,
1011
- filter: normalizedFilter,
1012
- forceRecentNotView: true,
1013
- cardTimeoutMs,
1014
- buttonSettleMs: refreshButtonSettleMs,
1015
- reloadSettleMs: refreshReloadSettleMs
1016
- });
1017
- const compactRefresh = compactRefreshAttempt(refreshResult);
1018
- refreshAttempts.push(compactRefresh);
1019
- runControl.checkpoint({
1020
- refresh_round: refreshRounds,
1021
- refresh: compactRefresh
1022
- });
1023
- updateRecommendProgress({
1024
- card_count: refreshResult.card_count || cardNodeIds.length,
1025
- refresh_method: refreshResult.method || null,
1026
- refresh_forced_recent_not_view: true,
1027
- list_end_reason: listEndReason
1028
- });
967
+ findNodeIds: async () => {
968
+ let currentRootState = await getRecommendRoots(client);
969
+ currentRootState = await ensureRecommendViewport(currentRootState, "candidate_find_nodes");
970
+ rootState = currentRootState;
971
+ const currentCardNodeIds = await waitForRecommendCardNodeIds(client, currentRootState.iframe.documentNodeId, {
972
+ timeoutMs: Math.min(cardTimeoutMs, 5000),
973
+ intervalMs: 300
974
+ });
975
+ cardNodeIds = currentCardNodeIds;
976
+ return currentCardNodeIds;
977
+ },
978
+ readCandidate: async (nodeId, { visibleIndex }) => readRecommendCardCandidate(client, nodeId, {
979
+ targetUrl,
980
+ source: "recommend-run-card",
981
+ metadata: {
982
+ run_candidate_index: results.length,
983
+ visible_index: visibleIndex
984
+ }
985
+ }),
986
+ detectBottomMarker: async ({ scrollAttempt = 0, signature = {} } = {}) => detectInfiniteListBottomMarker(client, {
987
+ rootNodeId: rootState?.iframe?.documentNodeId,
988
+ markerSelectors: RECOMMEND_BOTTOM_MARKER_SELECTORS,
989
+ refreshSelectors: [RECOMMEND_END_REFRESH_SELECTOR],
990
+ textScanSelectors: scrollAttempt > 0 || (signature?.stable_signature_count || 0) >= 2 ? undefined : [],
991
+ maxTextScanNodes: 500
992
+ })
993
+ }));
994
+ if (!nextCandidateResult.ok) {
995
+ listEndReason = nextCandidateResult.reason || "list_exhausted";
996
+ if (
997
+ (nextCandidateResult.end_reached || isRefreshableListStall(nextCandidateResult.reason))
998
+ && refreshOnEnd
999
+ && countPassedResults(results) < targetPassCount
1000
+ && refreshRounds < Math.max(0, Number(maxRefreshRounds) || 0)
1001
+ ) {
1002
+ await runControl.waitIfPaused();
1003
+ runControl.throwIfCanceled();
1004
+ runControl.setPhase("recommend:refresh");
1005
+ refreshRounds += 1;
1006
+ const refreshResult = await refreshRecommendListAtEnd(client, {
1007
+ rootState,
1008
+ jobLabel,
1009
+ pageScope: pageScopeSelection?.effective_scope || requestedPageScope,
1010
+ fallbackPageScope: normalizedFallbackPageScope,
1011
+ filter: normalizedFilter,
1012
+ forceRecentNotView: true,
1013
+ cardTimeoutMs,
1014
+ buttonSettleMs: refreshButtonSettleMs,
1015
+ reloadSettleMs: refreshReloadSettleMs
1016
+ });
1017
+ const compactRefresh = compactRefreshAttempt(refreshResult);
1018
+ refreshAttempts.push(compactRefresh);
1019
+ runControl.checkpoint({
1020
+ refresh_round: refreshRounds,
1021
+ refresh: compactRefresh
1022
+ });
1023
+ updateRecommendProgress({
1024
+ card_count: refreshResult.card_count || cardNodeIds.length,
1025
+ refresh_method: refreshResult.method || null,
1026
+ refresh_forced_recent_not_view: true,
1027
+ list_end_reason: listEndReason
1028
+ });
1029
1029
  if (refreshResult.ok) {
1030
1030
  rootState = refreshResult.root_state || await getRecommendRoots(client);
1031
1031
  rootState = await ensureRecommendViewport(rootState, "refresh_after");
1032
- cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
1033
- timeoutMs: cardTimeoutMs,
1034
- intervalMs: 300
1035
- });
1036
- resetInfiniteListForRefreshRound(listState, {
1037
- reason: listEndReason,
1038
- round: refreshRounds,
1039
- method: refreshResult.method,
1040
- metadata: {
1041
- card_count: cardNodeIds.length,
1042
- forced_recent_not_view: true
1043
- }
1044
- });
1032
+ cardNodeIds = await waitForRecommendCardNodeIds(client, rootState.iframe.documentNodeId, {
1033
+ timeoutMs: cardTimeoutMs,
1034
+ intervalMs: 300
1035
+ });
1036
+ resetInfiniteListForRefreshRound(listState, {
1037
+ reason: listEndReason,
1038
+ round: refreshRounds,
1039
+ method: refreshResult.method,
1040
+ metadata: {
1041
+ card_count: cardNodeIds.length,
1042
+ forced_recent_not_view: true
1043
+ }
1044
+ });
1045
1045
  listEndReason = "";
1046
1046
  continue;
1047
1047
  }
@@ -1053,240 +1053,240 @@ export async function runRecommendWorkflow({
1053
1053
  }
1054
1054
  break;
1055
1055
  }
1056
-
1057
- const index = results.length;
1058
- let cardNodeId = nextCandidateResult.item.node_id;
1059
- const candidateKey = nextCandidateResult.item.key;
1060
- let cardCandidate = nextCandidateResult.item.candidate;
1061
-
1062
- let screeningCandidate = cardCandidate;
1063
- let detailResult = null;
1064
- let recoverableDetailError = null;
1065
- let detailStep = "not_started";
1066
- if (index < effectiveDetailLimit) {
1067
- try {
1068
- await runControl.waitIfPaused();
1069
- runControl.throwIfCanceled();
1070
- runControl.setPhase("recommend:detail");
1071
- detailStep = "ensure_viewport";
1072
- rootState = await ensureRecommendViewport(rootState, "detail");
1056
+
1057
+ const index = results.length;
1058
+ let cardNodeId = nextCandidateResult.item.node_id;
1059
+ const candidateKey = nextCandidateResult.item.key;
1060
+ let cardCandidate = nextCandidateResult.item.candidate;
1061
+
1062
+ let screeningCandidate = cardCandidate;
1063
+ let detailResult = null;
1064
+ let recoverableDetailError = null;
1065
+ let detailStep = "not_started";
1066
+ if (index < effectiveDetailLimit) {
1067
+ try {
1068
+ await runControl.waitIfPaused();
1069
+ runControl.throwIfCanceled();
1070
+ runControl.setPhase("recommend:detail");
1071
+ detailStep = "ensure_viewport";
1072
+ rootState = await ensureRecommendViewport(rootState, "detail");
1073
1073
  checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep });
1074
1074
  detailStep = "open_detail";
1075
1075
  networkRecorder.clear();
1076
1076
  await maybeHumanActionCooldown("before_detail_open", timings);
1077
1077
  const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
1078
- cardNodeId,
1079
- candidateKey,
1080
- cardCandidate,
1081
- rootState,
1082
- targetUrl,
1083
- retryTimeoutMs: 8000,
1084
- maxAttempts: 3
1085
- });
1086
- addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
1087
- addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
1088
- cardNodeId = openedDetail.card_node_id || cardNodeId;
1089
- cardCandidate = openedDetail.card_candidate || cardCandidate;
1090
- screeningCandidate = cardCandidate;
1091
- const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
1092
- detailStep = "wait_network";
1093
- const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
1094
- waitForRecommendDetailNetworkEvents,
1095
- networkRecorder,
1096
- {
1097
- waitPlan,
1098
- minCount: 1,
1099
- requireLoaded: true,
1100
- intervalMs: 120
1101
- }
1102
- ));
1103
- if (networkWait?.elapsed_ms != null) {
1104
- timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
1105
- }
1106
- detailStep = "extract_detail";
1107
- detailResult = await extractRecommendDetailCandidate(client, {
1108
- cardCandidate,
1109
- cardNodeId,
1110
- detailState: openedDetail.detail_state,
1111
- networkEvents: networkRecorder.events,
1112
- targetUrl,
1113
- closeDetail: false,
1114
- networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
1115
- networkParseIntervalMs: 250
1116
- });
1117
- addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
1118
-
1119
- const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
1120
- let source = "network";
1121
- let imageEvidence = null;
1122
- let captureTarget = null;
1123
- let captureTargetWait = null;
1124
- if (parsedNetworkProfileCount > 0) {
1125
- recordCvNetworkHit(cvAcquisitionState, {
1126
- parsedNetworkProfileCount,
1127
- waitResult: networkWait
1128
- });
1129
- } else {
1130
- detailStep = "wait_capture_target";
1131
- captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
1132
- domain: "recommend",
1133
- timeoutMs: 6000,
1134
- intervalMs: 250
1135
- });
1136
- captureTarget = captureTargetWait.target || null;
1137
- const captureNodeId = captureTarget?.node_id || null;
1138
- if (captureNodeId) {
1139
- const imageEvidencePath = imageEvidenceFilePath({
1140
- imageOutputDir,
1141
- domain: "recommend",
1142
- runId: runControl?.runId,
1143
- index,
1144
- extension: "jpg"
1145
- });
1146
- try {
1147
- detailStep = "capture_image";
1148
- imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
1149
- filePath: imageEvidencePath,
1150
- format: "jpeg",
1151
- quality: 72,
1152
- optimize: true,
1153
- resizeMaxWidth: 1100,
1154
- captureViewport: false,
1155
- padding: 0,
1078
+ cardNodeId,
1079
+ candidateKey,
1080
+ cardCandidate,
1081
+ rootState,
1082
+ targetUrl,
1083
+ retryTimeoutMs: 8000,
1084
+ maxAttempts: 3
1085
+ });
1086
+ addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
1087
+ addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
1088
+ cardNodeId = openedDetail.card_node_id || cardNodeId;
1089
+ cardCandidate = openedDetail.card_candidate || cardCandidate;
1090
+ screeningCandidate = cardCandidate;
1091
+ const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
1092
+ detailStep = "wait_network";
1093
+ const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
1094
+ waitForRecommendDetailNetworkEvents,
1095
+ networkRecorder,
1096
+ {
1097
+ waitPlan,
1098
+ minCount: 1,
1099
+ requireLoaded: true,
1100
+ intervalMs: 120
1101
+ }
1102
+ ));
1103
+ if (networkWait?.elapsed_ms != null) {
1104
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
1105
+ }
1106
+ detailStep = "extract_detail";
1107
+ detailResult = await extractRecommendDetailCandidate(client, {
1108
+ cardCandidate,
1109
+ cardNodeId,
1110
+ detailState: openedDetail.detail_state,
1111
+ networkEvents: networkRecorder.events,
1112
+ targetUrl,
1113
+ closeDetail: false,
1114
+ networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
1115
+ networkParseIntervalMs: 250
1116
+ });
1117
+ addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
1118
+
1119
+ const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
1120
+ let source = "network";
1121
+ let imageEvidence = null;
1122
+ let captureTarget = null;
1123
+ let captureTargetWait = null;
1124
+ if (parsedNetworkProfileCount > 0) {
1125
+ recordCvNetworkHit(cvAcquisitionState, {
1126
+ parsedNetworkProfileCount,
1127
+ waitResult: networkWait
1128
+ });
1129
+ } else {
1130
+ detailStep = "wait_capture_target";
1131
+ captureTargetWait = await waitForCvCaptureTarget(client, openedDetail.detail_state, {
1132
+ domain: "recommend",
1133
+ timeoutMs: 6000,
1134
+ intervalMs: 250
1135
+ });
1136
+ captureTarget = captureTargetWait.target || null;
1137
+ const captureNodeId = captureTarget?.node_id || null;
1138
+ if (captureNodeId) {
1139
+ const imageEvidencePath = imageEvidenceFilePath({
1140
+ imageOutputDir,
1141
+ domain: "recommend",
1142
+ runId: runControl?.runId,
1143
+ index,
1144
+ extension: "jpg"
1145
+ });
1146
+ try {
1147
+ detailStep = "capture_image";
1148
+ imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
1149
+ filePath: imageEvidencePath,
1150
+ format: "jpeg",
1151
+ quality: 72,
1152
+ optimize: true,
1153
+ resizeMaxWidth: 1100,
1154
+ captureViewport: false,
1155
+ padding: 0,
1156
1156
  maxScreenshots: maxImagePages,
1157
1157
  wheelDeltaY: imageWheelDeltaY,
1158
1158
  settleMs: 350,
1159
1159
  scrollMethod: "dom-anchor-fallback-input",
1160
1160
  scrollDeltaJitterEnabled: effectiveHumanBehavior.listScrollJitter,
1161
1161
  stepTimeoutMs: 45000,
1162
- totalTimeoutMs: 90000,
1163
- duplicateStopCount: 1,
1164
- skipDuplicateScreenshots: true,
1165
- composeForLlm: true,
1166
- llmPagesPerImage: 3,
1167
- llmResizeMaxWidth: 1100,
1168
- llmQuality: 72,
1169
- metadata: {
1170
- domain: "recommend",
1171
- capture_mode: "scroll_sequence",
1172
- acquisition_reason: "network_miss_image_fallback",
1173
- run_candidate_index: index,
1174
- candidate_key: candidateKey,
1175
- capture_target: captureTarget,
1176
- capture_target_wait: captureTargetWait
1177
- }
1178
- }));
1179
- source = "image";
1180
- } catch (error) {
1181
- if (!isRecoverableImageCaptureError(error)) throw error;
1182
- const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1183
- if (recoveryCount < 1) {
1184
- candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1185
- timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
1186
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1187
- await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1188
- await recoverAndReapplyRecommendContext(`image_capture:${detailStep}`, error, {
1189
- forceRecentNotView: true
1190
- });
1191
- continue;
1192
- }
1193
- imageEvidence = createRecoverableImageCaptureEvidence(error, {
1194
- elapsedMs: timings.screenshot_capture_ms,
1195
- filePath: imageEvidencePath,
1196
- extension: "jpg",
1197
- maxScreenshots: maxImagePages
1198
- });
1199
- source = "image_capture_failed";
1200
- }
1201
- recordCvImageFallback(cvAcquisitionState, {
1202
- reason: source === "image_capture_failed"
1203
- ? "network_miss_image_capture_failed"
1204
- : "network_miss_image_fallback",
1205
- parsedNetworkProfileCount,
1206
- waitResult: networkWait,
1207
- imageEvidence
1208
- });
1209
- } else {
1210
- source = "missing_capture_node";
1211
- recordCvNetworkMiss(cvAcquisitionState, {
1212
- reason: "network_miss_no_capture_node",
1213
- parsedNetworkProfileCount,
1214
- waitResult: networkWait
1215
- });
1216
- }
1217
- }
1218
-
1219
- detailResult.image_evidence = imageEvidence;
1220
- detailResult.cv_acquisition = {
1221
- source,
1222
- mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
1223
- wait_plan: waitPlan,
1224
- network_wait: networkWait,
1225
- parsed_network_profile_count: parsedNetworkProfileCount,
1226
- image_evidence: summarizeImageEvidence(imageEvidence),
1227
- capture_target: captureTarget || null,
1228
- capture_target_wait: captureTargetWait
1229
- };
1230
- screeningCandidate = detailResult.candidate;
1231
- } catch (error) {
1232
- if (!isRecoverableRecommendDetailError(error)) throw error;
1233
- const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1234
- if (recoveryCount < 1) {
1235
- candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1236
- timings.detail_recovery_trigger = compactRecoverableDetailError(error);
1237
- checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1238
- await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1239
- await recoverAndReapplyRecommendContext(`detail:${detailStep}`, error, {
1240
- forceRecentNotView: true
1241
- });
1242
- continue;
1243
- }
1244
- recoverableDetailError = error;
1245
- detailResult = null;
1246
- timings.detail_recovered_error = compactRecoverableDetailError(error);
1247
- await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1248
- }
1249
- }
1250
-
1251
- await runControl.waitIfPaused();
1252
- runControl.throwIfCanceled();
1253
- runControl.setPhase("recommend:screening");
1254
- let llmResult = null;
1255
- if (useLlmScreening) {
1256
- if (recoverableDetailError || detailResult?.image_evidence?.ok === false) {
1257
- llmResult = null;
1258
- } else if (!llmConfig) {
1259
- llmResult = createMissingLlmConfigResult();
1260
- } else {
1261
- try {
1262
- const llmTimingKey = detailResult?.image_evidence?.file_paths?.length
1263
- ? "vision_model_ms"
1264
- : "text_model_ms";
1265
- llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
1266
- candidate: screeningCandidate,
1267
- criteria,
1268
- config: llmConfig,
1269
- timeoutMs: llmTimeoutMs,
1270
- imageEvidence: detailResult?.image_evidence || null,
1271
- maxImages: llmImageLimit,
1272
- imageDetail: llmImageDetail
1273
- }));
1274
- } catch (error) {
1275
- llmResult = createFailedLlmScreeningResult(error);
1276
- }
1277
- }
1278
- if (detailResult) detailResult.llm_result = llmResult;
1279
- }
1280
- const screening = recoverableDetailError
1281
- ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
1282
- : detailResult?.image_evidence?.ok === false
1283
- ? createImageCaptureFailureScreening(screeningCandidate, {
1284
- code: detailResult.image_evidence.error_code,
1285
- message: detailResult.image_evidence.error
1286
- })
1287
- : useLlmScreening
1288
- ? llmResultToScreening(llmResult, screeningCandidate)
1289
- : screenCandidate(screeningCandidate, { criteria });
1162
+ totalTimeoutMs: 90000,
1163
+ duplicateStopCount: 1,
1164
+ skipDuplicateScreenshots: true,
1165
+ composeForLlm: true,
1166
+ llmPagesPerImage: 3,
1167
+ llmResizeMaxWidth: 1100,
1168
+ llmQuality: 72,
1169
+ metadata: {
1170
+ domain: "recommend",
1171
+ capture_mode: "scroll_sequence",
1172
+ acquisition_reason: "network_miss_image_fallback",
1173
+ run_candidate_index: index,
1174
+ candidate_key: candidateKey,
1175
+ capture_target: captureTarget,
1176
+ capture_target_wait: captureTargetWait
1177
+ }
1178
+ }));
1179
+ source = "image";
1180
+ } catch (error) {
1181
+ if (!isRecoverableImageCaptureError(error)) throw error;
1182
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1183
+ if (recoveryCount < 1) {
1184
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1185
+ timings.image_capture_recovery_trigger = compactError(error, "IMAGE_CAPTURE_FAILED");
1186
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1187
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1188
+ await recoverAndReapplyRecommendContext(`image_capture:${detailStep}`, error, {
1189
+ forceRecentNotView: true
1190
+ });
1191
+ continue;
1192
+ }
1193
+ imageEvidence = createRecoverableImageCaptureEvidence(error, {
1194
+ elapsedMs: timings.screenshot_capture_ms,
1195
+ filePath: imageEvidencePath,
1196
+ extension: "jpg",
1197
+ maxScreenshots: maxImagePages
1198
+ });
1199
+ source = "image_capture_failed";
1200
+ }
1201
+ recordCvImageFallback(cvAcquisitionState, {
1202
+ reason: source === "image_capture_failed"
1203
+ ? "network_miss_image_capture_failed"
1204
+ : "network_miss_image_fallback",
1205
+ parsedNetworkProfileCount,
1206
+ waitResult: networkWait,
1207
+ imageEvidence
1208
+ });
1209
+ } else {
1210
+ source = "missing_capture_node";
1211
+ recordCvNetworkMiss(cvAcquisitionState, {
1212
+ reason: "network_miss_no_capture_node",
1213
+ parsedNetworkProfileCount,
1214
+ waitResult: networkWait
1215
+ });
1216
+ }
1217
+ }
1218
+
1219
+ detailResult.image_evidence = imageEvidence;
1220
+ detailResult.cv_acquisition = {
1221
+ source,
1222
+ mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
1223
+ wait_plan: waitPlan,
1224
+ network_wait: networkWait,
1225
+ parsed_network_profile_count: parsedNetworkProfileCount,
1226
+ image_evidence: summarizeImageEvidence(imageEvidence),
1227
+ capture_target: captureTarget || null,
1228
+ capture_target_wait: captureTargetWait
1229
+ };
1230
+ screeningCandidate = detailResult.candidate;
1231
+ } catch (error) {
1232
+ if (!isRecoverableRecommendDetailError(error)) throw error;
1233
+ const recoveryCount = candidateRecoveryCounts.get(candidateKey) || 0;
1234
+ if (recoveryCount < 1) {
1235
+ candidateRecoveryCounts.set(candidateKey, recoveryCount + 1);
1236
+ timings.detail_recovery_trigger = compactRecoverableDetailError(error);
1237
+ checkpointInProgressCandidate({ index, candidateKey, cardNodeId, detailStep, error });
1238
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1239
+ await recoverAndReapplyRecommendContext(`detail:${detailStep}`, error, {
1240
+ forceRecentNotView: true
1241
+ });
1242
+ continue;
1243
+ }
1244
+ recoverableDetailError = error;
1245
+ detailResult = null;
1246
+ timings.detail_recovered_error = compactRecoverableDetailError(error);
1247
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
1248
+ }
1249
+ }
1250
+
1251
+ await runControl.waitIfPaused();
1252
+ runControl.throwIfCanceled();
1253
+ runControl.setPhase("recommend:screening");
1254
+ let llmResult = null;
1255
+ if (useLlmScreening) {
1256
+ if (recoverableDetailError || detailResult?.image_evidence?.ok === false) {
1257
+ llmResult = null;
1258
+ } else if (!llmConfig) {
1259
+ llmResult = createMissingLlmConfigResult();
1260
+ } else {
1261
+ try {
1262
+ const llmTimingKey = detailResult?.image_evidence?.file_paths?.length
1263
+ ? "vision_model_ms"
1264
+ : "text_model_ms";
1265
+ llmResult = await measureTiming(timings, llmTimingKey, () => callScreeningLlm({
1266
+ candidate: screeningCandidate,
1267
+ criteria,
1268
+ config: llmConfig,
1269
+ timeoutMs: llmTimeoutMs,
1270
+ imageEvidence: detailResult?.image_evidence || null,
1271
+ maxImages: llmImageLimit,
1272
+ imageDetail: llmImageDetail
1273
+ }));
1274
+ } catch (error) {
1275
+ llmResult = createFailedLlmScreeningResult(error);
1276
+ }
1277
+ }
1278
+ if (detailResult) detailResult.llm_result = llmResult;
1279
+ }
1280
+ const screening = recoverableDetailError
1281
+ ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
1282
+ : detailResult?.image_evidence?.ok === false
1283
+ ? createImageCaptureFailureScreening(screeningCandidate, {
1284
+ code: detailResult.image_evidence.error_code,
1285
+ message: detailResult.image_evidence.error
1286
+ })
1287
+ : useLlmScreening
1288
+ ? llmResultToScreening(llmResult, screeningCandidate)
1289
+ : screenCandidate(screeningCandidate, { criteria });
1290
1290
  let actionDiscovery = null;
1291
1291
  let postActionResult = null;
1292
1292
  let closeFailureError = null;
@@ -1298,24 +1298,24 @@ export async function runRecommendWorkflow({
1298
1298
  runControl.setPhase("recommend:post-action");
1299
1299
  await maybeHumanActionCooldown("before_post_action", timings);
1300
1300
  actionDiscovery = await waitForRecommendDetailActionControls(client, {
1301
- timeoutMs: actionTimeoutMs,
1302
- intervalMs: actionIntervalMs,
1303
- requireAny: true
1304
- });
1305
- postActionResult = await runRecommendPostAction({
1306
- client,
1307
- screening,
1308
- actionDiscovery,
1309
- postAction: normalizedPostAction,
1310
- greetCount,
1311
- maxGreetCount: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
1312
- executePostAction,
1313
- afterClickDelayMs: actionAfterClickDelayMs
1314
- });
1315
- if (postActionResult.counted_as_greet && postActionResult.action_clicked) {
1316
- greetCount += 1;
1317
- }
1318
- addTiming(timings, "post_action_ms", Date.now() - postActionStarted);
1301
+ timeoutMs: actionTimeoutMs,
1302
+ intervalMs: actionIntervalMs,
1303
+ requireAny: true
1304
+ });
1305
+ postActionResult = await runRecommendPostAction({
1306
+ client,
1307
+ screening,
1308
+ actionDiscovery,
1309
+ postAction: normalizedPostAction,
1310
+ greetCount,
1311
+ maxGreetCount: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
1312
+ executePostAction,
1313
+ afterClickDelayMs: actionAfterClickDelayMs
1314
+ });
1315
+ if (postActionResult.counted_as_greet && postActionResult.action_clicked) {
1316
+ greetCount += 1;
1317
+ }
1318
+ addTiming(timings, "post_action_ms", Date.now() - postActionStarted);
1319
1319
  }
1320
1320
  if (detailResult && closeDetail) {
1321
1321
  detailResult.close_result = await measureTiming(timings, "close_detail_ms", () => closeRecommendDetail(client));
@@ -1349,16 +1349,16 @@ export async function runRecommendWorkflow({
1349
1349
  }
1350
1350
  }
1351
1351
  }
1352
- timings.total_ms = Date.now() - candidateStarted;
1353
- const compactResult = {
1354
- index,
1355
- candidate_key: candidateKey,
1356
- card_node_id: cardNodeId,
1357
- candidate: compactCandidate(screeningCandidate),
1358
- detail: compactDetail(detailResult),
1359
- llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
1360
- screening: compactScreening(screening),
1361
- action_discovery: compactActionDiscovery(actionDiscovery),
1352
+ timings.total_ms = Date.now() - candidateStarted;
1353
+ const compactResult = {
1354
+ index,
1355
+ candidate_key: candidateKey,
1356
+ card_node_id: cardNodeId,
1357
+ candidate: compactCandidate(screeningCandidate),
1358
+ detail: compactDetail(detailResult),
1359
+ llm_screening: detailResult ? null : compactScreeningLlmResult(llmResult),
1360
+ screening: compactScreening(screening),
1361
+ action_discovery: compactActionDiscovery(actionDiscovery),
1362
1362
  post_action: postActionResult,
1363
1363
  error: recoverableDetailError
1364
1364
  ? compactRecoverableDetailError(recoverableDetailError)
@@ -1367,40 +1367,40 @@ export async function runRecommendWorkflow({
1367
1367
  : detailResult?.image_evidence?.ok === false
1368
1368
  ? compactError({
1369
1369
  code: detailResult.image_evidence.error_code,
1370
- message: detailResult.image_evidence.error
1371
- }, "IMAGE_CAPTURE_FAILED")
1372
- : null,
1373
- timings
1374
- };
1375
- results.push(compactResult);
1376
- markInfiniteListCandidateProcessed(listState, candidateKey, {
1377
- metadata: {
1378
- result_index: index,
1379
- candidate_id: screeningCandidate.id || null
1380
- }
1381
- });
1382
-
1383
- updateRecommendProgress({
1384
- last_candidate_id: screeningCandidate.id || null,
1385
- last_candidate_key: candidateKey,
1386
- last_score: screening.score
1387
- });
1388
- const checkpointStarted = Date.now();
1389
- runControl.checkpoint({
1390
- results,
1391
- last_candidate: {
1392
- id: screeningCandidate.id || null,
1393
- key: candidateKey,
1394
- identity: screeningCandidate.identity || {},
1395
- screening: {
1396
- status: screening.status,
1397
- passed: screening.passed,
1398
- score: screening.score
1399
- },
1400
- llm_screening: compactScreeningLlmResult(llmResult),
1401
- error: compactResult.error,
1402
- post_action: postActionResult
1403
- }
1370
+ message: detailResult.image_evidence.error
1371
+ }, "IMAGE_CAPTURE_FAILED")
1372
+ : null,
1373
+ timings
1374
+ };
1375
+ results.push(compactResult);
1376
+ markInfiniteListCandidateProcessed(listState, candidateKey, {
1377
+ metadata: {
1378
+ result_index: index,
1379
+ candidate_id: screeningCandidate.id || null
1380
+ }
1381
+ });
1382
+
1383
+ updateRecommendProgress({
1384
+ last_candidate_id: screeningCandidate.id || null,
1385
+ last_candidate_key: candidateKey,
1386
+ last_score: screening.score
1387
+ });
1388
+ const checkpointStarted = Date.now();
1389
+ runControl.checkpoint({
1390
+ results,
1391
+ last_candidate: {
1392
+ id: screeningCandidate.id || null,
1393
+ key: candidateKey,
1394
+ identity: screeningCandidate.identity || {},
1395
+ screening: {
1396
+ status: screening.status,
1397
+ passed: screening.passed,
1398
+ score: screening.score
1399
+ },
1400
+ llm_screening: compactScreeningLlmResult(llmResult),
1401
+ error: compactResult.error,
1402
+ post_action: postActionResult
1403
+ }
1404
1404
  });
1405
1405
  addTiming(compactResult.timings, "checkpoint_save_ms", Date.now() - checkpointStarted);
1406
1406
 
@@ -1439,18 +1439,18 @@ export async function runRecommendWorkflow({
1439
1439
  await runControl.sleep(delayMs);
1440
1440
  addTiming(compactResult.timings, "sleep_ms", Date.now() - sleepStarted);
1441
1441
  compactResult.timings.total_ms = Date.now() - candidateStarted;
1442
- }
1443
- }
1444
-
1445
- runControl.setPhase("recommend:done");
1446
- return {
1447
- domain: "recommend",
1448
- target_url: targetUrl,
1449
- job_selection: compactJobSelection(jobSelection),
1450
- page_scope: compactPageScopeSelection(pageScopeSelection),
1451
- filter: compactFilterResult(filterResult),
1452
- card_count: cardNodeIds.length,
1453
- candidate_list: compactInfiniteListState(listState),
1442
+ }
1443
+ }
1444
+
1445
+ runControl.setPhase("recommend:done");
1446
+ return {
1447
+ domain: "recommend",
1448
+ target_url: targetUrl,
1449
+ job_selection: compactJobSelection(jobSelection),
1450
+ page_scope: compactPageScopeSelection(pageScopeSelection),
1451
+ filter: compactFilterResult(filterResult),
1452
+ card_count: cardNodeIds.length,
1453
+ candidate_list: compactInfiniteListState(listState),
1454
1454
  viewport_health: {
1455
1455
  stats: viewportGuard.getStats(),
1456
1456
  events: viewportGuard.getEvents()
@@ -1459,56 +1459,56 @@ export async function runRecommendWorkflow({
1459
1459
  human_rest: humanRestController.getState(),
1460
1460
  last_human_event: lastHumanEvent,
1461
1461
  list_end_reason: listEndReason || null,
1462
- refresh_rounds: refreshRounds,
1463
- refresh_attempts: refreshAttempts,
1464
- context_recoveries: contextRecoveryAttempts,
1465
- ...countRecommendResultStatuses(results, { greetCount }),
1466
- results
1467
- };
1468
- }
1469
-
1470
- export function createRecommendRunService({
1471
- lifecycle,
1472
- idPrefix = "recommend",
1473
- workflow = runRecommendWorkflow,
1474
- onSnapshot = null
1475
- } = {}) {
1476
- const manager = lifecycle || createRunLifecycleManager({ idPrefix, onSnapshot });
1477
-
1478
- function startRecommendRun({
1479
- runId = "",
1480
- pid = process.pid,
1481
- client,
1482
- targetUrl = "",
1483
- criteria = "",
1484
- jobLabel = "",
1485
- pageScope = "recommend",
1486
- fallbackPageScope = "recommend",
1487
- filter = {},
1488
- maxCandidates = 5,
1489
- detailLimit,
1490
- closeDetail = true,
1491
- delayMs = 0,
1492
- cardTimeoutMs = 10000,
1493
- maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
1494
- imageWheelDeltaY = 650,
1495
- cvAcquisitionMode = "unknown",
1496
- listMaxScrolls = 20,
1497
- listStableSignatureLimit = 5,
1498
- listWheelDeltaY = 850,
1499
- listSettleMs = 2200,
1500
- listFallbackPoint = null,
1501
- refreshOnEnd = true,
1502
- maxRefreshRounds = 2,
1503
- refreshButtonSettleMs = 8000,
1504
- refreshReloadSettleMs = 8000,
1505
- postAction = "none",
1506
- maxGreetCount = null,
1507
- executePostAction = true,
1508
- actionTimeoutMs = 8000,
1509
- actionIntervalMs = 500,
1510
- actionAfterClickDelayMs = 900,
1511
- screeningMode = "llm",
1462
+ refresh_rounds: refreshRounds,
1463
+ refresh_attempts: refreshAttempts,
1464
+ context_recoveries: contextRecoveryAttempts,
1465
+ ...countRecommendResultStatuses(results, { greetCount }),
1466
+ results
1467
+ };
1468
+ }
1469
+
1470
+ export function createRecommendRunService({
1471
+ lifecycle,
1472
+ idPrefix = "recommend",
1473
+ workflow = runRecommendWorkflow,
1474
+ onSnapshot = null
1475
+ } = {}) {
1476
+ const manager = lifecycle || createRunLifecycleManager({ idPrefix, onSnapshot });
1477
+
1478
+ function startRecommendRun({
1479
+ runId = "",
1480
+ pid = process.pid,
1481
+ client,
1482
+ targetUrl = "",
1483
+ criteria = "",
1484
+ jobLabel = "",
1485
+ pageScope = "recommend",
1486
+ fallbackPageScope = "recommend",
1487
+ filter = {},
1488
+ maxCandidates = 5,
1489
+ detailLimit,
1490
+ closeDetail = true,
1491
+ delayMs = 0,
1492
+ cardTimeoutMs = 10000,
1493
+ maxImagePages = DEFAULT_MAX_IMAGE_PAGES,
1494
+ imageWheelDeltaY = 650,
1495
+ cvAcquisitionMode = "unknown",
1496
+ listMaxScrolls = 20,
1497
+ listStableSignatureLimit = 5,
1498
+ listWheelDeltaY = 850,
1499
+ listSettleMs = 2200,
1500
+ listFallbackPoint = null,
1501
+ refreshOnEnd = true,
1502
+ maxRefreshRounds = 2,
1503
+ refreshButtonSettleMs = 8000,
1504
+ refreshReloadSettleMs = 8000,
1505
+ postAction = "none",
1506
+ maxGreetCount = null,
1507
+ executePostAction = true,
1508
+ actionTimeoutMs = 8000,
1509
+ actionIntervalMs = 500,
1510
+ actionAfterClickDelayMs = 900,
1511
+ screeningMode = "llm",
1512
1512
  llmConfig = null,
1513
1513
  llmTimeoutMs = 120000,
1514
1514
  llmImageLimit = 8,
@@ -1529,41 +1529,41 @@ export function createRecommendRunService({
1529
1529
  });
1530
1530
  const effectiveHumanRestEnabled = effectiveHumanBehavior.restEnabled;
1531
1531
  const candidateLimit = Math.max(1, Number(maxCandidates) || 1);
1532
- const normalizedDetailLimit = detailLimit == null ? null : Math.max(0, Number(detailLimit) || 0);
1533
- return manager.startRun({
1534
- runId,
1535
- name,
1536
- pid,
1537
- context: {
1538
- domain: "recommend",
1539
- target_url: targetUrl,
1540
- criteria_present: Boolean(criteria),
1541
- job_label: jobLabel || "",
1542
- requested_page_scope: requestedPageScope,
1543
- fallback_page_scope: normalizedFallbackPageScope,
1544
- filter: normalizedFilter,
1545
- max_candidates: maxCandidates,
1546
- max_candidates_semantics: "passed_candidates",
1547
- detail_limit: normalizedDetailLimit,
1548
- close_detail: closeDetail,
1549
- cv_acquisition_mode: cvAcquisitionMode,
1550
- max_image_pages: maxImagePages,
1551
- image_wheel_delta_y: imageWheelDeltaY,
1552
- list_max_scrolls: listMaxScrolls,
1553
- list_stable_signature_limit: listStableSignatureLimit,
1554
- list_wheel_delta_y: listWheelDeltaY,
1555
- list_settle_ms: listSettleMs,
1556
- list_fallback_point: listFallbackPoint,
1557
- refresh_on_end: refreshOnEnd,
1558
- max_refresh_rounds: maxRefreshRounds,
1559
- refresh_button_settle_ms: refreshButtonSettleMs,
1560
- refresh_reload_settle_ms: refreshReloadSettleMs,
1561
- post_action: normalizedPostAction,
1562
- max_greet_count: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
1563
- execute_post_action: Boolean(executePostAction),
1564
- action_timeout_ms: actionTimeoutMs,
1565
- screening_mode: normalizedScreeningMode,
1566
- llm_configured: Boolean(llmConfig),
1532
+ const normalizedDetailLimit = detailLimit == null ? null : Math.max(0, Number(detailLimit) || 0);
1533
+ return manager.startRun({
1534
+ runId,
1535
+ name,
1536
+ pid,
1537
+ context: {
1538
+ domain: "recommend",
1539
+ target_url: targetUrl,
1540
+ criteria_present: Boolean(criteria),
1541
+ job_label: jobLabel || "",
1542
+ requested_page_scope: requestedPageScope,
1543
+ fallback_page_scope: normalizedFallbackPageScope,
1544
+ filter: normalizedFilter,
1545
+ max_candidates: maxCandidates,
1546
+ max_candidates_semantics: "passed_candidates",
1547
+ detail_limit: normalizedDetailLimit,
1548
+ close_detail: closeDetail,
1549
+ cv_acquisition_mode: cvAcquisitionMode,
1550
+ max_image_pages: maxImagePages,
1551
+ image_wheel_delta_y: imageWheelDeltaY,
1552
+ list_max_scrolls: listMaxScrolls,
1553
+ list_stable_signature_limit: listStableSignatureLimit,
1554
+ list_wheel_delta_y: listWheelDeltaY,
1555
+ list_settle_ms: listSettleMs,
1556
+ list_fallback_point: listFallbackPoint,
1557
+ refresh_on_end: refreshOnEnd,
1558
+ max_refresh_rounds: maxRefreshRounds,
1559
+ refresh_button_settle_ms: refreshButtonSettleMs,
1560
+ refresh_reload_settle_ms: refreshReloadSettleMs,
1561
+ post_action: normalizedPostAction,
1562
+ max_greet_count: Number.isInteger(maxGreetCount) ? maxGreetCount : null,
1563
+ execute_post_action: Boolean(executePostAction),
1564
+ action_timeout_ms: actionTimeoutMs,
1565
+ screening_mode: normalizedScreeningMode,
1566
+ llm_configured: Boolean(llmConfig),
1567
1567
  llm_timeout_ms: llmTimeoutMs,
1568
1568
  llm_image_limit: llmImageLimit,
1569
1569
  llm_image_detail: llmImageDetail,
@@ -1574,16 +1574,16 @@ export function createRecommendRunService({
1574
1574
  human_rest_enabled: effectiveHumanRestEnabled
1575
1575
  },
1576
1576
  progress: {
1577
- card_count: 0,
1578
- target_count: candidateLimit,
1579
- target_count_semantics: "passed_candidates",
1580
- processed: 0,
1581
- screened: 0,
1582
- detail_opened: 0,
1583
- llm_screened: 0,
1584
- passed: 0,
1585
- greet_count: 0,
1586
- post_action_clicked: 0,
1577
+ card_count: 0,
1578
+ target_count: candidateLimit,
1579
+ target_count_semantics: "passed_candidates",
1580
+ processed: 0,
1581
+ screened: 0,
1582
+ detail_opened: 0,
1583
+ llm_screened: 0,
1584
+ passed: 0,
1585
+ greet_count: 0,
1586
+ post_action_clicked: 0,
1587
1587
  image_capture_failed: 0,
1588
1588
  detail_open_failed: 0,
1589
1589
  transient_recovered: 0,
@@ -1595,40 +1595,40 @@ export function createRecommendRunService({
1595
1595
  human_rest_ms: 0,
1596
1596
  last_human_event: null
1597
1597
  },
1598
- checkpoint: {},
1599
- task: (runControl) => workflow({
1600
- client,
1601
- targetUrl,
1602
- criteria,
1603
- jobLabel,
1604
- pageScope: requestedPageScope,
1605
- fallbackPageScope: normalizedFallbackPageScope,
1606
- filter: normalizedFilter,
1607
- maxCandidates,
1608
- detailLimit: normalizedDetailLimit,
1609
- closeDetail,
1610
- delayMs,
1611
- cardTimeoutMs,
1612
- maxImagePages,
1613
- imageWheelDeltaY,
1614
- cvAcquisitionMode,
1615
- listMaxScrolls,
1616
- listStableSignatureLimit,
1617
- listWheelDeltaY,
1618
- listSettleMs,
1619
- listFallbackPoint,
1620
- refreshOnEnd,
1621
- maxRefreshRounds,
1622
- refreshButtonSettleMs,
1623
- refreshReloadSettleMs,
1624
- postAction: normalizedPostAction,
1625
- maxGreetCount,
1626
- executePostAction,
1627
- actionTimeoutMs,
1628
- actionIntervalMs,
1629
- actionAfterClickDelayMs,
1630
- screeningMode: normalizedScreeningMode,
1631
- llmConfig,
1598
+ checkpoint: {},
1599
+ task: (runControl) => workflow({
1600
+ client,
1601
+ targetUrl,
1602
+ criteria,
1603
+ jobLabel,
1604
+ pageScope: requestedPageScope,
1605
+ fallbackPageScope: normalizedFallbackPageScope,
1606
+ filter: normalizedFilter,
1607
+ maxCandidates,
1608
+ detailLimit: normalizedDetailLimit,
1609
+ closeDetail,
1610
+ delayMs,
1611
+ cardTimeoutMs,
1612
+ maxImagePages,
1613
+ imageWheelDeltaY,
1614
+ cvAcquisitionMode,
1615
+ listMaxScrolls,
1616
+ listStableSignatureLimit,
1617
+ listWheelDeltaY,
1618
+ listSettleMs,
1619
+ listFallbackPoint,
1620
+ refreshOnEnd,
1621
+ maxRefreshRounds,
1622
+ refreshButtonSettleMs,
1623
+ refreshReloadSettleMs,
1624
+ postAction: normalizedPostAction,
1625
+ maxGreetCount,
1626
+ executePostAction,
1627
+ actionTimeoutMs,
1628
+ actionIntervalMs,
1629
+ actionAfterClickDelayMs,
1630
+ screeningMode: normalizedScreeningMode,
1631
+ llmConfig,
1632
1632
  llmTimeoutMs,
1633
1633
  llmImageLimit,
1634
1634
  llmImageDetail,
@@ -1638,15 +1638,15 @@ export function createRecommendRunService({
1638
1638
  }, runControl)
1639
1639
  });
1640
1640
  }
1641
-
1642
- return {
1643
- startRecommendRun,
1644
- getRecommendRun: manager.getRun,
1645
- pauseRecommendRun: manager.pauseRun,
1646
- resumeRecommendRun: manager.resumeRun,
1647
- cancelRecommendRun: manager.cancelRun,
1648
- waitForRecommendRun: manager.waitForRun,
1649
- listRecommendRuns: manager.listRuns,
1650
- manager
1651
- };
1652
- }
1641
+
1642
+ return {
1643
+ startRecommendRun,
1644
+ getRecommendRun: manager.getRun,
1645
+ pauseRecommendRun: manager.pauseRun,
1646
+ resumeRecommendRun: manager.resumeRun,
1647
+ cancelRecommendRun: manager.cancelRun,
1648
+ waitForRecommendRun: manager.waitForRun,
1649
+ listRecommendRuns: manager.listRuns,
1650
+ manager
1651
+ };
1652
+ }