@reconcrap/boss-recommend-mcp 1.3.38 → 2.0.0
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/README.md +53 -33
- package/package.json +61 -9
- package/skills/boss-recommend-pipeline/SKILL.md +4 -0
- package/src/chat-mcp.js +1333 -0
- package/src/chat-runtime-config.js +559 -0
- package/src/cli.js +1095 -196
- package/src/core/browser/index.js +378 -0
- package/src/core/capture/index.js +298 -0
- package/src/core/cv-acquisition/index.js +219 -0
- package/src/core/greet-quota/index.js +54 -0
- package/src/core/infinite-list/index.js +459 -0
- package/src/core/reporting/legacy-csv.js +332 -0
- package/src/core/run/index.js +286 -0
- package/src/core/screening/index.js +1166 -0
- package/src/core/self-heal/index.js +848 -0
- package/src/domains/chat/cards.js +129 -0
- package/src/domains/chat/constants.js +183 -0
- package/src/domains/chat/detail.js +1369 -0
- package/src/domains/chat/index.js +7 -0
- package/src/domains/chat/jobs.js +334 -0
- package/src/domains/chat/page-guard.js +88 -0
- package/src/domains/chat/roots.js +56 -0
- package/src/domains/chat/run-service.js +1101 -0
- package/src/domains/recommend/actions.js +457 -0
- package/src/domains/recommend/cards.js +228 -0
- package/src/domains/recommend/constants.js +141 -0
- package/src/domains/recommend/detail.js +341 -0
- package/src/domains/recommend/filters.js +581 -0
- package/src/domains/recommend/index.js +10 -0
- package/src/domains/recommend/jobs.js +232 -0
- package/src/domains/recommend/refresh.js +204 -0
- package/src/domains/recommend/roots.js +78 -0
- package/src/domains/recommend/run-service.js +903 -0
- package/src/domains/recommend/scopes.js +245 -0
- package/src/domains/recruit/actions.js +277 -0
- package/src/domains/recruit/cards.js +67 -0
- package/src/domains/recruit/constants.js +130 -0
- package/src/domains/recruit/detail.js +414 -0
- package/src/domains/recruit/index.js +9 -0
- package/src/domains/recruit/instruction-parser.js +451 -0
- package/src/domains/recruit/refresh.js +40 -0
- package/src/domains/recruit/roots.js +68 -0
- package/src/domains/recruit/run-service.js +580 -0
- package/src/domains/recruit/search.js +1149 -0
- package/src/index.js +578 -419
- package/src/recommend-mcp.js +1257 -0
- package/src/recruit-mcp.js +1035 -0
- package/src/adapters.js +0 -3079
- package/src/boss-chat.js +0 -1037
- package/src/pipeline.js +0 -2249
- package/src/recommend-healing-config.js +0 -131
- package/src/recommend-healing-rules.json +0 -261
- package/src/self-heal.js +0 -2237
- package/src/test-adapters-runtime.js +0 -628
- package/src/test-boss-chat.js +0 -3196
- package/src/test-index-async.js +0 -498
- package/src/test-parser.js +0 -742
- package/src/test-pipeline.js +0 -2703
- package/src/test-run-state.js +0 -152
- package/src/test-self-heal.js +0 -224
- package/vendor/boss-chat-cli/README.md +0 -134
- package/vendor/boss-chat-cli/package.json +0 -53
- package/vendor/boss-chat-cli/src/app.js +0 -1501
- package/vendor/boss-chat-cli/src/browser/chat-page.js +0 -3562
- package/vendor/boss-chat-cli/src/cli.js +0 -1713
- package/vendor/boss-chat-cli/src/mcp/server.js +0 -149
- package/vendor/boss-chat-cli/src/mcp/tool-runtime.js +0 -193
- package/vendor/boss-chat-cli/src/runtime/async-run-state.js +0 -260
- package/vendor/boss-chat-cli/src/runtime/interaction.js +0 -102
- package/vendor/boss-chat-cli/src/runtime/run-control.js +0 -102
- package/vendor/boss-chat-cli/src/services/chrome-client.js +0 -107
- package/vendor/boss-chat-cli/src/services/llm.js +0 -1292
- package/vendor/boss-chat-cli/src/services/llm.test.js +0 -326
- package/vendor/boss-chat-cli/src/services/profile-store.js +0 -173
- package/vendor/boss-chat-cli/src/services/report-store.js +0 -317
- package/vendor/boss-chat-cli/src/services/resume-capture.js +0 -469
- package/vendor/boss-chat-cli/src/services/resume-network.js +0 -727
- package/vendor/boss-chat-cli/src/services/state-store.js +0 -90
- package/vendor/boss-chat-cli/src/utils/customer-key.js +0 -82
- package/vendor/boss-recommend-screen-cli/boss-recommend-screen-cli.cjs +0 -6927
- package/vendor/boss-recommend-screen-cli/scripts/capture-full-resume-canvas.cjs +0 -817
- package/vendor/boss-recommend-screen-cli/scripts/stitch_resume_chunks.py +0 -141
- package/vendor/boss-recommend-screen-cli/test-recoverable-resume-failures.cjs +0 -2294
- package/vendor/boss-recommend-search-cli/src/cli.js +0 -1698
- package/vendor/boss-recommend-search-cli/src/test-job-selection.js +0 -211
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearFocusedInput,
|
|
3
|
+
clickNodeCenter,
|
|
4
|
+
countSelectors,
|
|
5
|
+
describeNode,
|
|
6
|
+
findFirstNode,
|
|
7
|
+
getAttributesMap,
|
|
8
|
+
getOuterHTML,
|
|
9
|
+
insertText,
|
|
10
|
+
pressKey,
|
|
11
|
+
querySelector,
|
|
12
|
+
querySelectorAll,
|
|
13
|
+
sleep
|
|
14
|
+
} from "../../core/browser/index.js";
|
|
15
|
+
import {
|
|
16
|
+
htmlToText,
|
|
17
|
+
normalizeText
|
|
18
|
+
} from "../../core/screening/index.js";
|
|
19
|
+
import {
|
|
20
|
+
RECRUIT_CARD_SELECTOR,
|
|
21
|
+
RECRUIT_TARGET_URL,
|
|
22
|
+
RECRUIT_NO_DATA_SELECTORS,
|
|
23
|
+
RECRUIT_SEARCH_SELECTORS
|
|
24
|
+
} from "./constants.js";
|
|
25
|
+
import {
|
|
26
|
+
getRecruitRoots,
|
|
27
|
+
waitForRecruitRoots
|
|
28
|
+
} from "./roots.js";
|
|
29
|
+
|
|
30
|
+
const DEFAULT_RECRUIT_KEYWORD = "算法工程师";
|
|
31
|
+
const ACTIVE_CLASS_PATTERN = /\b(active|selected|checked|cur|current)\b/i;
|
|
32
|
+
const DEFAULT_RECRUIT_RESET_TIMEOUT_MS = 180000;
|
|
33
|
+
const DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS = 90000;
|
|
34
|
+
const DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS = 30000;
|
|
35
|
+
const DEFAULT_RECRUIT_CITY_NO_RESULT_FALLBACK_MS = 15000;
|
|
36
|
+
|
|
37
|
+
const DEGREE_LABEL_MAP = new Map([
|
|
38
|
+
["不限", "不限"],
|
|
39
|
+
["本科", "本科"],
|
|
40
|
+
["本科及以上", "本科"],
|
|
41
|
+
["硕士", "硕士"],
|
|
42
|
+
["硕士及以上", "硕士"],
|
|
43
|
+
["博士", "博士"]
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const NATIONAL_CITY_LABELS = new Set([
|
|
47
|
+
"全国",
|
|
48
|
+
"不限",
|
|
49
|
+
"不限城市",
|
|
50
|
+
"全部",
|
|
51
|
+
"All",
|
|
52
|
+
"ALL",
|
|
53
|
+
"all"
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const CITY_NO_RESULT_LABELS = new Set([
|
|
57
|
+
"暂无结果",
|
|
58
|
+
"暂无数据",
|
|
59
|
+
"无结果"
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
function uniqueNodeIds(nodeIds = []) {
|
|
63
|
+
return Array.from(new Set(nodeIds.filter(Boolean)));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildRecruitSearchFrameUrl(pageUrl = RECRUIT_TARGET_URL) {
|
|
67
|
+
const origin = new URL(pageUrl).origin;
|
|
68
|
+
return `${origin}/web/frame/search/?jobId=&keywords=&t=${Date.now()}&source=&city=`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function navigateRecruitSearchFrame(client, iframeNodeId, {
|
|
72
|
+
pageUrl = RECRUIT_TARGET_URL,
|
|
73
|
+
reason = "reset_frame"
|
|
74
|
+
} = {}) {
|
|
75
|
+
if (!iframeNodeId || typeof client?.Page?.navigate !== "function") return null;
|
|
76
|
+
const iframeNode = await describeNode(client, iframeNodeId, { depth: 1, pierce: true });
|
|
77
|
+
const frameId = iframeNode?.frameId;
|
|
78
|
+
if (!frameId) return null;
|
|
79
|
+
const frameUrl = buildRecruitSearchFrameUrl(pageUrl);
|
|
80
|
+
await client.Page.navigate({ frameId, url: frameUrl });
|
|
81
|
+
return {
|
|
82
|
+
method: "Page.navigate",
|
|
83
|
+
scope: "frame",
|
|
84
|
+
frame_id: frameId,
|
|
85
|
+
url: frameUrl,
|
|
86
|
+
reason
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function normalizeRecruitSearchLabel(label) {
|
|
91
|
+
return normalizeText(label).replace(/\s+/g, "");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildRecruitJobTitleSearchTerms(jobTitle) {
|
|
95
|
+
const normalized = normalizeText(jobTitle);
|
|
96
|
+
if (!normalized) return [];
|
|
97
|
+
const variants = [
|
|
98
|
+
normalized,
|
|
99
|
+
normalized.replace(/\s*[__]\s*/g, " "),
|
|
100
|
+
normalized.replace(/\s*[||]\s*/g, " ")
|
|
101
|
+
].map(normalizeText).filter(Boolean);
|
|
102
|
+
const separatorParts = normalized.split(/\s*[__||]\s*/).map(normalizeText).filter(Boolean);
|
|
103
|
+
if (separatorParts.length > 1) {
|
|
104
|
+
variants.push(separatorParts.join(" "));
|
|
105
|
+
variants.push(separatorParts[0]);
|
|
106
|
+
}
|
|
107
|
+
return Array.from(new Set(variants));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function isRecruitNationalCity(city) {
|
|
111
|
+
return NATIONAL_CITY_LABELS.has(normalizeRecruitSearchLabel(city));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function resolveRecruitDegreeLabel(degree) {
|
|
115
|
+
const normalized = normalizeRecruitSearchLabel(degree || "不限");
|
|
116
|
+
return DEGREE_LABEL_MAP.get(normalized) || normalized || "不限";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function normalizeRecruitDegreeLabels(value) {
|
|
120
|
+
const rawItems = Array.isArray(value)
|
|
121
|
+
? value
|
|
122
|
+
: typeof value === "string"
|
|
123
|
+
? value.split(/[,,、|/]/)
|
|
124
|
+
: [];
|
|
125
|
+
const labels = rawItems
|
|
126
|
+
.map(resolveRecruitDegreeLabel)
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
const uniqueLabels = uniqueNodeIds(labels);
|
|
129
|
+
return uniqueLabels.length ? uniqueLabels : ["不限"];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function normalizeRecruitSearchParams(searchParams = {}) {
|
|
133
|
+
const degrees = normalizeRecruitDegreeLabels(searchParams.degrees || searchParams.degree || "不限");
|
|
134
|
+
const normalized = {
|
|
135
|
+
city: normalizeText(searchParams.city) || null,
|
|
136
|
+
degree: degrees[0] || "不限",
|
|
137
|
+
degrees,
|
|
138
|
+
schools: Array.isArray(searchParams.schools)
|
|
139
|
+
? searchParams.schools.map(normalizeText).filter(Boolean)
|
|
140
|
+
: [],
|
|
141
|
+
keyword: normalizeText(searchParams.keyword) || DEFAULT_RECRUIT_KEYWORD,
|
|
142
|
+
filter_recent_viewed: typeof searchParams.filter_recent_viewed === "boolean"
|
|
143
|
+
? searchParams.filter_recent_viewed
|
|
144
|
+
: null
|
|
145
|
+
};
|
|
146
|
+
const job = normalizeText(searchParams.job || searchParams.job_title || searchParams.selected_job);
|
|
147
|
+
if (job) normalized.job = job;
|
|
148
|
+
return normalized;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function hasRecruitSearchParams(searchParams = {}) {
|
|
152
|
+
const degrees = normalizeRecruitDegreeLabels(searchParams.degrees || searchParams.degree || "不限");
|
|
153
|
+
const job = normalizeText(searchParams.job || searchParams.job_title || searchParams.selected_job);
|
|
154
|
+
const normalized = {
|
|
155
|
+
city: normalizeText(searchParams.city) || null,
|
|
156
|
+
degree: degrees[0] || "不限",
|
|
157
|
+
degrees,
|
|
158
|
+
schools: Array.isArray(searchParams.schools)
|
|
159
|
+
? searchParams.schools.map(normalizeText).filter(Boolean)
|
|
160
|
+
: [],
|
|
161
|
+
keyword: normalizeText(searchParams.keyword),
|
|
162
|
+
filter_recent_viewed: typeof searchParams.filter_recent_viewed === "boolean"
|
|
163
|
+
? searchParams.filter_recent_viewed
|
|
164
|
+
: null
|
|
165
|
+
};
|
|
166
|
+
return Boolean(
|
|
167
|
+
job
|
|
168
|
+
|| normalized.city
|
|
169
|
+
|| normalized.degrees.some((degree) => degree && degree !== "不限")
|
|
170
|
+
|| normalized.schools.length
|
|
171
|
+
|| normalized.keyword
|
|
172
|
+
|| typeof normalized.filter_recent_viewed === "boolean"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function candidateIsActive(attributes = {}, outerHTML = "") {
|
|
177
|
+
const className = attributes.class || "";
|
|
178
|
+
const openingTag = String(outerHTML || "").split(">")[0] || "";
|
|
179
|
+
return ACTIVE_CLASS_PATTERN.test(className)
|
|
180
|
+
|| ACTIVE_CLASS_PATTERN.test(openingTag)
|
|
181
|
+
|| /\bchecked(?:=["']?checked)?\b/i.test(openingTag);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function readTextCandidate(client, nodeId, {
|
|
185
|
+
selector = "",
|
|
186
|
+
index = 0
|
|
187
|
+
} = {}) {
|
|
188
|
+
const [attributes, outerHTML] = await Promise.all([
|
|
189
|
+
getAttributesMap(client, nodeId),
|
|
190
|
+
getOuterHTML(client, nodeId)
|
|
191
|
+
]);
|
|
192
|
+
const text = normalizeText(htmlToText(outerHTML));
|
|
193
|
+
return {
|
|
194
|
+
node_id: nodeId,
|
|
195
|
+
selector,
|
|
196
|
+
index,
|
|
197
|
+
label: normalizeRecruitSearchLabel(text),
|
|
198
|
+
text,
|
|
199
|
+
active: candidateIsActive(attributes, outerHTML),
|
|
200
|
+
class_name: attributes.class || "",
|
|
201
|
+
attributes
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function listTextCandidates(client, rootNodeId, selectors = []) {
|
|
206
|
+
const candidates = [];
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
for (const selector of selectors) {
|
|
209
|
+
const nodeIds = uniqueNodeIds(await querySelectorAll(client, rootNodeId, selector));
|
|
210
|
+
for (let index = 0; index < nodeIds.length; index += 1) {
|
|
211
|
+
const nodeId = nodeIds[index];
|
|
212
|
+
if (seen.has(nodeId)) continue;
|
|
213
|
+
seen.add(nodeId);
|
|
214
|
+
candidates.push(await readTextCandidate(client, nodeId, { selector, index }));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return candidates;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function chooseRecruitTextCandidate(candidates = [], {
|
|
221
|
+
label = "",
|
|
222
|
+
match = "exact"
|
|
223
|
+
} = {}) {
|
|
224
|
+
const target = normalizeRecruitSearchLabel(label);
|
|
225
|
+
if (!target) return null;
|
|
226
|
+
const byExact = candidates.find((candidate) => candidate.label === target);
|
|
227
|
+
if (byExact) return byExact;
|
|
228
|
+
if (match === "exact") return null;
|
|
229
|
+
const byPrefix = candidates.find((candidate) => (
|
|
230
|
+
candidate.label.startsWith(target)
|
|
231
|
+
|| target.startsWith(candidate.label)
|
|
232
|
+
));
|
|
233
|
+
if (byPrefix) return byPrefix;
|
|
234
|
+
if (match === "prefix") return null;
|
|
235
|
+
return candidates.find((candidate) => candidate.label.includes(target) || target.includes(candidate.label)) || null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function findTextCandidate(client, rootNodeId, selectors, label, options = {}) {
|
|
239
|
+
const candidates = await listTextCandidates(client, rootNodeId, selectors);
|
|
240
|
+
return {
|
|
241
|
+
candidate: chooseRecruitTextCandidate(candidates, { label, ...options }),
|
|
242
|
+
candidates
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function summarizeTextCandidates(candidates = [], limit = 20) {
|
|
247
|
+
return candidates.map((item) => ({
|
|
248
|
+
label: item.text,
|
|
249
|
+
active: item.active,
|
|
250
|
+
node_id: item.node_id,
|
|
251
|
+
selector: item.selector
|
|
252
|
+
})).slice(0, limit);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function waitForRecruitTextCandidate(client, rootNodeId, selectors, label, {
|
|
256
|
+
timeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS,
|
|
257
|
+
intervalMs = 300,
|
|
258
|
+
match = "exact"
|
|
259
|
+
} = {}) {
|
|
260
|
+
const started = Date.now();
|
|
261
|
+
let candidate = null;
|
|
262
|
+
let candidates = [];
|
|
263
|
+
while (Date.now() - started <= timeoutMs) {
|
|
264
|
+
const found = await findTextCandidate(client, rootNodeId, selectors, label, { match });
|
|
265
|
+
candidate = found.candidate;
|
|
266
|
+
candidates = found.candidates;
|
|
267
|
+
if (candidate) break;
|
|
268
|
+
await sleep(intervalMs);
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
candidate,
|
|
272
|
+
candidates,
|
|
273
|
+
elapsed_ms: Date.now() - started
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function waitForRecruitJobTitleCandidate(client, rootNodeId, selectors, jobTitle, {
|
|
278
|
+
timeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS,
|
|
279
|
+
intervalMs = 300
|
|
280
|
+
} = {}) {
|
|
281
|
+
const terms = buildRecruitJobTitleSearchTerms(jobTitle);
|
|
282
|
+
const started = Date.now();
|
|
283
|
+
let candidate = null;
|
|
284
|
+
let candidates = [];
|
|
285
|
+
let matchedTerm = "";
|
|
286
|
+
while (Date.now() - started <= timeoutMs) {
|
|
287
|
+
candidates = await listTextCandidates(client, rootNodeId, selectors);
|
|
288
|
+
for (const term of terms) {
|
|
289
|
+
const found = chooseRecruitTextCandidate(candidates, { label: term, match: "contains" });
|
|
290
|
+
if (found) {
|
|
291
|
+
candidate = found;
|
|
292
|
+
matchedTerm = term;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (candidate) break;
|
|
297
|
+
await sleep(intervalMs);
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
candidate,
|
|
301
|
+
candidates,
|
|
302
|
+
matched_term: matchedTerm,
|
|
303
|
+
search_terms: terms,
|
|
304
|
+
elapsed_ms: Date.now() - started
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function clickFirstNodeBySelectors(client, rootNodeId, selectors, {
|
|
309
|
+
optional = false,
|
|
310
|
+
scrollIntoView = true
|
|
311
|
+
} = {}) {
|
|
312
|
+
const found = await findFirstNode(client, rootNodeId, selectors);
|
|
313
|
+
if (!found) {
|
|
314
|
+
if (optional) return { clicked: false, reason: "not_found" };
|
|
315
|
+
throw new Error(`Recruit search node was not found for selectors: ${selectors.join(", ")}`);
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const box = await clickNodeCenter(client, found.nodeId, { scrollIntoView });
|
|
319
|
+
await sleep(250);
|
|
320
|
+
return {
|
|
321
|
+
clicked: true,
|
|
322
|
+
selector: found.selector,
|
|
323
|
+
node_id: found.nodeId,
|
|
324
|
+
box
|
|
325
|
+
};
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (optional) {
|
|
328
|
+
return {
|
|
329
|
+
clicked: false,
|
|
330
|
+
reason: "not_clickable",
|
|
331
|
+
selector: found.selector,
|
|
332
|
+
node_id: found.nodeId,
|
|
333
|
+
error: error?.message || String(error)
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function getRecruitSearchCounts(client, frameNodeId) {
|
|
341
|
+
return countSelectors(client, frameNodeId, {
|
|
342
|
+
keyword_input: RECRUIT_SEARCH_SELECTORS.keywordInput.join(", "),
|
|
343
|
+
search_button: RECRUIT_SEARCH_SELECTORS.searchButton.join(", "),
|
|
344
|
+
degree_option: RECRUIT_SEARCH_SELECTORS.degreeOption.join(", "),
|
|
345
|
+
school_item: RECRUIT_SEARCH_SELECTORS.schoolItem.join(", "),
|
|
346
|
+
recent_viewed_label: RECRUIT_SEARCH_SELECTORS.recentViewedLabel.join(", "),
|
|
347
|
+
candidate_card: RECRUIT_CARD_SELECTOR,
|
|
348
|
+
no_data: RECRUIT_NO_DATA_SELECTORS.join(", ")
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function waitForRecruitSearchControls(client, {
|
|
353
|
+
timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
|
|
354
|
+
intervalMs = 300
|
|
355
|
+
} = {}) {
|
|
356
|
+
const started = Date.now();
|
|
357
|
+
let lastState = null;
|
|
358
|
+
while (Date.now() - started <= timeoutMs) {
|
|
359
|
+
const roots = await getRecruitRoots(client, { requireFrame: false });
|
|
360
|
+
const frameNodeId = roots.iframe?.documentNodeId;
|
|
361
|
+
if (frameNodeId) {
|
|
362
|
+
const counts = await getRecruitSearchCounts(client, frameNodeId);
|
|
363
|
+
lastState = {
|
|
364
|
+
ok: counts.keyword_input > 0 && counts.search_button > 0,
|
|
365
|
+
elapsed_ms: Date.now() - started,
|
|
366
|
+
iframe_selector: roots.iframe.selector,
|
|
367
|
+
iframe_document_node_id: frameNodeId,
|
|
368
|
+
counts
|
|
369
|
+
};
|
|
370
|
+
if (lastState.ok) return lastState;
|
|
371
|
+
}
|
|
372
|
+
await sleep(intervalMs);
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
elapsed_ms: Date.now() - started,
|
|
377
|
+
...(lastState || {})
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function resetRecruitSearchPage(client, {
|
|
382
|
+
url = RECRUIT_TARGET_URL,
|
|
383
|
+
settleMs = 5000,
|
|
384
|
+
timeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS
|
|
385
|
+
} = {}) {
|
|
386
|
+
const actions = [];
|
|
387
|
+
const rootTimeoutMs = Math.min(timeoutMs, 90000);
|
|
388
|
+
async function waitForRootsAfterSettle() {
|
|
389
|
+
await sleep(settleMs);
|
|
390
|
+
return waitForRecruitRoots(client, {
|
|
391
|
+
timeoutMs: rootTimeoutMs,
|
|
392
|
+
intervalMs: 300
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function waitForControls() {
|
|
397
|
+
return waitForRecruitSearchControls(client, {
|
|
398
|
+
timeoutMs,
|
|
399
|
+
intervalMs: 300
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (typeof client?.Page?.reload === "function") {
|
|
404
|
+
await client.Page.reload({ ignoreCache: true });
|
|
405
|
+
actions.push({ method: "Page.reload" });
|
|
406
|
+
} else {
|
|
407
|
+
await client.Page.navigate({ url });
|
|
408
|
+
actions.push({ method: "Page.navigate", url });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let roots = await waitForRootsAfterSettle();
|
|
412
|
+
const frameReset = await navigateRecruitSearchFrame(client, roots?.iframe?.nodeId, {
|
|
413
|
+
pageUrl: url,
|
|
414
|
+
reason: "reset_frame_after_page_reload"
|
|
415
|
+
});
|
|
416
|
+
if (frameReset) {
|
|
417
|
+
actions.push(frameReset);
|
|
418
|
+
await sleep(settleMs);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let controls = await waitForControls();
|
|
422
|
+
if (!controls.ok && typeof client?.Page?.navigate === "function") {
|
|
423
|
+
await client.Page.navigate({ url });
|
|
424
|
+
actions.push({
|
|
425
|
+
method: "Page.navigate",
|
|
426
|
+
url,
|
|
427
|
+
reason: roots?.iframe?.documentNodeId ? "controls_not_ready" : "iframe_not_ready"
|
|
428
|
+
});
|
|
429
|
+
roots = await waitForRootsAfterSettle();
|
|
430
|
+
const fallbackFrameReset = await navigateRecruitSearchFrame(client, roots?.iframe?.nodeId, {
|
|
431
|
+
pageUrl: url,
|
|
432
|
+
reason: "reset_frame_after_page_navigate"
|
|
433
|
+
});
|
|
434
|
+
if (fallbackFrameReset) {
|
|
435
|
+
actions.push(fallbackFrameReset);
|
|
436
|
+
await sleep(settleMs);
|
|
437
|
+
}
|
|
438
|
+
controls = await waitForControls();
|
|
439
|
+
}
|
|
440
|
+
roots = await getRecruitRoots(client, { requireFrame: false });
|
|
441
|
+
if (!controls.ok && !roots?.iframe?.documentNodeId) {
|
|
442
|
+
throw new Error("Recruit search page reset did not expose searchFrame iframe");
|
|
443
|
+
}
|
|
444
|
+
if (!controls.ok) {
|
|
445
|
+
throw new Error("Recruit search page reset exposed iframe but search controls were not ready");
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
actions,
|
|
449
|
+
target_url: url,
|
|
450
|
+
iframe_selector: controls.iframe_selector || roots.iframe.selector,
|
|
451
|
+
iframe_document_node_id: controls.iframe_document_node_id || roots.iframe.documentNodeId,
|
|
452
|
+
controls
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export async function setRecruitKeyword(client, frameNodeId, keyword) {
|
|
457
|
+
const normalizedKeyword = normalizeText(keyword);
|
|
458
|
+
if (!normalizedKeyword) {
|
|
459
|
+
return { applied: false, reason: "empty_keyword" };
|
|
460
|
+
}
|
|
461
|
+
const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.keywordInput);
|
|
462
|
+
await clearFocusedInput(client);
|
|
463
|
+
await sleep(120);
|
|
464
|
+
await insertText(client, normalizedKeyword);
|
|
465
|
+
await sleep(350);
|
|
466
|
+
return {
|
|
467
|
+
applied: true,
|
|
468
|
+
keyword: normalizedKeyword,
|
|
469
|
+
input
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export async function selectRecruitDefaultJobTitle(client, frameNodeId) {
|
|
474
|
+
return clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.jobTitleOption, {
|
|
475
|
+
optional: true
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function setRecruitJobTitle(client, frameNodeId, jobTitle, {
|
|
480
|
+
optionTimeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS
|
|
481
|
+
} = {}) {
|
|
482
|
+
const normalizedJobTitle = normalizeText(jobTitle);
|
|
483
|
+
if (!normalizedJobTitle) {
|
|
484
|
+
return { applied: false, reason: "empty_job_title" };
|
|
485
|
+
}
|
|
486
|
+
const lookup = await waitForRecruitJobTitleCandidate(
|
|
487
|
+
client,
|
|
488
|
+
frameNodeId,
|
|
489
|
+
RECRUIT_SEARCH_SELECTORS.jobTitleOption,
|
|
490
|
+
normalizedJobTitle,
|
|
491
|
+
{ timeoutMs: Math.min(optionTimeoutMs, 30000) }
|
|
492
|
+
);
|
|
493
|
+
if (!lookup.candidate) {
|
|
494
|
+
throw new Error(`Recruit job title option was not found: ${normalizedJobTitle}`);
|
|
495
|
+
}
|
|
496
|
+
let box = null;
|
|
497
|
+
if (!lookup.candidate.active) {
|
|
498
|
+
box = await clickNodeCenter(client, lookup.candidate.node_id, { scrollIntoView: true });
|
|
499
|
+
await sleep(500);
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
applied: true,
|
|
503
|
+
requested_job: normalizedJobTitle,
|
|
504
|
+
selected_label: lookup.candidate.text,
|
|
505
|
+
matched_term: lookup.matched_term,
|
|
506
|
+
search_terms: lookup.search_terms,
|
|
507
|
+
selected_node_id: lookup.candidate.node_id,
|
|
508
|
+
was_active: lookup.candidate.active,
|
|
509
|
+
clicked: !lookup.candidate.active,
|
|
510
|
+
box,
|
|
511
|
+
discovered_options: summarizeTextCandidates(lookup.candidates, 30)
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function setRecruitDegree(client, frameNodeId, degree) {
|
|
516
|
+
const degreeLabel = resolveRecruitDegreeLabel(degree);
|
|
517
|
+
if (!degreeLabel || degreeLabel === "不限") {
|
|
518
|
+
return { applied: false, reason: "unlimited_degree", degree: degreeLabel || "不限" };
|
|
519
|
+
}
|
|
520
|
+
const { candidate, candidates } = await findTextCandidate(
|
|
521
|
+
client,
|
|
522
|
+
frameNodeId,
|
|
523
|
+
RECRUIT_SEARCH_SELECTORS.degreeOption,
|
|
524
|
+
degreeLabel,
|
|
525
|
+
{ match: "prefix" }
|
|
526
|
+
);
|
|
527
|
+
if (!candidate) {
|
|
528
|
+
throw new Error(`Recruit degree option was not found: ${degreeLabel}`);
|
|
529
|
+
}
|
|
530
|
+
const box = await clickNodeCenter(client, candidate.node_id, { scrollIntoView: true });
|
|
531
|
+
await sleep(350);
|
|
532
|
+
return {
|
|
533
|
+
applied: true,
|
|
534
|
+
requested_degree: degree,
|
|
535
|
+
selected_label: candidate.text,
|
|
536
|
+
selected_node_id: candidate.node_id,
|
|
537
|
+
was_active: candidate.active,
|
|
538
|
+
box,
|
|
539
|
+
discovered_options: candidates.map((item) => ({
|
|
540
|
+
label: item.text,
|
|
541
|
+
active: item.active,
|
|
542
|
+
node_id: item.node_id,
|
|
543
|
+
selector: item.selector
|
|
544
|
+
}))
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export async function setRecruitDegrees(client, frameNodeId, degrees = []) {
|
|
549
|
+
const labels = normalizeRecruitDegreeLabels(degrees).filter((label) => label && label !== "不限");
|
|
550
|
+
if (!labels.length) {
|
|
551
|
+
return { applied: false, reason: "unlimited_degree", degrees: ["不限"], selected: [] };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const selected = [];
|
|
555
|
+
let discoveredOptions = [];
|
|
556
|
+
for (const label of labels) {
|
|
557
|
+
const { candidate, candidates } = await findTextCandidate(
|
|
558
|
+
client,
|
|
559
|
+
frameNodeId,
|
|
560
|
+
RECRUIT_SEARCH_SELECTORS.degreeOption,
|
|
561
|
+
label,
|
|
562
|
+
{ match: "prefix" }
|
|
563
|
+
);
|
|
564
|
+
discoveredOptions = candidates.map((item) => ({
|
|
565
|
+
label: item.text,
|
|
566
|
+
active: item.active,
|
|
567
|
+
node_id: item.node_id,
|
|
568
|
+
selector: item.selector
|
|
569
|
+
}));
|
|
570
|
+
if (!candidate) {
|
|
571
|
+
throw new Error(`Recruit degree option was not found: ${label}`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let box = null;
|
|
575
|
+
if (!candidate.active) {
|
|
576
|
+
box = await clickNodeCenter(client, candidate.node_id, { scrollIntoView: true });
|
|
577
|
+
await sleep(350);
|
|
578
|
+
}
|
|
579
|
+
selected.push({
|
|
580
|
+
requested_degree: label,
|
|
581
|
+
selected_label: candidate.text,
|
|
582
|
+
selected_node_id: candidate.node_id,
|
|
583
|
+
was_active: candidate.active,
|
|
584
|
+
clicked: !candidate.active,
|
|
585
|
+
box
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
applied: true,
|
|
591
|
+
requested_degrees: labels,
|
|
592
|
+
selected,
|
|
593
|
+
discovered_options: discoveredOptions
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function findClickableDescendant(client, nodeId, selectors) {
|
|
598
|
+
for (const selector of selectors) {
|
|
599
|
+
const childNodeId = await querySelector(client, nodeId, selector);
|
|
600
|
+
if (childNodeId) return { node_id: childNodeId, selector };
|
|
601
|
+
}
|
|
602
|
+
return { node_id: nodeId, selector: null };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export async function setRecruitSchools(client, frameNodeId, schools = []) {
|
|
606
|
+
const targets = schools.map(normalizeText).filter(Boolean);
|
|
607
|
+
const applied = [];
|
|
608
|
+
const missing = [];
|
|
609
|
+
if (!targets.length) {
|
|
610
|
+
return { applied: false, schools: [], selected: [], missing: [] };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
for (const school of targets) {
|
|
614
|
+
const { candidate, candidates } = await findTextCandidate(
|
|
615
|
+
client,
|
|
616
|
+
frameNodeId,
|
|
617
|
+
RECRUIT_SEARCH_SELECTORS.schoolItem,
|
|
618
|
+
school,
|
|
619
|
+
{ match: "contains" }
|
|
620
|
+
);
|
|
621
|
+
if (!candidate) {
|
|
622
|
+
missing.push({
|
|
623
|
+
school,
|
|
624
|
+
discovered: candidates.map((item) => item.text).slice(0, 20)
|
|
625
|
+
});
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const clickable = await findClickableDescendant(client, candidate.node_id, RECRUIT_SEARCH_SELECTORS.schoolClickable);
|
|
630
|
+
let clickableActive = candidate.active;
|
|
631
|
+
if (clickable.node_id !== candidate.node_id) {
|
|
632
|
+
const clickableCandidate = await readTextCandidate(client, clickable.node_id, {
|
|
633
|
+
selector: clickable.selector || "",
|
|
634
|
+
index: 0
|
|
635
|
+
});
|
|
636
|
+
clickableActive = clickableActive || clickableCandidate.active;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
let box = null;
|
|
640
|
+
if (!clickableActive) {
|
|
641
|
+
box = await clickNodeCenter(client, clickable.node_id, { scrollIntoView: true });
|
|
642
|
+
await sleep(350);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
applied.push({
|
|
646
|
+
school,
|
|
647
|
+
selected_label: candidate.text,
|
|
648
|
+
selected_node_id: candidate.node_id,
|
|
649
|
+
clickable_node_id: clickable.node_id,
|
|
650
|
+
clickable_selector: clickable.selector,
|
|
651
|
+
was_active: clickableActive,
|
|
652
|
+
clicked: !clickableActive,
|
|
653
|
+
box
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (missing.length) {
|
|
658
|
+
throw new Error(`Recruit school options were not found: ${missing.map((item) => item.school).join(", ")}`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
applied: true,
|
|
663
|
+
schools: targets,
|
|
664
|
+
selected: applied,
|
|
665
|
+
missing
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export async function setRecruitRecentViewedFilter(client, frameNodeId, enabled) {
|
|
670
|
+
if (typeof enabled !== "boolean") {
|
|
671
|
+
return { applied: false, reason: "not_requested" };
|
|
672
|
+
}
|
|
673
|
+
const { candidate, candidates } = await findTextCandidate(
|
|
674
|
+
client,
|
|
675
|
+
frameNodeId,
|
|
676
|
+
RECRUIT_SEARCH_SELECTORS.recentViewedLabel,
|
|
677
|
+
"过滤近14天查看",
|
|
678
|
+
{ match: "contains" }
|
|
679
|
+
);
|
|
680
|
+
if (!candidate) {
|
|
681
|
+
throw new Error("Recruit recent-viewed filter was not found");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
let box = null;
|
|
685
|
+
if (candidate.active !== enabled) {
|
|
686
|
+
box = await clickNodeCenter(client, candidate.node_id, { scrollIntoView: true });
|
|
687
|
+
await sleep(900);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
applied: true,
|
|
692
|
+
requested: enabled,
|
|
693
|
+
was_active: candidate.active,
|
|
694
|
+
changed: candidate.active !== enabled,
|
|
695
|
+
selected_label: candidate.text,
|
|
696
|
+
selected_node_id: candidate.node_id,
|
|
697
|
+
box,
|
|
698
|
+
discovered_options: candidates.map((item) => ({
|
|
699
|
+
label: item.text,
|
|
700
|
+
active: item.active,
|
|
701
|
+
node_id: item.node_id,
|
|
702
|
+
selector: item.selector
|
|
703
|
+
}))
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function selectRecruitNationalCityThroughPicker(client, frameNodeId, {
|
|
708
|
+
requestedCity = "全国",
|
|
709
|
+
reason = "national_city_requested",
|
|
710
|
+
optionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
|
|
711
|
+
} = {}) {
|
|
712
|
+
const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.cityInput);
|
|
713
|
+
await clearFocusedInput(client);
|
|
714
|
+
await sleep(500);
|
|
715
|
+
|
|
716
|
+
const path = [];
|
|
717
|
+
const categoryLookup = await waitForRecruitTextCandidate(
|
|
718
|
+
client,
|
|
719
|
+
frameNodeId,
|
|
720
|
+
RECRUIT_SEARCH_SELECTORS.citySearchResult,
|
|
721
|
+
"城市",
|
|
722
|
+
{ match: "exact", timeoutMs: Math.min(optionTimeoutMs, 6000) }
|
|
723
|
+
);
|
|
724
|
+
if (categoryLookup.candidate) {
|
|
725
|
+
const box = await clickNodeCenter(client, categoryLookup.candidate.node_id, { scrollIntoView: true });
|
|
726
|
+
await sleep(400);
|
|
727
|
+
path.push({
|
|
728
|
+
label: "城市",
|
|
729
|
+
selected_label: categoryLookup.candidate.text,
|
|
730
|
+
node_id: categoryLookup.candidate.node_id,
|
|
731
|
+
box
|
|
732
|
+
});
|
|
733
|
+
} else {
|
|
734
|
+
path.push({
|
|
735
|
+
label: "城市",
|
|
736
|
+
skipped: true,
|
|
737
|
+
reason: "not_found_or_already_expanded",
|
|
738
|
+
discovered_options: summarizeTextCandidates(categoryLookup.candidates)
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
let popularLookup = await waitForRecruitTextCandidate(
|
|
743
|
+
client,
|
|
744
|
+
frameNodeId,
|
|
745
|
+
RECRUIT_SEARCH_SELECTORS.cityProvinceItem,
|
|
746
|
+
"热门",
|
|
747
|
+
{ match: "exact", timeoutMs: optionTimeoutMs }
|
|
748
|
+
);
|
|
749
|
+
if (!popularLookup.candidate) {
|
|
750
|
+
popularLookup = await waitForRecruitTextCandidate(
|
|
751
|
+
client,
|
|
752
|
+
frameNodeId,
|
|
753
|
+
RECRUIT_SEARCH_SELECTORS.citySearchResult,
|
|
754
|
+
"热门",
|
|
755
|
+
{ match: "exact", timeoutMs: Math.min(optionTimeoutMs, 6000) }
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
if (!popularLookup.candidate) {
|
|
759
|
+
return {
|
|
760
|
+
applied: false,
|
|
761
|
+
reason: "national_city_popular_not_found",
|
|
762
|
+
requested_city: requestedCity,
|
|
763
|
+
input,
|
|
764
|
+
path,
|
|
765
|
+
discovered_options: summarizeTextCandidates(popularLookup.candidates)
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
const popularBox = await clickNodeCenter(client, popularLookup.candidate.node_id, { scrollIntoView: true });
|
|
769
|
+
await sleep(400);
|
|
770
|
+
path.push({
|
|
771
|
+
label: "热门",
|
|
772
|
+
selected_label: popularLookup.candidate.text,
|
|
773
|
+
node_id: popularLookup.candidate.node_id,
|
|
774
|
+
box: popularBox
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
let nationalLookup = await waitForRecruitTextCandidate(
|
|
778
|
+
client,
|
|
779
|
+
frameNodeId,
|
|
780
|
+
RECRUIT_SEARCH_SELECTORS.cityDropdownItem,
|
|
781
|
+
"全国",
|
|
782
|
+
{ match: "exact", timeoutMs: optionTimeoutMs }
|
|
783
|
+
);
|
|
784
|
+
if (!nationalLookup.candidate) {
|
|
785
|
+
nationalLookup = await waitForRecruitTextCandidate(
|
|
786
|
+
client,
|
|
787
|
+
frameNodeId,
|
|
788
|
+
RECRUIT_SEARCH_SELECTORS.citySearchResult,
|
|
789
|
+
"全国",
|
|
790
|
+
{ match: "exact", timeoutMs: Math.min(optionTimeoutMs, 6000) }
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
if (!nationalLookup.candidate) {
|
|
794
|
+
return {
|
|
795
|
+
applied: false,
|
|
796
|
+
reason: "national_city_option_not_found",
|
|
797
|
+
requested_city: requestedCity,
|
|
798
|
+
input,
|
|
799
|
+
path,
|
|
800
|
+
discovered_options: summarizeTextCandidates(nationalLookup.candidates)
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const nationalBox = await clickNodeCenter(client, nationalLookup.candidate.node_id, { scrollIntoView: true });
|
|
805
|
+
await sleep(700);
|
|
806
|
+
path.push({
|
|
807
|
+
label: "全国",
|
|
808
|
+
selected_label: nationalLookup.candidate.text,
|
|
809
|
+
node_id: nationalLookup.candidate.node_id,
|
|
810
|
+
box: nationalBox
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
return {
|
|
814
|
+
applied: true,
|
|
815
|
+
reason,
|
|
816
|
+
city: "全国",
|
|
817
|
+
requested_city: requestedCity,
|
|
818
|
+
selected_label: nationalLookup.candidate.text,
|
|
819
|
+
selected_node_id: nationalLookup.candidate.node_id,
|
|
820
|
+
input,
|
|
821
|
+
path,
|
|
822
|
+
box: nationalBox,
|
|
823
|
+
selection_mode: "city_picker",
|
|
824
|
+
picker_path: ["城市", "热门", "全国"]
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function resetRecruitCityToNational(client, {
|
|
829
|
+
requestedCity = "",
|
|
830
|
+
reason = "national_city_frame_reset",
|
|
831
|
+
optionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
|
|
832
|
+
} = {}) {
|
|
833
|
+
const roots = await getRecruitRoots(client, { requireFrame: false });
|
|
834
|
+
const reset = await navigateRecruitSearchFrame(client, roots?.iframe?.nodeId, { reason });
|
|
835
|
+
if (!reset) {
|
|
836
|
+
return {
|
|
837
|
+
applied: false,
|
|
838
|
+
reason: "national_city_frame_reset_unavailable",
|
|
839
|
+
requested_city: requestedCity
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
await sleep(1500);
|
|
843
|
+
const controls = await waitForRecruitSearchControls(client, {
|
|
844
|
+
timeoutMs: Math.max(optionTimeoutMs, DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS),
|
|
845
|
+
intervalMs: 300
|
|
846
|
+
});
|
|
847
|
+
return {
|
|
848
|
+
applied: controls.ok,
|
|
849
|
+
reason,
|
|
850
|
+
city: "全国",
|
|
851
|
+
requested_city: requestedCity,
|
|
852
|
+
selected_label: "全国",
|
|
853
|
+
selection_mode: "frame_reset",
|
|
854
|
+
reset,
|
|
855
|
+
controls,
|
|
856
|
+
reacquire_frame: true
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export async function setRecruitCity(client, frameNodeId, city, {
|
|
861
|
+
optionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
|
|
862
|
+
} = {}) {
|
|
863
|
+
const normalizedCity = normalizeText(city);
|
|
864
|
+
if (!normalizedCity) {
|
|
865
|
+
return { applied: false, reason: "empty_city" };
|
|
866
|
+
}
|
|
867
|
+
if (isRecruitNationalCity(normalizedCity)) {
|
|
868
|
+
return selectRecruitNationalCityThroughPicker(client, frameNodeId, {
|
|
869
|
+
requestedCity: normalizedCity,
|
|
870
|
+
reason: "national_city_requested",
|
|
871
|
+
optionTimeoutMs
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const input = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.cityInput);
|
|
876
|
+
await clearFocusedInput(client);
|
|
877
|
+
await sleep(120);
|
|
878
|
+
await insertText(client, normalizedCity);
|
|
879
|
+
await sleep(500);
|
|
880
|
+
|
|
881
|
+
const started = Date.now();
|
|
882
|
+
const noResultFallbackMs = Math.min(DEFAULT_RECRUIT_CITY_NO_RESULT_FALLBACK_MS, optionTimeoutMs);
|
|
883
|
+
let candidate = null;
|
|
884
|
+
let candidates = [];
|
|
885
|
+
let noResultFirstSeenAt = 0;
|
|
886
|
+
while (Date.now() - started <= optionTimeoutMs) {
|
|
887
|
+
const found = await findTextCandidate(
|
|
888
|
+
client,
|
|
889
|
+
frameNodeId,
|
|
890
|
+
RECRUIT_SEARCH_SELECTORS.citySearchResult,
|
|
891
|
+
normalizedCity,
|
|
892
|
+
{ match: "contains" }
|
|
893
|
+
);
|
|
894
|
+
candidate = found.candidate;
|
|
895
|
+
candidates = found.candidates;
|
|
896
|
+
if (candidate) break;
|
|
897
|
+
const hasNoResult = candidates.some((item) => CITY_NO_RESULT_LABELS.has(item.label));
|
|
898
|
+
if (hasNoResult) {
|
|
899
|
+
if (!noResultFirstSeenAt) noResultFirstSeenAt = Date.now();
|
|
900
|
+
if (Date.now() - noResultFirstSeenAt >= noResultFallbackMs) break;
|
|
901
|
+
} else {
|
|
902
|
+
noResultFirstSeenAt = 0;
|
|
903
|
+
}
|
|
904
|
+
await sleep(300);
|
|
905
|
+
}
|
|
906
|
+
if (!candidate) {
|
|
907
|
+
const nationalFallback = await selectRecruitNationalCityThroughPicker(client, frameNodeId, {
|
|
908
|
+
requestedCity: normalizedCity,
|
|
909
|
+
reason: "city_result_not_found",
|
|
910
|
+
optionTimeoutMs
|
|
911
|
+
});
|
|
912
|
+
if (nationalFallback.applied) {
|
|
913
|
+
return {
|
|
914
|
+
...nationalFallback,
|
|
915
|
+
reason: "city_result_not_found",
|
|
916
|
+
requested_city: normalizedCity,
|
|
917
|
+
requested_city_not_found: true,
|
|
918
|
+
fallback_to_national: true,
|
|
919
|
+
original_input: input,
|
|
920
|
+
elapsed_ms: Date.now() - started,
|
|
921
|
+
discovered_options_before_fallback: candidates.map((item) => item.text).slice(0, 20)
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const resetFallback = await resetRecruitCityToNational(client, {
|
|
926
|
+
requestedCity: normalizedCity,
|
|
927
|
+
reason: "city_result_not_found_frame_reset",
|
|
928
|
+
optionTimeoutMs
|
|
929
|
+
});
|
|
930
|
+
if (resetFallback.applied) {
|
|
931
|
+
return {
|
|
932
|
+
...resetFallback,
|
|
933
|
+
reason: "city_result_not_found",
|
|
934
|
+
requested_city: normalizedCity,
|
|
935
|
+
requested_city_not_found: true,
|
|
936
|
+
fallback_to_national: true,
|
|
937
|
+
original_input: input,
|
|
938
|
+
picker_fallback: nationalFallback,
|
|
939
|
+
elapsed_ms: Date.now() - started,
|
|
940
|
+
discovered_options_before_fallback: candidates.map((item) => item.text).slice(0, 20)
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return {
|
|
945
|
+
applied: false,
|
|
946
|
+
reason: "city_result_not_found",
|
|
947
|
+
city: normalizedCity,
|
|
948
|
+
input,
|
|
949
|
+
elapsed_ms: Date.now() - started,
|
|
950
|
+
discovered_options: candidates.map((item) => item.text).slice(0, 20),
|
|
951
|
+
national_fallback: nationalFallback,
|
|
952
|
+
reset_fallback: resetFallback
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const box = await clickNodeCenter(client, candidate.node_id, { scrollIntoView: true });
|
|
957
|
+
await sleep(600);
|
|
958
|
+
return {
|
|
959
|
+
applied: true,
|
|
960
|
+
city: normalizedCity,
|
|
961
|
+
selected_label: candidate.text,
|
|
962
|
+
selected_node_id: candidate.node_id,
|
|
963
|
+
input,
|
|
964
|
+
elapsed_ms: Date.now() - started,
|
|
965
|
+
box
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
export async function clickRecruitSearch(client, frameNodeId) {
|
|
970
|
+
const buttonResult = await clickFirstNodeBySelectors(client, frameNodeId, RECRUIT_SEARCH_SELECTORS.searchButton, {
|
|
971
|
+
optional: true,
|
|
972
|
+
scrollIntoView: false
|
|
973
|
+
});
|
|
974
|
+
if (buttonResult.clicked) {
|
|
975
|
+
await sleep(1500);
|
|
976
|
+
return {
|
|
977
|
+
searched: true,
|
|
978
|
+
mode: "button",
|
|
979
|
+
button: buttonResult
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
await pressKey(client, "Enter", {
|
|
984
|
+
code: "Enter",
|
|
985
|
+
windowsVirtualKeyCode: 13,
|
|
986
|
+
nativeVirtualKeyCode: 13
|
|
987
|
+
});
|
|
988
|
+
await sleep(1500);
|
|
989
|
+
return {
|
|
990
|
+
searched: true,
|
|
991
|
+
mode: "enter"
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
export async function waitForRecruitSearchResultState(client, {
|
|
996
|
+
timeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
|
|
997
|
+
intervalMs = 500
|
|
998
|
+
} = {}) {
|
|
999
|
+
const started = Date.now();
|
|
1000
|
+
let lastState = null;
|
|
1001
|
+
while (Date.now() - started <= timeoutMs) {
|
|
1002
|
+
try {
|
|
1003
|
+
const roots = await getRecruitRoots(client, { requireFrame: false });
|
|
1004
|
+
const frameNodeId = roots.iframe?.documentNodeId;
|
|
1005
|
+
if (frameNodeId) {
|
|
1006
|
+
const counts = await countSelectors(client, frameNodeId, {
|
|
1007
|
+
candidate_card: RECRUIT_CARD_SELECTOR,
|
|
1008
|
+
no_data: RECRUIT_NO_DATA_SELECTORS.join(", ")
|
|
1009
|
+
});
|
|
1010
|
+
lastState = {
|
|
1011
|
+
ok: counts.candidate_card > 0 || counts.no_data > 0,
|
|
1012
|
+
elapsed_ms: Date.now() - started,
|
|
1013
|
+
iframe_selector: roots.iframe.selector,
|
|
1014
|
+
iframe_document_node_id: frameNodeId,
|
|
1015
|
+
counts
|
|
1016
|
+
};
|
|
1017
|
+
if (lastState.ok) return lastState;
|
|
1018
|
+
}
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
lastState = {
|
|
1021
|
+
ok: false,
|
|
1022
|
+
elapsed_ms: Date.now() - started,
|
|
1023
|
+
error: error?.message || String(error)
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
await sleep(intervalMs);
|
|
1027
|
+
}
|
|
1028
|
+
return {
|
|
1029
|
+
ok: false,
|
|
1030
|
+
elapsed_ms: Date.now() - started,
|
|
1031
|
+
...(lastState || {})
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
export async function applyRecruitSearchParams(client, {
|
|
1036
|
+
searchParams = {},
|
|
1037
|
+
requireCards = true,
|
|
1038
|
+
resetBeforeApply = false,
|
|
1039
|
+
searchTimeoutMs = DEFAULT_RECRUIT_SEARCH_TIMEOUT_MS,
|
|
1040
|
+
resetTimeoutMs = DEFAULT_RECRUIT_RESET_TIMEOUT_MS,
|
|
1041
|
+
resetSettleMs = 5000,
|
|
1042
|
+
cityOptionTimeoutMs = DEFAULT_RECRUIT_CITY_OPTION_TIMEOUT_MS
|
|
1043
|
+
} = {}) {
|
|
1044
|
+
const normalizedSearchParams = normalizeRecruitSearchParams(searchParams);
|
|
1045
|
+
const reset = resetBeforeApply
|
|
1046
|
+
? await resetRecruitSearchPage(client, {
|
|
1047
|
+
timeoutMs: resetTimeoutMs,
|
|
1048
|
+
settleMs: resetSettleMs
|
|
1049
|
+
})
|
|
1050
|
+
: null;
|
|
1051
|
+
const controls = reset?.controls?.ok
|
|
1052
|
+
? reset.controls
|
|
1053
|
+
: await waitForRecruitSearchControls(client, {
|
|
1054
|
+
timeoutMs: searchTimeoutMs,
|
|
1055
|
+
intervalMs: 500
|
|
1056
|
+
});
|
|
1057
|
+
if (!controls.ok) {
|
|
1058
|
+
throw new Error(`Recruit search controls were not ready after navigation; counts=${JSON.stringify(controls.counts || {})}`);
|
|
1059
|
+
}
|
|
1060
|
+
const initialRoots = await getRecruitRoots(client);
|
|
1061
|
+
let frameNodeId = initialRoots.iframe.documentNodeId;
|
|
1062
|
+
const initialFrameNodeId = frameNodeId;
|
|
1063
|
+
const beforeCounts = await getRecruitSearchCounts(client, frameNodeId);
|
|
1064
|
+
const steps = [];
|
|
1065
|
+
|
|
1066
|
+
if (normalizedSearchParams.job) {
|
|
1067
|
+
steps.push({
|
|
1068
|
+
step: "job_title",
|
|
1069
|
+
result: await setRecruitJobTitle(client, frameNodeId, normalizedSearchParams.job, {
|
|
1070
|
+
optionTimeoutMs: searchTimeoutMs
|
|
1071
|
+
})
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (normalizedSearchParams.city) {
|
|
1076
|
+
const cityResult = await setRecruitCity(client, frameNodeId, normalizedSearchParams.city, {
|
|
1077
|
+
optionTimeoutMs: cityOptionTimeoutMs
|
|
1078
|
+
});
|
|
1079
|
+
steps.push({
|
|
1080
|
+
step: "city",
|
|
1081
|
+
result: cityResult
|
|
1082
|
+
});
|
|
1083
|
+
if (cityResult?.reacquire_frame) {
|
|
1084
|
+
const rootsAfterCity = await getRecruitRoots(client);
|
|
1085
|
+
frameNodeId = rootsAfterCity.iframe.documentNodeId;
|
|
1086
|
+
steps.push({
|
|
1087
|
+
step: "reacquire_after_city",
|
|
1088
|
+
result: {
|
|
1089
|
+
selector: rootsAfterCity.iframe.selector,
|
|
1090
|
+
document_node_id: frameNodeId,
|
|
1091
|
+
reason: cityResult.reason
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
steps.push({
|
|
1098
|
+
step: "degree",
|
|
1099
|
+
result: await setRecruitDegrees(client, frameNodeId, normalizedSearchParams.degrees)
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
steps.push({
|
|
1103
|
+
step: "schools",
|
|
1104
|
+
result: await setRecruitSchools(client, frameNodeId, normalizedSearchParams.schools)
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
steps.push({
|
|
1108
|
+
step: "keyword",
|
|
1109
|
+
result: await setRecruitKeyword(client, frameNodeId, normalizedSearchParams.keyword)
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
steps.push({
|
|
1113
|
+
step: "search",
|
|
1114
|
+
result: await clickRecruitSearch(client, frameNodeId)
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
if (typeof normalizedSearchParams.filter_recent_viewed === "boolean") {
|
|
1118
|
+
const postSearchRoots = await getRecruitRoots(client);
|
|
1119
|
+
steps.push({
|
|
1120
|
+
step: "recent_viewed",
|
|
1121
|
+
result: await setRecruitRecentViewedFilter(
|
|
1122
|
+
client,
|
|
1123
|
+
postSearchRoots.iframe.documentNodeId,
|
|
1124
|
+
normalizedSearchParams.filter_recent_viewed
|
|
1125
|
+
)
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const postSearchState = await waitForRecruitSearchResultState(client, {
|
|
1130
|
+
timeoutMs: searchTimeoutMs
|
|
1131
|
+
});
|
|
1132
|
+
if (requireCards && (postSearchState.counts?.candidate_card || 0) === 0) {
|
|
1133
|
+
throw new Error(`Recruit search did not produce candidate cards; no_data=${postSearchState.counts?.no_data || 0}`);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return {
|
|
1137
|
+
applied: true,
|
|
1138
|
+
search_params: normalizedSearchParams,
|
|
1139
|
+
reset,
|
|
1140
|
+
controls,
|
|
1141
|
+
initial_iframe: {
|
|
1142
|
+
selector: initialRoots.iframe.selector,
|
|
1143
|
+
document_node_id: initialFrameNodeId
|
|
1144
|
+
},
|
|
1145
|
+
before_counts: beforeCounts,
|
|
1146
|
+
steps,
|
|
1147
|
+
post_search_state: postSearchState
|
|
1148
|
+
};
|
|
1149
|
+
}
|