@reconcrap/boss-recommend-mcp 2.0.37 → 2.0.39

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