@reconcrap/boss-recommend-mcp 2.0.41 → 2.0.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reconcrap/boss-recommend-mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.43",
|
|
4
4
|
"description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"boss",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"live:infinite-list": "node scripts/live-infinite-list-smoke.js",
|
|
61
61
|
"live:scroll-end": "node scripts/live-scroll-end-screenshot.js",
|
|
62
62
|
"live:refresh-round": "node scripts/live-refresh-round-smoke.js",
|
|
63
|
+
"live:recommend-recovery": "node scripts/live-recommend-recovery-smoke.js",
|
|
63
64
|
"live:self-heal": "node scripts/live-self-heal-smoke.js",
|
|
64
65
|
"live:recommend-actions": "node scripts/live-recommend-actions-smoke.js",
|
|
65
66
|
"live:recommend-phase10-full": "node scripts/live-recommend-phase10-full.js",
|
|
@@ -82,6 +83,7 @@
|
|
|
82
83
|
"config/screening-config.example.json",
|
|
83
84
|
"skills",
|
|
84
85
|
"scripts/postinstall.cjs",
|
|
86
|
+
"scripts/live-recommend-recovery-smoke.js",
|
|
85
87
|
"src/core",
|
|
86
88
|
"src/domains",
|
|
87
89
|
"src/chat-mcp.js",
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import {
|
|
6
|
+
assertNoForbiddenCdpCalls,
|
|
7
|
+
assertRuntimeEvaluateBlocked,
|
|
8
|
+
bringPageToFront,
|
|
9
|
+
connectToChromeTarget,
|
|
10
|
+
enableDomains,
|
|
11
|
+
getAttributesMap,
|
|
12
|
+
getOuterHTML,
|
|
13
|
+
querySelector,
|
|
14
|
+
sleep
|
|
15
|
+
} from "../src/core/browser/index.js";
|
|
16
|
+
import {
|
|
17
|
+
htmlToText,
|
|
18
|
+
normalizeText
|
|
19
|
+
} from "../src/core/screening/index.js";
|
|
20
|
+
import {
|
|
21
|
+
findRecommendJobTrigger,
|
|
22
|
+
getRecommendRoots,
|
|
23
|
+
refreshRecommendListAtEnd,
|
|
24
|
+
RECOMMEND_TARGET_URL,
|
|
25
|
+
waitForRecommendCardNodeIds
|
|
26
|
+
} from "../src/domains/recommend/index.js";
|
|
27
|
+
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const options = {
|
|
30
|
+
host: "127.0.0.1",
|
|
31
|
+
port: 9222,
|
|
32
|
+
targetUrl: RECOMMEND_TARGET_URL,
|
|
33
|
+
targetUrlIncludes: RECOMMEND_TARGET_URL,
|
|
34
|
+
jobLabel: "",
|
|
35
|
+
pageScope: "recommend",
|
|
36
|
+
fallbackPageScope: "recommend",
|
|
37
|
+
forceNavigate: true,
|
|
38
|
+
forceRecentNotView: true,
|
|
39
|
+
reloadSettleMs: 12000,
|
|
40
|
+
cardTimeoutMs: 60000,
|
|
41
|
+
saveReport: ".live-artifacts/recommend-recovery-smoke.json",
|
|
42
|
+
filterGroups: [
|
|
43
|
+
{
|
|
44
|
+
group: "degree",
|
|
45
|
+
labels: ["本科", "硕士", "博士"],
|
|
46
|
+
selectAllLabels: true
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
group: "school",
|
|
50
|
+
labels: ["985", "211", "双一流院校", "国内外名校"],
|
|
51
|
+
selectAllLabels: true
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
57
|
+
const arg = argv[index];
|
|
58
|
+
if (arg === "--host") options.host = argv[++index];
|
|
59
|
+
if (arg === "--port") options.port = Number(argv[++index]);
|
|
60
|
+
if (arg === "--target-url") options.targetUrl = argv[++index];
|
|
61
|
+
if (arg === "--target-url-includes") options.targetUrlIncludes = argv[++index];
|
|
62
|
+
if (arg === "--job") options.jobLabel = argv[++index];
|
|
63
|
+
if (arg === "--page-scope") options.pageScope = argv[++index];
|
|
64
|
+
if (arg === "--fallback-page-scope") options.fallbackPageScope = argv[++index];
|
|
65
|
+
if (arg === "--no-force-navigate") options.forceNavigate = false;
|
|
66
|
+
if (arg === "--no-recent-not-view") options.forceRecentNotView = false;
|
|
67
|
+
if (arg === "--reload-settle-ms") options.reloadSettleMs = Number(argv[++index]);
|
|
68
|
+
if (arg === "--card-timeout-ms") options.cardTimeoutMs = Number(argv[++index]);
|
|
69
|
+
if (arg === "--save-report") options.saveReport = argv[++index];
|
|
70
|
+
if (arg === "--no-save-report") options.saveReport = "";
|
|
71
|
+
if (arg === "--no-default-filter") options.filterGroups = [];
|
|
72
|
+
if (arg === "--filter") {
|
|
73
|
+
const raw = String(argv[++index] || "");
|
|
74
|
+
const [group, labelsRaw = ""] = raw.split(/[:=]/);
|
|
75
|
+
options.filterGroups.push({
|
|
76
|
+
group: group.trim(),
|
|
77
|
+
labels: labelsRaw.split(/[,,、|/]/).map((item) => item.trim()).filter(Boolean),
|
|
78
|
+
selectAllLabels: true
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return options;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function methodSummary(methodLog) {
|
|
87
|
+
const summary = {};
|
|
88
|
+
for (const entry of methodLog) {
|
|
89
|
+
summary[entry.method] = (summary[entry.method] || 0) + 1;
|
|
90
|
+
}
|
|
91
|
+
return summary;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeJsonFile(filePath, payload) {
|
|
95
|
+
const resolved = path.resolve(filePath);
|
|
96
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
97
|
+
fs.writeFileSync(resolved, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
98
|
+
return resolved;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function connectToRecommendSession(options) {
|
|
102
|
+
try {
|
|
103
|
+
return await connectToChromeTarget({
|
|
104
|
+
host: options.host,
|
|
105
|
+
port: options.port,
|
|
106
|
+
targetUrlIncludes: options.targetUrlIncludes
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return connectToChromeTarget({
|
|
110
|
+
host: options.host,
|
|
111
|
+
port: options.port,
|
|
112
|
+
targetPredicate: (target) => (
|
|
113
|
+
target?.type === "page"
|
|
114
|
+
&& String(target?.url || "").includes("zhipin.com/web/chat")
|
|
115
|
+
)
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function readCurrentJobLabel(client, frameDocumentNodeId) {
|
|
121
|
+
const labelNodeId = await querySelector(
|
|
122
|
+
client,
|
|
123
|
+
frameDocumentNodeId,
|
|
124
|
+
".job-selecter-wrap .ui-dropmenu-label, .ui-dropmenu-label"
|
|
125
|
+
);
|
|
126
|
+
if (!labelNodeId) return "";
|
|
127
|
+
const html = await getOuterHTML(client, labelNodeId);
|
|
128
|
+
return normalizeText(htmlToText(html));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function summarizeRecommendState(client) {
|
|
132
|
+
const roots = await getRecommendRoots(client);
|
|
133
|
+
const iframeAttributes = await getAttributesMap(client, roots.iframe.nodeId).catch(() => ({}));
|
|
134
|
+
const cardNodeIds = await waitForRecommendCardNodeIds(client, roots.iframe.documentNodeId, {
|
|
135
|
+
timeoutMs: 1200,
|
|
136
|
+
intervalMs: 200
|
|
137
|
+
}).catch(() => []);
|
|
138
|
+
const trigger = await findRecommendJobTrigger(client, roots.iframe.documentNodeId).catch(() => null);
|
|
139
|
+
const currentJobLabel = trigger
|
|
140
|
+
? await readCurrentJobLabel(client, roots.iframe.documentNodeId).catch(() => "")
|
|
141
|
+
: "";
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
roots,
|
|
145
|
+
summary: {
|
|
146
|
+
iframe_selector: roots.iframe.selector || "",
|
|
147
|
+
iframe_node_id: roots.iframe.nodeId,
|
|
148
|
+
iframe_document_node_id: roots.iframe.documentNodeId,
|
|
149
|
+
iframe_src: iframeAttributes.src || "",
|
|
150
|
+
current_job_label: currentJobLabel,
|
|
151
|
+
job_trigger_found: Boolean(trigger),
|
|
152
|
+
job_trigger_rect: trigger?.rect || null,
|
|
153
|
+
card_count: cardNodeIds.length
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function compactRefreshResult(refreshResult = {}) {
|
|
159
|
+
return {
|
|
160
|
+
ok: Boolean(refreshResult.ok),
|
|
161
|
+
method: refreshResult.method || "",
|
|
162
|
+
reason: refreshResult.reason || null,
|
|
163
|
+
error: refreshResult.error || null,
|
|
164
|
+
forced_recent_not_view: Boolean(refreshResult.forced_recent_not_view),
|
|
165
|
+
target_url: refreshResult.target_url || null,
|
|
166
|
+
card_count: refreshResult.card_count || 0,
|
|
167
|
+
elapsed_ms: refreshResult.elapsed_ms || 0,
|
|
168
|
+
attempts: (refreshResult.attempts || []).map((attempt) => ({
|
|
169
|
+
ok: Boolean(attempt.ok),
|
|
170
|
+
method: attempt.method || "",
|
|
171
|
+
reason: attempt.reason || null,
|
|
172
|
+
error: attempt.error || null,
|
|
173
|
+
card_count: attempt.card_count || 0,
|
|
174
|
+
elapsed_ms: attempt.elapsed_ms || 0,
|
|
175
|
+
job_selection_attempts: attempt.job_selection_attempts || []
|
|
176
|
+
})),
|
|
177
|
+
job_selection: refreshResult.job_selection
|
|
178
|
+
? {
|
|
179
|
+
requested: refreshResult.job_selection.requested,
|
|
180
|
+
selected: Boolean(refreshResult.job_selection.selected),
|
|
181
|
+
already_current: Boolean(refreshResult.job_selection.already_current),
|
|
182
|
+
reason: refreshResult.job_selection.reason || null,
|
|
183
|
+
selected_option: refreshResult.job_selection.selected_option || null,
|
|
184
|
+
refresh_attempts: refreshResult.job_selection.refresh_attempts || []
|
|
185
|
+
}
|
|
186
|
+
: null,
|
|
187
|
+
job_selection_attempts: refreshResult.job_selection_attempts || [],
|
|
188
|
+
page_scope: refreshResult.page_scope
|
|
189
|
+
? {
|
|
190
|
+
requested_scope: refreshResult.page_scope.requested_scope,
|
|
191
|
+
effective_scope: refreshResult.page_scope.effective_scope,
|
|
192
|
+
selected: Boolean(refreshResult.page_scope.selected),
|
|
193
|
+
fallback_applied: Boolean(refreshResult.page_scope.fallback_applied),
|
|
194
|
+
reason: refreshResult.page_scope.reason || null,
|
|
195
|
+
card_count: refreshResult.page_scope.card_count || refreshResult.page_scope.after?.card_count || 0
|
|
196
|
+
}
|
|
197
|
+
: null,
|
|
198
|
+
filter: refreshResult.filter
|
|
199
|
+
? {
|
|
200
|
+
confirmed: Boolean(refreshResult.filter.confirmed),
|
|
201
|
+
selected_option: refreshResult.filter.selected_option || null,
|
|
202
|
+
selected_options: refreshResult.filter.selected_options || []
|
|
203
|
+
}
|
|
204
|
+
: null,
|
|
205
|
+
filter_reapply_attempts: refreshResult.filter_reapply_attempts || []
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function run() {
|
|
210
|
+
const options = parseArgs(process.argv.slice(2));
|
|
211
|
+
let session;
|
|
212
|
+
const result = {
|
|
213
|
+
status: "UNKNOWN",
|
|
214
|
+
generated_at: new Date().toISOString(),
|
|
215
|
+
chrome: {
|
|
216
|
+
host: options.host,
|
|
217
|
+
port: options.port,
|
|
218
|
+
target_url_includes: options.targetUrlIncludes
|
|
219
|
+
},
|
|
220
|
+
input: {
|
|
221
|
+
target_url: options.targetUrl,
|
|
222
|
+
job_label: options.jobLabel,
|
|
223
|
+
page_scope: options.pageScope,
|
|
224
|
+
fallback_page_scope: options.fallbackPageScope,
|
|
225
|
+
force_navigate: options.forceNavigate,
|
|
226
|
+
force_recent_not_view: options.forceRecentNotView,
|
|
227
|
+
reload_settle_ms: options.reloadSettleMs,
|
|
228
|
+
card_timeout_ms: options.cardTimeoutMs,
|
|
229
|
+
filter_groups: options.filterGroups
|
|
230
|
+
},
|
|
231
|
+
before: null,
|
|
232
|
+
refresh: null,
|
|
233
|
+
after: null
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
session = await connectToRecommendSession(options);
|
|
238
|
+
const { client, methodLog, target } = session;
|
|
239
|
+
result.chrome.target = {
|
|
240
|
+
id: target.id,
|
|
241
|
+
type: target.type,
|
|
242
|
+
url: target.url,
|
|
243
|
+
title: target.title
|
|
244
|
+
};
|
|
245
|
+
result.runtime_guard_probe = await assertRuntimeEvaluateBlocked(client);
|
|
246
|
+
await enableDomains(client, ["Page", "DOM", "Input", "Accessibility"]);
|
|
247
|
+
await bringPageToFront(client);
|
|
248
|
+
|
|
249
|
+
const beforeState = await summarizeRecommendState(client);
|
|
250
|
+
result.before = beforeState.summary;
|
|
251
|
+
const jobLabel = options.jobLabel || beforeState.summary.current_job_label;
|
|
252
|
+
if (!jobLabel) {
|
|
253
|
+
throw new Error("No recommend job label was provided or detectable; pass --job");
|
|
254
|
+
}
|
|
255
|
+
result.input.job_label = jobLabel;
|
|
256
|
+
|
|
257
|
+
const refreshResult = await refreshRecommendListAtEnd(client, {
|
|
258
|
+
rootState: beforeState.roots,
|
|
259
|
+
jobLabel,
|
|
260
|
+
pageScope: options.pageScope,
|
|
261
|
+
fallbackPageScope: options.fallbackPageScope,
|
|
262
|
+
filter: { filterGroups: options.filterGroups },
|
|
263
|
+
preferEndRefreshButton: false,
|
|
264
|
+
forceNavigate: options.forceNavigate,
|
|
265
|
+
targetUrl: options.targetUrl,
|
|
266
|
+
forceRecentNotView: options.forceRecentNotView,
|
|
267
|
+
cardTimeoutMs: options.cardTimeoutMs,
|
|
268
|
+
reloadSettleMs: options.reloadSettleMs
|
|
269
|
+
});
|
|
270
|
+
result.refresh = compactRefreshResult(refreshResult);
|
|
271
|
+
|
|
272
|
+
await sleep(1000);
|
|
273
|
+
const afterState = await summarizeRecommendState(client);
|
|
274
|
+
result.after = afterState.summary;
|
|
275
|
+
result.method_summary = methodSummary(methodLog);
|
|
276
|
+
assertNoForbiddenCdpCalls(methodLog);
|
|
277
|
+
|
|
278
|
+
if (!refreshResult.ok) {
|
|
279
|
+
throw new Error(`Recommend recovery refresh failed: ${refreshResult.reason || refreshResult.error || "unknown"}`);
|
|
280
|
+
}
|
|
281
|
+
if (!refreshResult.job_selection?.selected) {
|
|
282
|
+
throw new Error("Recommend recovery smoke did not select the requested job");
|
|
283
|
+
}
|
|
284
|
+
if (!refreshResult.card_count) {
|
|
285
|
+
throw new Error("Recommend recovery smoke found no cards after refresh");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
result.status = "PASS";
|
|
289
|
+
} catch (error) {
|
|
290
|
+
result.status = "FAIL";
|
|
291
|
+
result.error = {
|
|
292
|
+
message: error?.message || String(error),
|
|
293
|
+
stack: error?.stack || ""
|
|
294
|
+
};
|
|
295
|
+
process.exitCode = 1;
|
|
296
|
+
} finally {
|
|
297
|
+
if (session) await session.close().catch(() => null);
|
|
298
|
+
if (options.saveReport) {
|
|
299
|
+
result.report_path = writeJsonFile(options.saveReport, result);
|
|
300
|
+
}
|
|
301
|
+
console.log(JSON.stringify(result, null, 2));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await run();
|
|
@@ -22,12 +22,21 @@ import {
|
|
|
22
22
|
import {
|
|
23
23
|
getRecommendRoots
|
|
24
24
|
} from "./roots.js";
|
|
25
|
-
import {
|
|
26
|
-
findRecommendCardNodeIds,
|
|
27
|
-
readRecommendCardCandidate
|
|
28
|
-
} from "./cards.js";
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
import {
|
|
26
|
+
findRecommendCardNodeIds,
|
|
27
|
+
readRecommendCardCandidate
|
|
28
|
+
} from "./cards.js";
|
|
29
|
+
|
|
30
|
+
const DETAIL_OUTSIDE_CLOSE_BOUNDARY_SELECTORS = Object.freeze([
|
|
31
|
+
".resume-center-side .resume-detail-wrap",
|
|
32
|
+
".resume-detail-wrap",
|
|
33
|
+
".boss-popup__wrapper .boss-popup__body",
|
|
34
|
+
".boss-popup__wrapper .dialog-body",
|
|
35
|
+
".dialog-wrap.active .resume-detail-wrap",
|
|
36
|
+
".geek-detail-modal .resume-detail-wrap"
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export function matchesRecommendDetailNetwork(url) {
|
|
31
40
|
return DETAIL_NETWORK_PATTERNS.some((pattern) => pattern.test(String(url || "")));
|
|
32
41
|
}
|
|
33
42
|
|
|
@@ -150,10 +159,10 @@ async function readRecommendDetailState(client) {
|
|
|
150
159
|
};
|
|
151
160
|
}
|
|
152
161
|
|
|
153
|
-
export async function waitForRecommendDetailClosed(client, {
|
|
154
|
-
timeoutMs = 4000,
|
|
155
|
-
intervalMs = 250
|
|
156
|
-
} = {}) {
|
|
162
|
+
export async function waitForRecommendDetailClosed(client, {
|
|
163
|
+
timeoutMs = 4000,
|
|
164
|
+
intervalMs = 250
|
|
165
|
+
} = {}) {
|
|
157
166
|
const started = Date.now();
|
|
158
167
|
let lastState = null;
|
|
159
168
|
while (Date.now() - started <= timeoutMs) {
|
|
@@ -171,12 +180,67 @@ export async function waitForRecommendDetailClosed(client, {
|
|
|
171
180
|
closed: false,
|
|
172
181
|
elapsed_ms: Date.now() - started,
|
|
173
182
|
state: lastState
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function compactRect(rect) {
|
|
187
|
+
if (!rect) return null;
|
|
188
|
+
return {
|
|
189
|
+
x: Math.round(Number(rect.x) || 0),
|
|
190
|
+
y: Math.round(Number(rect.y) || 0),
|
|
191
|
+
width: Math.round(Number(rect.width) || 0),
|
|
192
|
+
height: Math.round(Number(rect.height) || 0)
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function compactDetailTarget(target) {
|
|
197
|
+
if (!target) return null;
|
|
198
|
+
return {
|
|
199
|
+
root: target.root || "",
|
|
200
|
+
root_node_id: target.root_node_id || null,
|
|
201
|
+
selector: target.selector || "",
|
|
202
|
+
node_id: target.node_id || null,
|
|
203
|
+
rect: compactRect(target.rect)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function compactDetailOpenState(state) {
|
|
208
|
+
if (!state) {
|
|
209
|
+
return {
|
|
210
|
+
open: false,
|
|
211
|
+
popup: null,
|
|
212
|
+
resume_iframe: null,
|
|
213
|
+
iframe_document_node_id: null
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
open: Boolean(state.popup || state.resumeIframe),
|
|
218
|
+
popup: compactDetailTarget(state.popup),
|
|
219
|
+
resume_iframe: compactDetailTarget(state.resumeIframe),
|
|
220
|
+
iframe_document_node_id: state.iframe?.documentNodeId || null
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function verifyRecommendDetailStillOpen(client, {
|
|
225
|
+
settleMs = 350
|
|
226
|
+
} = {}) {
|
|
227
|
+
const firstState = await readRecommendDetailState(client);
|
|
228
|
+
if (settleMs > 0) await sleep(settleMs);
|
|
229
|
+
const secondState = await readRecommendDetailState(client);
|
|
230
|
+
const first = compactDetailOpenState(firstState);
|
|
231
|
+
const second = compactDetailOpenState(secondState);
|
|
232
|
+
const stableOpen = Boolean(first.open && second.open);
|
|
233
|
+
return {
|
|
234
|
+
open: Boolean(second.open),
|
|
235
|
+
stable_open: stableOpen,
|
|
236
|
+
first,
|
|
237
|
+
second
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function findVisibleDetailTarget(client, roots, selectors) {
|
|
242
|
+
for (const root of roots) {
|
|
243
|
+
if (!root?.nodeId) continue;
|
|
180
244
|
for (const selector of selectors) {
|
|
181
245
|
const nodeIds = await querySelectorAll(client, root.nodeId, selector);
|
|
182
246
|
for (const nodeId of nodeIds) {
|
|
@@ -477,15 +541,35 @@ export async function closeRecommendDetail(client, {
|
|
|
477
541
|
closed: closedAfterClick.closed,
|
|
478
542
|
elapsed_ms: closedAfterClick.elapsed_ms
|
|
479
543
|
});
|
|
480
|
-
if (closedAfterClick.closed) {
|
|
481
|
-
return {
|
|
482
|
-
closed: true,
|
|
483
|
-
attempts
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
await
|
|
488
|
-
attempts.push(
|
|
544
|
+
if (closedAfterClick.closed) {
|
|
545
|
+
return {
|
|
546
|
+
closed: true,
|
|
547
|
+
attempts
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const outsideClick = await clickOutsideRecommendDetail(client, closedAfterClick.state || existingState);
|
|
552
|
+
attempts.push(outsideClick);
|
|
553
|
+
if (outsideClick.clicked) {
|
|
554
|
+
const closedAfterOutsideClick = await waitForRecommendDetailClosed(client, {
|
|
555
|
+
timeoutMs: closeWaitMs,
|
|
556
|
+
intervalMs: 250
|
|
557
|
+
});
|
|
558
|
+
attempts.push({
|
|
559
|
+
mode: "wait-closed-after-outside-click",
|
|
560
|
+
closed: closedAfterOutsideClick.closed,
|
|
561
|
+
elapsed_ms: closedAfterOutsideClick.elapsed_ms
|
|
562
|
+
});
|
|
563
|
+
if (closedAfterOutsideClick.closed) {
|
|
564
|
+
return {
|
|
565
|
+
closed: true,
|
|
566
|
+
attempts
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
await pressEscape(client);
|
|
572
|
+
attempts.push({ mode: "Escape-fallback" });
|
|
489
573
|
|
|
490
574
|
const closedAfterEscape = await waitForRecommendDetailClosed(client, {
|
|
491
575
|
timeoutMs: escapeWaitMs,
|
|
@@ -502,13 +586,33 @@ export async function closeRecommendDetail(client, {
|
|
|
502
586
|
attempts
|
|
503
587
|
};
|
|
504
588
|
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const verification = await verifyRecommendDetailStillOpen(client);
|
|
592
|
+
attempts.push({
|
|
593
|
+
mode: "final-close-verification",
|
|
594
|
+
open: verification.open,
|
|
595
|
+
stable_open: verification.stable_open,
|
|
596
|
+
popup: verification.second.popup,
|
|
597
|
+
resume_iframe: verification.second.resume_iframe
|
|
598
|
+
});
|
|
599
|
+
if (!verification.open) {
|
|
600
|
+
return {
|
|
601
|
+
closed: true,
|
|
602
|
+
attempts,
|
|
603
|
+
verification
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
closed: false,
|
|
609
|
+
reason: verification.stable_open
|
|
610
|
+
? "detail_still_visible_after_close_attempts"
|
|
611
|
+
: "detail_visibility_ambiguous_after_close_attempts",
|
|
612
|
+
attempts,
|
|
613
|
+
verification
|
|
614
|
+
};
|
|
615
|
+
}
|
|
512
616
|
|
|
513
617
|
async function findVisibleCloseTarget(client, roots, selectors) {
|
|
514
618
|
let fallback = null;
|
|
@@ -540,15 +644,107 @@ async function findVisibleCloseTarget(client, roots, selectors) {
|
|
|
540
644
|
return fallback;
|
|
541
645
|
}
|
|
542
646
|
|
|
543
|
-
async function pressEscape(client) {
|
|
544
|
-
await pressKey(client, "Escape", {
|
|
545
|
-
code: "Escape",
|
|
546
|
-
windowsVirtualKeyCode: 27,
|
|
547
|
-
nativeVirtualKeyCode: 27
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
|
|
647
|
+
async function pressEscape(client) {
|
|
648
|
+
await pressKey(client, "Escape", {
|
|
649
|
+
code: "Escape",
|
|
650
|
+
windowsVirtualKeyCode: 27,
|
|
651
|
+
nativeVirtualKeyCode: 27
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function clampPointCoordinate(value, min, max) {
|
|
656
|
+
return Math.max(min, Math.min(max, value));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function getClickViewport(client) {
|
|
660
|
+
try {
|
|
661
|
+
const metrics = typeof client?.Page?.getLayoutMetrics === "function"
|
|
662
|
+
? await client.Page.getLayoutMetrics()
|
|
663
|
+
: null;
|
|
664
|
+
const viewport = metrics?.cssLayoutViewport || metrics?.layoutViewport || metrics?.visualViewport || {};
|
|
665
|
+
return {
|
|
666
|
+
width: Number(viewport.clientWidth || viewport.width || 1440),
|
|
667
|
+
height: Number(viewport.clientHeight || viewport.height || 900)
|
|
668
|
+
};
|
|
669
|
+
} catch {
|
|
670
|
+
return {
|
|
671
|
+
width: 1440,
|
|
672
|
+
height: 900
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function getOutsideClickPoint(rect, viewport) {
|
|
678
|
+
if (!rect || rect.width <= 2 || rect.height <= 2) return null;
|
|
679
|
+
const margin = 24;
|
|
680
|
+
const minX = 8;
|
|
681
|
+
const minY = 8;
|
|
682
|
+
const maxX = Math.max(minX, (Number(viewport?.width) || 1440) - 8);
|
|
683
|
+
const maxY = Math.max(minY, (Number(viewport?.height) || 900) - 8);
|
|
684
|
+
const midX = rect.x + rect.width / 2;
|
|
685
|
+
const midY = rect.y + Math.min(Math.max(rect.height * 0.2, 48), Math.max(48, rect.height - 24));
|
|
686
|
+
const candidates = [
|
|
687
|
+
{ side: "left", x: rect.x - margin, y: midY },
|
|
688
|
+
{ side: "right", x: rect.x + rect.width + margin, y: midY },
|
|
689
|
+
{ side: "above", x: midX, y: rect.y - margin },
|
|
690
|
+
{ side: "below", x: midX, y: rect.y + rect.height + margin },
|
|
691
|
+
{ side: "viewport-corner", x: 16, y: 16 }
|
|
692
|
+
];
|
|
693
|
+
|
|
694
|
+
for (const candidate of candidates) {
|
|
695
|
+
const x = clampPointCoordinate(candidate.x, minX, maxX);
|
|
696
|
+
const y = clampPointCoordinate(candidate.y, minY, maxY);
|
|
697
|
+
const insideRect = (
|
|
698
|
+
x >= rect.x
|
|
699
|
+
&& x <= rect.x + rect.width
|
|
700
|
+
&& y >= rect.y
|
|
701
|
+
&& y <= rect.y + rect.height
|
|
702
|
+
);
|
|
703
|
+
if (!insideRect) {
|
|
704
|
+
return {
|
|
705
|
+
...candidate,
|
|
706
|
+
x,
|
|
707
|
+
y
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function clickOutsideRecommendDetail(client, detailState) {
|
|
715
|
+
const rootState = detailState?.roots?.length
|
|
716
|
+
? detailState
|
|
717
|
+
: await readRecommendDetailState(client);
|
|
718
|
+
const boundaryTarget = await findVisibleDetailTarget(
|
|
719
|
+
client,
|
|
720
|
+
rootState.roots || [],
|
|
721
|
+
DETAIL_OUTSIDE_CLOSE_BOUNDARY_SELECTORS
|
|
722
|
+
);
|
|
723
|
+
const target = boundaryTarget || rootState.resumeIframe || rootState.popup || null;
|
|
724
|
+
const viewport = await getClickViewport(client);
|
|
725
|
+
const point = getOutsideClickPoint(target?.rect, viewport);
|
|
726
|
+
if (!point) {
|
|
727
|
+
return {
|
|
728
|
+
clicked: false,
|
|
729
|
+
mode: "outside-modal-click",
|
|
730
|
+
reason: "no_outside_click_point",
|
|
731
|
+
selector: target?.selector || null,
|
|
732
|
+
root: target?.root || null
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
await clickPoint(client, point.x, point.y);
|
|
736
|
+
return {
|
|
737
|
+
clicked: true,
|
|
738
|
+
mode: "outside-modal-click",
|
|
739
|
+
selector: target?.selector || null,
|
|
740
|
+
root: target?.root || null,
|
|
741
|
+
side: point.side,
|
|
742
|
+
x: Math.round(point.x),
|
|
743
|
+
y: Math.round(point.y)
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export async function extractRecommendDetailCandidate(client, {
|
|
552
748
|
cardCandidate,
|
|
553
749
|
cardNodeId,
|
|
554
750
|
detailState,
|
|
@@ -420,17 +420,31 @@ export function countRecommendResultStatuses(results = [], {
|
|
|
420
420
|
};
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
-
function countPassedResults(results = []) {
|
|
424
|
-
return countRecommendResultStatuses(results).passed;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function
|
|
428
|
-
if (!
|
|
429
|
-
return {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
423
|
+
function countPassedResults(results = []) {
|
|
424
|
+
return countRecommendResultStatuses(results).passed;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function compactCloseResult(closeResult) {
|
|
428
|
+
if (!closeResult) return null;
|
|
429
|
+
return {
|
|
430
|
+
closed: Boolean(closeResult.closed),
|
|
431
|
+
reason: closeResult.reason || null,
|
|
432
|
+
attempts: closeResult.attempts || [],
|
|
433
|
+
verification: closeResult.verification || null
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
|
|
438
|
+
if (!error) return null;
|
|
439
|
+
const result = {
|
|
440
|
+
code: error.code || fallbackCode,
|
|
441
|
+
message: error.message || String(error)
|
|
442
|
+
};
|
|
443
|
+
if (error.close_result) {
|
|
444
|
+
result.close_result = compactCloseResult(error.close_result);
|
|
445
|
+
}
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
434
448
|
|
|
435
449
|
function createRecommendCloseFailureError(closeResult) {
|
|
436
450
|
const error = new Error(closeResult?.reason || "Recommend detail did not close before recovery");
|