@viyzhu/boss-cli-fork 0.7.3 → 0.8.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/AGENTS.md +111 -85
- package/LICENSE +674 -674
- package/README.md +346 -284
- package/dist/browser/cdp_browser.d.ts +170 -170
- package/dist/browser/cdp_browser.js +674 -674
- package/dist/cli/cliRouter.d.ts.map +1 -1
- package/dist/cli/cliRouter.js +119 -67
- package/dist/cli/cliRouter.js.map +1 -1
- package/dist/common/auth.js +49 -49
- package/dist/common/boss_modal.js +42 -42
- package/dist/common/boss_page_guards.js +247 -247
- package/dist/common/boss_paywall_popup.js +87 -87
- package/dist/common/boss_session_page.js +14 -14
- package/dist/common/boss_sidebar_nav.js +24 -24
- package/dist/common/c_resume_capture.js +61 -61
- package/dist/toolset/action.js +232 -232
- package/dist/toolset/chat.js +437 -437
- package/dist/toolset/deep-search.js +570 -570
- package/dist/toolset/index.d.ts +4 -1
- package/dist/toolset/index.d.ts.map +1 -1
- package/dist/toolset/index.js +3 -2
- package/dist/toolset/index.js.map +1 -1
- package/dist/toolset/jd.js +132 -132
- package/dist/toolset/list.js +48 -48
- package/dist/toolset/normal-search.d.ts +167 -1
- package/dist/toolset/normal-search.d.ts.map +1 -1
- package/dist/toolset/normal-search.js +1276 -165
- package/dist/toolset/normal-search.js.map +1 -1
- package/dist/toolset/recommend.js +207 -207
- package/package.json +69 -69
- package/skills/boss-frontend-analysis/SKILL.md +58 -58
- package/skills/boss-frontend-analysis/agents/openai.yaml +4 -4
- package/skills/boss-frontend-analysis/scripts/capture_boss_frontend.mjs +604 -604
|
@@ -8,6 +8,146 @@ const GREET_CARD_DIALOG_WAIT_MS = 12_000;
|
|
|
8
8
|
const SEARCH_FRAME_READY_TIMEOUT_MS = 18_000;
|
|
9
9
|
const SEARCH_RESULT_SETTLE_MS = { min: 900, max: 1600 };
|
|
10
10
|
const JOB_SWITCH_SETTLE_MS = { min: 700, max: 1300 };
|
|
11
|
+
/** 城市控件是输入即联想,等联想列表渲染;也顺带把连续点击拉开距离。 */
|
|
12
|
+
const CITY_SETTLE_MS = { min: 800, max: 1500 };
|
|
13
|
+
/** 等城市联想列表出现的上限。异步拉取,固定 sleep 会偶发误报「没有联想结果」。 */
|
|
14
|
+
const CITY_SUGGEST_TIMEOUT_MS = 8_000;
|
|
15
|
+
/** 等筛选面板(学历/院校)重渲染完成的上限。选完城市后整块会重挂。 */
|
|
16
|
+
const FILTER_PANEL_TIMEOUT_MS = 10_000;
|
|
17
|
+
/** 学历 / 院校勾选之间的间隔,别连点。 */
|
|
18
|
+
const FILTER_SETTLE_MS = { min: 600, max: 1200 };
|
|
19
|
+
/** 平台对「专业」的限制,弹层标题写死「最多选择10个」。 */
|
|
20
|
+
const MAJOR_MAX_SELECT = 10;
|
|
21
|
+
/** 滑块拖几次还没停在目标档位就认输报错,别无限纠偏。 */
|
|
22
|
+
const SLIDER_MAX_ATTEMPTS = 6;
|
|
23
|
+
/** 年龄下拉里「46岁+」这一项在 hidden 里存的哨兵值(实测)。 */
|
|
24
|
+
const AGE_PLUS_HIDDEN_VALUE = 10000;
|
|
25
|
+
/**
|
|
26
|
+
* 页面上有两个 `.major-dialog`(专业、资格证书),没展开的那个 `display:none` 但仍在 DOM 里。
|
|
27
|
+
* 所有弹层内的查找都得先挑出可见的那个,否则会操作到「资格证书」的控件上。
|
|
28
|
+
*/
|
|
29
|
+
const VISIBLE_MAJOR_DIALOG = 'Array.from(document.querySelectorAll(".major-dialog")).find((el) => getComputedStyle(el).display !== "none")';
|
|
30
|
+
/**
|
|
31
|
+
* 专业**联想结果**(打字之后出现的那一列),必须限定在 `.major-lists` 里。
|
|
32
|
+
*
|
|
33
|
+
* 弹层里还有一棵分类树(工学 / 经管类 / 教育学…),它的条目**用的是同一个
|
|
34
|
+
* `li[ka="search_select_major"]`**。不限定的话,输入框一清空就会立刻匹配到分类树,
|
|
35
|
+
* 「等联想结果出现」直接空转,读到的候选是上一轮的或者整棵树。
|
|
36
|
+
*/
|
|
37
|
+
const MAJOR_SUGGEST_ITEMS = `Array.from(${VISIBLE_MAJOR_DIALOG}?.querySelectorAll('.major-lists li[ka="search_select_major"]') ?? [])`;
|
|
38
|
+
/**
|
|
39
|
+
* 默认搜索城市。**不硬编码深圳**——这个包是公开发布的,别人不一定在深圳;
|
|
40
|
+
* 不设这个变量时完全不碰城市控件,行为与加本功能之前一致。
|
|
41
|
+
*/
|
|
42
|
+
export function defaultSearchCityFromEnv() {
|
|
43
|
+
return process.env.BOSS_SEARCH_CITY?.trim() ?? '';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 解析 `--school` / `--status` / `--major` 这类可多选参数的逗号分隔值。
|
|
47
|
+
* 中英文逗号都收——中文输入法下顺手打出全角逗号太常见,
|
|
48
|
+
* 因此而报「院校要求没有『统招本科,985院校』这一项」纯属自找麻烦。
|
|
49
|
+
*/
|
|
50
|
+
export function parseFilterLabels(raw) {
|
|
51
|
+
if (!raw) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
return raw
|
|
55
|
+
.split(/[,,]/)
|
|
56
|
+
.map((s) => s.trim())
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 「跳槽频率」的选项里有个 `时间≥1年`——`≥` 在键盘上打不出来。
|
|
61
|
+
* 允许用户写 `>=`,映射到页面上的真实文案;其余一律按原样完全匹配。
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeFilterLabel(raw) {
|
|
64
|
+
return raw.trim().replace(/>=/g, '≥');
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 拆 `--exp-range` / `--age-range` 的 `下限-上限`。
|
|
68
|
+
* 半角/全角连字符和波浪号都收,理由同逗号:中文输入法下这几个键太容易打串。
|
|
69
|
+
*/
|
|
70
|
+
export function parseRangeArg(raw) {
|
|
71
|
+
const parts = raw
|
|
72
|
+
.trim()
|
|
73
|
+
.split(/[--~~]/)
|
|
74
|
+
.map((s) => s.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
if (parts.length !== 2) {
|
|
77
|
+
throw new Error(`区间要写成「下限-上限」两段,收到「${raw}」。`);
|
|
78
|
+
}
|
|
79
|
+
return { min: parts[0], max: parts[1] };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 经验滑块共 12 档,档位文案是实测出来的(拖到各个百分比读 tooltip):
|
|
83
|
+
* 1=在校/应届,2..11=「(档位-1)年」,12=10年以上。
|
|
84
|
+
*/
|
|
85
|
+
export const EXP_SLIDER_STOPS = 12;
|
|
86
|
+
export function expSliderLabel(index) {
|
|
87
|
+
if (index === 1) {
|
|
88
|
+
return '在校/应届';
|
|
89
|
+
}
|
|
90
|
+
if (index === EXP_SLIDER_STOPS) {
|
|
91
|
+
return '10年以上';
|
|
92
|
+
}
|
|
93
|
+
return `${index - 1}年`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 学历滑块共 7 档,档位文案同样是实测 tooltip 读出来的,**不是照学历常识排的**
|
|
97
|
+
* (第 2 档是「中专/中技」不是「中专」,写错就永远匹配不上)。
|
|
98
|
+
*/
|
|
99
|
+
export const DEGREE_SLIDER_LABELS = [
|
|
100
|
+
'初中及以下',
|
|
101
|
+
'中专/中技',
|
|
102
|
+
'高中',
|
|
103
|
+
'大专',
|
|
104
|
+
'本科',
|
|
105
|
+
'硕士',
|
|
106
|
+
'博士',
|
|
107
|
+
];
|
|
108
|
+
export const DEGREE_SLIDER_STOPS = DEGREE_SLIDER_LABELS.length;
|
|
109
|
+
export function degreeSliderLabel(index) {
|
|
110
|
+
return DEGREE_SLIDER_LABELS[index - 1] ?? `第${index}档`;
|
|
111
|
+
}
|
|
112
|
+
/** 把 `--degree-range` 的一段转成滑块档位;只认页面上那七个文案。 */
|
|
113
|
+
export function degreeTokenToIndex(token) {
|
|
114
|
+
const t = token.trim();
|
|
115
|
+
const i = DEGREE_SLIDER_LABELS.indexOf(t);
|
|
116
|
+
if (i < 0) {
|
|
117
|
+
throw new Error(`学历区间只认:${DEGREE_SLIDER_LABELS.join('|')},收到「${token}」。`);
|
|
118
|
+
}
|
|
119
|
+
return i + 1;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 把 `--exp-range` 的一段转成滑块档位。收 `应届`、`0`~`10`、`10+`。
|
|
123
|
+
* 不认的值直接报错——猜错档位=搜错人群,而且用户不会发现。
|
|
124
|
+
*/
|
|
125
|
+
export function expTokenToIndex(token) {
|
|
126
|
+
const t = token.trim();
|
|
127
|
+
if (t === '应届' || t === '在校' || t === '在校/应届' || t === '0') {
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
if (t === '10+' || t === '10以上' || t === '10年以上') {
|
|
131
|
+
return EXP_SLIDER_STOPS;
|
|
132
|
+
}
|
|
133
|
+
const years = Number(t.replace(/年$/, ''));
|
|
134
|
+
if (!Number.isInteger(years) || years < 1 || years > 10) {
|
|
135
|
+
throw new Error(`经验区间只认「应届」、1-10 的整数年、或「10+」,收到「${token}」。`);
|
|
136
|
+
}
|
|
137
|
+
return years + 1;
|
|
138
|
+
}
|
|
139
|
+
/** 把 `--age-range` 的一段转成年龄下拉里的文案。收 `16`~`46` 与 `46+`。 */
|
|
140
|
+
export function ageTokenToLabel(token) {
|
|
141
|
+
const t = token.trim();
|
|
142
|
+
if (t === '46+' || t === '46以上' || t === '46岁+') {
|
|
143
|
+
return '46岁+';
|
|
144
|
+
}
|
|
145
|
+
const age = Number(t.replace(/岁$/, ''));
|
|
146
|
+
if (!Number.isInteger(age) || age < 16 || age > 46) {
|
|
147
|
+
throw new Error(`年龄区间只认 16-46 的整数、或「46+」,收到「${token}」。`);
|
|
148
|
+
}
|
|
149
|
+
return `${age}岁`;
|
|
150
|
+
}
|
|
11
151
|
export function isBossChatSearchUrl(url) {
|
|
12
152
|
try {
|
|
13
153
|
const u = new URL(url);
|
|
@@ -41,13 +181,13 @@ async function getSearchFrame(page) {
|
|
|
41
181
|
throw new Error(`已检测到常规搜索 iframe,但无法获取其页面上下文。iframe src:${iframeSrc || 'unknown'};frames:${frameUrls || 'empty'}`);
|
|
42
182
|
}
|
|
43
183
|
async function ensureSearchFrameReady(frame) {
|
|
44
|
-
await frame.waitForFunction(`(() => {
|
|
45
|
-
const input = document.querySelector(".search-input");
|
|
46
|
-
if (!(input instanceof HTMLInputElement)) return false;
|
|
47
|
-
const list = document.querySelector(".geek-list-wrap, .card-list");
|
|
48
|
-
const hasCard = document.querySelectorAll(".geek-info-card").length > 0;
|
|
49
|
-
const empty = document.querySelector(".empty-tips");
|
|
50
|
-
return !!list || hasCard || !!empty;
|
|
184
|
+
await frame.waitForFunction(`(() => {
|
|
185
|
+
const input = document.querySelector(".search-input");
|
|
186
|
+
if (!(input instanceof HTMLInputElement)) return false;
|
|
187
|
+
const list = document.querySelector(".geek-list-wrap, .card-list");
|
|
188
|
+
const hasCard = document.querySelectorAll(".geek-info-card").length > 0;
|
|
189
|
+
const empty = document.querySelector(".empty-tips");
|
|
190
|
+
return !!list || hasCard || !!empty;
|
|
51
191
|
})()`, { timeout: SEARCH_FRAME_READY_TIMEOUT_MS });
|
|
52
192
|
}
|
|
53
193
|
async function ensureInNormalSearchPage(page) {
|
|
@@ -70,39 +210,46 @@ export async function assertNormalSearchPageReadyForPreview(page) {
|
|
|
70
210
|
}
|
|
71
211
|
async function runKeywordSearch(frame, keyword) {
|
|
72
212
|
const kwLiteral = JSON.stringify(keyword);
|
|
73
|
-
const ok = (await frame.evaluate(`(() => {
|
|
74
|
-
const input = document.querySelector(".search-input");
|
|
75
|
-
if (!(input instanceof HTMLInputElement)) return false;
|
|
76
|
-
const kw = ${kwLiteral};
|
|
77
|
-
input.focus();
|
|
78
|
-
input.value = kw;
|
|
79
|
-
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
80
|
-
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
81
|
-
input.dispatchEvent(new KeyboardEvent("keydown", {
|
|
82
|
-
key: "Enter",
|
|
83
|
-
code: "Enter",
|
|
84
|
-
keyCode: 13,
|
|
85
|
-
which: 13,
|
|
86
|
-
bubbles: true,
|
|
87
|
-
cancelable: true,
|
|
88
|
-
}));
|
|
89
|
-
input.dispatchEvent(new KeyboardEvent("keyup", {
|
|
90
|
-
key: "Enter",
|
|
91
|
-
code: "Enter",
|
|
92
|
-
keyCode: 13,
|
|
93
|
-
which: 13,
|
|
94
|
-
bubbles: true,
|
|
95
|
-
cancelable: true,
|
|
96
|
-
}));
|
|
97
|
-
return true;
|
|
213
|
+
const ok = (await frame.evaluate(`(() => {
|
|
214
|
+
const input = document.querySelector(".search-input");
|
|
215
|
+
if (!(input instanceof HTMLInputElement)) return false;
|
|
216
|
+
const kw = ${kwLiteral};
|
|
217
|
+
input.focus();
|
|
218
|
+
input.value = kw;
|
|
219
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
220
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
221
|
+
input.dispatchEvent(new KeyboardEvent("keydown", {
|
|
222
|
+
key: "Enter",
|
|
223
|
+
code: "Enter",
|
|
224
|
+
keyCode: 13,
|
|
225
|
+
which: 13,
|
|
226
|
+
bubbles: true,
|
|
227
|
+
cancelable: true,
|
|
228
|
+
}));
|
|
229
|
+
input.dispatchEvent(new KeyboardEvent("keyup", {
|
|
230
|
+
key: "Enter",
|
|
231
|
+
code: "Enter",
|
|
232
|
+
keyCode: 13,
|
|
233
|
+
which: 13,
|
|
234
|
+
bubbles: true,
|
|
235
|
+
cancelable: true,
|
|
236
|
+
}));
|
|
237
|
+
return true;
|
|
98
238
|
})()`));
|
|
99
239
|
if (!ok) {
|
|
100
240
|
throw new Error('未找到常规搜索关键词输入框(.search-input)。');
|
|
101
241
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
242
|
+
/**
|
|
243
|
+
* ⚠️ 判据必须**内联**进脚本字符串,不能走 `waitForFunction(fn, opts, arg)` 的入参。
|
|
244
|
+
* puppeteer 对**字符串**形式的 pageFunction 是直接当表达式求值的,后面的入参会被静默丢弃:
|
|
245
|
+
* `"((kw) => ...)"` 求值结果是个函数对象 → 恒为真 → 这个等待直接变成空转。
|
|
246
|
+
* (本机实测:`waitForFunction("((x) => x === 'NEVER')", {timeout:3000}, 'NO')` 6ms 就通过了。)
|
|
247
|
+
* 而 AGENTS.md 又要求 evaluate 一律用字符串脚本,所以只有 JSON.stringify 内联这一条路。
|
|
248
|
+
*/
|
|
249
|
+
await frame.waitForFunction(`(() => {
|
|
250
|
+
const input = document.querySelector(".search-input");
|
|
251
|
+
return input instanceof HTMLInputElement && input.value === ${kwLiteral};
|
|
252
|
+
})()`, { timeout: 5_000 });
|
|
106
253
|
await sleepRandom(SEARCH_RESULT_SETTLE_MS.min, SEARCH_RESULT_SETTLE_MS.max);
|
|
107
254
|
await ensureSearchFrameReady(frame);
|
|
108
255
|
}
|
|
@@ -128,28 +275,28 @@ export async function selectNormalSearchJob(frame, keyword) {
|
|
|
128
275
|
const before = await readCurrentSearchJob(frame);
|
|
129
276
|
const kwLiteral = JSON.stringify(kw);
|
|
130
277
|
// 岗位下拉的选项(.job-name)常驻 DOM,但先点触发器展开以兼容折叠态。
|
|
131
|
-
await frame.evaluate(`(() => {
|
|
132
|
-
const h = document.querySelector(".search-job-list-C .ui-dropmenu-label")
|
|
133
|
-
|| document.querySelector(".ui-dropmenu-label");
|
|
134
|
-
if (h instanceof HTMLElement) {
|
|
135
|
-
h.scrollIntoView({ block: "center", inline: "nearest" });
|
|
136
|
-
h.click();
|
|
137
|
-
}
|
|
278
|
+
await frame.evaluate(`(() => {
|
|
279
|
+
const h = document.querySelector(".search-job-list-C .ui-dropmenu-label")
|
|
280
|
+
|| document.querySelector(".ui-dropmenu-label");
|
|
281
|
+
if (h instanceof HTMLElement) {
|
|
282
|
+
h.scrollIntoView({ block: "center", inline: "nearest" });
|
|
283
|
+
h.click();
|
|
284
|
+
}
|
|
138
285
|
})()`);
|
|
139
286
|
await sleepRandom(JOB_SWITCH_SETTLE_MS.min, JOB_SWITCH_SETTLE_MS.max);
|
|
140
|
-
const picked = (await frame.evaluate(`(() => {
|
|
141
|
-
const kw = ${kwLiteral};
|
|
142
|
-
const norm = (v) => (v ?? "").replace(/\\s+/g, "").trim().toLowerCase();
|
|
143
|
-
const items = Array.from(document.querySelectorAll(".job-name"));
|
|
144
|
-
if (items.length === 0) return { ok: false, reason: "empty" };
|
|
145
|
-
const target = items.find((el) => norm(el.textContent).includes(norm(kw)));
|
|
146
|
-
if (!(target instanceof HTMLElement)) {
|
|
147
|
-
return { ok: false, reason: "not_found", available: items.map((el) => (el.textContent ?? "").replace(/\\s+/g, " ").trim()) };
|
|
148
|
-
}
|
|
149
|
-
const label = (target.textContent ?? "").replace(/\\s+/g, " ").trim();
|
|
150
|
-
target.scrollIntoView({ block: "center", inline: "nearest" });
|
|
151
|
-
target.click();
|
|
152
|
-
return { ok: true, label };
|
|
287
|
+
const picked = (await frame.evaluate(`(() => {
|
|
288
|
+
const kw = ${kwLiteral};
|
|
289
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, "").trim().toLowerCase();
|
|
290
|
+
const items = Array.from(document.querySelectorAll(".job-name"));
|
|
291
|
+
if (items.length === 0) return { ok: false, reason: "empty" };
|
|
292
|
+
const target = items.find((el) => norm(el.textContent).includes(norm(kw)));
|
|
293
|
+
if (!(target instanceof HTMLElement)) {
|
|
294
|
+
return { ok: false, reason: "not_found", available: items.map((el) => (el.textContent ?? "").replace(/\\s+/g, " ").trim()) };
|
|
295
|
+
}
|
|
296
|
+
const label = (target.textContent ?? "").replace(/\\s+/g, " ").trim();
|
|
297
|
+
target.scrollIntoView({ block: "center", inline: "nearest" });
|
|
298
|
+
target.click();
|
|
299
|
+
return { ok: true, label };
|
|
153
300
|
})()`));
|
|
154
301
|
if (!picked.ok) {
|
|
155
302
|
if (picked.reason === 'not_found') {
|
|
@@ -160,10 +307,11 @@ export async function selectNormalSearchJob(frame, keyword) {
|
|
|
160
307
|
}
|
|
161
308
|
const label = picked.label ?? kw;
|
|
162
309
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
310
|
+
// 判据内联,理由同 runKeywordSearch:字符串 pageFunction 收不到入参。
|
|
311
|
+
await frame.waitForFunction(`(() => {
|
|
312
|
+
const cur = (document.querySelector(".search-current-job")?.textContent ?? "").replace(/\\s+/g, " ").trim();
|
|
313
|
+
return cur.length > 0 && cur !== ${JSON.stringify(before)};
|
|
314
|
+
})()`, { timeout: 8_000 });
|
|
167
315
|
}
|
|
168
316
|
catch {
|
|
169
317
|
// 当前岗位文案未变(可能本就是该岗)——不阻断,返回读到的 label。
|
|
@@ -171,35 +319,902 @@ export async function selectNormalSearchJob(frame, keyword) {
|
|
|
171
319
|
await sleepRandom(JOB_SWITCH_SETTLE_MS.min, JOB_SWITCH_SETTLE_MS.max);
|
|
172
320
|
return label;
|
|
173
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* 把当前岗位切成「不限职位」——搜全池而不是被某个岗位的画像圈住。
|
|
324
|
+
*
|
|
325
|
+
* 它是岗位下拉的第一项,但**不带 `.job-name`**(那个 class 只给真实职位用),
|
|
326
|
+
* 所以 `selectNormalSearchJob` 的选择器扫不到它,得单独按 `li[ka="search_select_job"]` 找。
|
|
327
|
+
*/
|
|
328
|
+
export async function selectNormalSearchAnyJob(frame) {
|
|
329
|
+
const current = await readCurrentSearchJob(frame);
|
|
330
|
+
if (current.includes('不限职位')) {
|
|
331
|
+
return current;
|
|
332
|
+
}
|
|
333
|
+
await frame.evaluate(`(() => {
|
|
334
|
+
const h = document.querySelector(".search-job-list-C .ui-dropmenu-label")
|
|
335
|
+
|| document.querySelector(".ui-dropmenu-label");
|
|
336
|
+
if (h instanceof HTMLElement) {
|
|
337
|
+
h.scrollIntoView({ block: "center", inline: "nearest" });
|
|
338
|
+
h.click();
|
|
339
|
+
}
|
|
340
|
+
})()`);
|
|
341
|
+
await sleepRandom(JOB_SWITCH_SETTLE_MS.min, JOB_SWITCH_SETTLE_MS.max);
|
|
342
|
+
const ok = (await frame.evaluate(`(() => {
|
|
343
|
+
const items = Array.from(document.querySelectorAll('li[ka="search_select_job"]'));
|
|
344
|
+
const target = items.find((el) => (el.textContent ?? "").replace(/\\s+/g, "").includes("不限职位"));
|
|
345
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
346
|
+
target.scrollIntoView({ block: "center", inline: "nearest" });
|
|
347
|
+
target.click();
|
|
348
|
+
return true;
|
|
349
|
+
})()`));
|
|
350
|
+
if (!ok) {
|
|
351
|
+
throw new Error('未找到岗位下拉里的「不限职位」项(li[ka="search_select_job"])。');
|
|
352
|
+
}
|
|
353
|
+
await sleepRandom(JOB_SWITCH_SETTLE_MS.min, JOB_SWITCH_SETTLE_MS.max);
|
|
354
|
+
return (await readCurrentSearchJob(frame)) || '不限职位';
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 读当前已选城市;未选时返回空串。
|
|
358
|
+
*
|
|
359
|
+
* ⚠️ **不能用 `gray-color` 判占位**:实测选中深圳之后该 class 仍然挂着,
|
|
360
|
+
* 只有文案从「城市」变成「深圳」。所以判据是文案本身。
|
|
361
|
+
*/
|
|
362
|
+
async function readSelectedCity(frame) {
|
|
363
|
+
return (await frame.evaluate(`(() => {
|
|
364
|
+
const el = document.querySelector(".city-wrap .city");
|
|
365
|
+
if (!el) return "";
|
|
366
|
+
const t = (el.textContent ?? "").replace(/\\s+/g, " ").trim();
|
|
367
|
+
return t === "城市" ? "" : t;
|
|
368
|
+
})()`));
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* 选搜索城市。控件是「输入即联想」,不是普通下拉:
|
|
372
|
+
* 点 `.city-wrap` 让输入框获焦 → 往 `.search-city-kw input` 里敲字 →
|
|
373
|
+
* 冒出 `.city-box .search-result-item` → 点中文本完全相同的那一项。
|
|
374
|
+
*
|
|
375
|
+
* **必须走真实键盘输入**(ElementHandle.type):页面是 Vue,直接改 `input.value`
|
|
376
|
+
* 不触发 input 事件,联想列表根本不会出来。
|
|
377
|
+
*
|
|
378
|
+
* 已经是目标城市就直接返回,不做任何点击——少一次无谓交互就少一分自动化特征。
|
|
379
|
+
*/
|
|
380
|
+
export async function selectNormalSearchCity(frame, city) {
|
|
381
|
+
const target = city.trim();
|
|
382
|
+
if (!target) {
|
|
383
|
+
return readSelectedCity(frame);
|
|
384
|
+
}
|
|
385
|
+
const current = await readSelectedCity(frame);
|
|
386
|
+
if (current === target) {
|
|
387
|
+
return current;
|
|
388
|
+
}
|
|
389
|
+
await frame.evaluate(`(() => {
|
|
390
|
+
const el = document.querySelector(".city-wrap");
|
|
391
|
+
if (el instanceof HTMLElement) {
|
|
392
|
+
el.scrollIntoView({ block: "center", inline: "nearest" });
|
|
393
|
+
el.click();
|
|
394
|
+
}
|
|
395
|
+
})()`);
|
|
396
|
+
await sleepRandom(CITY_SETTLE_MS.min, CITY_SETTLE_MS.max);
|
|
397
|
+
const input = await frame.$('.search-city-kw input');
|
|
398
|
+
if (!input) {
|
|
399
|
+
throw new Error('未找到城市输入框(.search-city-kw input)。');
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* 先清空残留再输入,否则会叠成「深圳深圳」,联想列表直接给「暂无结果」。
|
|
403
|
+
*
|
|
404
|
+
* ⚠️ **必须走 DOM 赋值 + 派发 input 事件**,不能用键盘 Backspace 清:
|
|
405
|
+
* 本机实测按 4 次 Backspace 后 `input.value` 纹丝不动(Vue 受控输入,
|
|
406
|
+
* 且这个框在 `hideCity` 状态下拿不到真实焦点)。派发 input 事件才是 v-model 认的那条路。
|
|
407
|
+
* 清空之后再用真实键盘打字——打字本身没问题,有问题的只有清空。
|
|
408
|
+
*/
|
|
409
|
+
await frame.evaluate(`(() => {
|
|
410
|
+
const el = document.querySelector(".search-city-kw input");
|
|
411
|
+
if (el) {
|
|
412
|
+
el.value = "";
|
|
413
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
414
|
+
}
|
|
415
|
+
})()`);
|
|
416
|
+
await sleepRandom(CITY_SETTLE_MS.min, CITY_SETTLE_MS.max);
|
|
417
|
+
await input.click();
|
|
418
|
+
await input.type(target, { delay: 90 });
|
|
419
|
+
/**
|
|
420
|
+
* 联想列表是异步拉的,**必须等它出现再读**——早先用固定 sleep 等,
|
|
421
|
+
* 本机实测会偶发「没有联想结果」误报(sleep 到点了列表还没渲染)。
|
|
422
|
+
*/
|
|
423
|
+
try {
|
|
424
|
+
await frame.waitForFunction(`(() => document.querySelectorAll(".city-box .search-result-item").length > 0)()`, { timeout: CITY_SUGGEST_TIMEOUT_MS });
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
throw new Error(`城市「${target}」在 ${CITY_SUGGEST_TIMEOUT_MS / 1000}s 内没等到联想结果,换个写法试试(如「深圳」而不是「深圳市」)。`);
|
|
428
|
+
}
|
|
429
|
+
await sleepRandom(CITY_SETTLE_MS.min, CITY_SETTLE_MS.max);
|
|
430
|
+
// 目标城市内联进脚本,理由同 runKeywordSearch:字符串 pageFunction 收不到入参,
|
|
431
|
+
// 走入参的话这里恒为 `{}`,每次都报「查无此项」。
|
|
432
|
+
const picked = (await frame.evaluate(`(() => {
|
|
433
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, "").trim();
|
|
434
|
+
const want = ${JSON.stringify(target)};
|
|
435
|
+
const all = Array.from(document.querySelectorAll(".city-box .search-result-item"));
|
|
436
|
+
// 没命中时平台会渲染一条「暂无结果」占位项——它不是候选城市,别当成选项。
|
|
437
|
+
const items = all.filter((el) => norm(el.textContent) !== "暂无结果");
|
|
438
|
+
if (items.length === 0) return { ok: false, reason: "empty" };
|
|
439
|
+
const exact = items.find((el) => norm(el.textContent) === norm(want));
|
|
440
|
+
// 只点完全匹配项。模糊命中就点第一个太危险——「南京」可能点成「南京市六合区」。
|
|
441
|
+
if (!exact) return { ok: false, reason: "no_exact", options: items.map((el) => norm(el.textContent)) };
|
|
442
|
+
const label = norm(exact.textContent);
|
|
443
|
+
exact.scrollIntoView({ block: "center", inline: "nearest" });
|
|
444
|
+
exact.click();
|
|
445
|
+
return { ok: true, label, exact: true };
|
|
446
|
+
})()`));
|
|
447
|
+
if (!picked.ok) {
|
|
448
|
+
if (picked.reason === 'no_exact') {
|
|
449
|
+
throw new Error(`城市「${target}」没有完全匹配项,已中止以免选错。候选:${(picked.options ?? []).join('|')}`);
|
|
450
|
+
}
|
|
451
|
+
throw new Error(`城市「${target}」查无此项,换个写法试试(如「深圳」而不是「深圳市」)。`);
|
|
452
|
+
}
|
|
453
|
+
await sleepRandom(CITY_SETTLE_MS.min, CITY_SETTLE_MS.max);
|
|
454
|
+
return picked.label ?? target;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* 在筛选面板里点一项,**查找与点击在同一次 evaluate 内完成**。
|
|
458
|
+
*
|
|
459
|
+
* 为什么不能「先等面板出现、再点」:选完城市后这块会异步重渲染,
|
|
460
|
+
* 「等到了」和「去点」之间存在窗口期,元素在这中间被卸载 —— 本机实测稳定复现
|
|
461
|
+
* 「等待通过、紧接着 querySelector 返回 null」。把两步合成一次原子操作,
|
|
462
|
+
* 外层只负责在 `no_root` 时重试,重渲染就只是多试一轮而不是直接报错。
|
|
463
|
+
*/
|
|
464
|
+
async function clickFilterItem(frame, rootSel, itemSel, want, opts = {}) {
|
|
465
|
+
const expr = `(() => {
|
|
466
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, "").trim();
|
|
467
|
+
const root = document.querySelector(${JSON.stringify(rootSel)});
|
|
468
|
+
if (!root) return { ok: false, reason: "no_root" };
|
|
469
|
+
const items = Array.from(root.querySelectorAll(${JSON.stringify(itemSel)}));
|
|
470
|
+
const target = items.find((el) => norm(el.textContent) === norm(${JSON.stringify(want)}));
|
|
471
|
+
if (!(target instanceof HTMLElement)) {
|
|
472
|
+
return { ok: false, reason: "not_found", options: items.map((el) => norm(el.textContent)) };
|
|
473
|
+
}
|
|
474
|
+
const label = norm(target.textContent);
|
|
475
|
+
if (${opts.skipIfActive ? 'true' : 'false'} && target.className.includes("active")) {
|
|
476
|
+
return { ok: true, label, already: true };
|
|
477
|
+
}
|
|
478
|
+
target.scrollIntoView({ block: "center", inline: "nearest" });
|
|
479
|
+
target.click();
|
|
480
|
+
return { ok: true, label, already: false };
|
|
481
|
+
})()`;
|
|
482
|
+
return evalWithPanelRetry(frame, expr);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* 跑一段筛选面板脚本,只在它报 `no_root`(整块还没挂上来)时重试。
|
|
486
|
+
* 选项本身不存在是用户传错了值,立即返回让上层报错。
|
|
487
|
+
*/
|
|
488
|
+
async function evalWithPanelRetry(frame, expr) {
|
|
489
|
+
const deadline = Date.now() + FILTER_PANEL_TIMEOUT_MS;
|
|
490
|
+
for (;;) {
|
|
491
|
+
const r = (await frame.evaluate(expr));
|
|
492
|
+
if (r.ok || r.reason !== 'no_root' || Date.now() > deadline) {
|
|
493
|
+
return r;
|
|
494
|
+
}
|
|
495
|
+
await sleepRandom(300, 600);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** 学历要求:`.degree-ui` 里的单选项,当前选中项带 `.active`(已选中就不重复点)。 */
|
|
499
|
+
export async function selectNormalSearchDegree(frame, degree) {
|
|
500
|
+
const target = degree.trim();
|
|
501
|
+
if (!target) {
|
|
502
|
+
return '';
|
|
503
|
+
}
|
|
504
|
+
const picked = await clickFilterItem(frame, '.degree-ui', '.degree-item', target, {
|
|
505
|
+
skipIfActive: true,
|
|
506
|
+
});
|
|
507
|
+
if (!picked.ok) {
|
|
508
|
+
if (picked.reason === 'not_found') {
|
|
509
|
+
throw new Error(`学历要求没有「${target}」这一项。可选:${(picked.options ?? []).join('|')}`);
|
|
510
|
+
}
|
|
511
|
+
throw new Error(`未找到学历要求控件(.degree-ui),等了 ${FILTER_PANEL_TIMEOUT_MS / 1000}s。`);
|
|
512
|
+
}
|
|
513
|
+
if (!picked.already) {
|
|
514
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
515
|
+
}
|
|
516
|
+
return picked.label ?? target;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* 经验要求:预设项和自定义滑块是互斥的两条路,读的时候两条都得看。
|
|
520
|
+
* 只看预设项的话,用自定义区间筛出来的结果标题里会什么都不显示。
|
|
521
|
+
*/
|
|
522
|
+
async function readSelectedExp(frame) {
|
|
523
|
+
const preset = await readActiveFilterItem(frame, '.exp-list-ui', '.exp-item');
|
|
524
|
+
if (preset) {
|
|
525
|
+
return preset;
|
|
526
|
+
}
|
|
527
|
+
const range = await readSliderRange(frame, EXP_SLIDER);
|
|
528
|
+
return range ? `${expSliderLabel(range.min)}-${expSliderLabel(range.max)}(自定义)` : '';
|
|
529
|
+
}
|
|
530
|
+
/** 年龄要求:同上,预设项之外还要看自定义的那两个下拉。 */
|
|
531
|
+
async function readSelectedAge(frame) {
|
|
532
|
+
const preset = await readActiveFilterItem(frame, '.age-list-ui', '.age-item');
|
|
533
|
+
if (preset) {
|
|
534
|
+
return preset;
|
|
535
|
+
}
|
|
536
|
+
const range = await readAgeCustomRange(frame);
|
|
537
|
+
return range ? `${range.min}-${range.max}(自定义)` : '';
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* 经验要求:`.exp-list-ui` 里的单选项
|
|
541
|
+
* (在校/应届 / 25年毕业 / 26年毕业 / 26年后毕业 / 1-3年 / 3-5年 / 5-10年)。
|
|
542
|
+
*
|
|
543
|
+
* 旁边还有个「自定义」滑块(`.experience-select-custom-slider`)没做——拖滑块的交互
|
|
544
|
+
* 和点选项完全是两回事,等真有人需要「6-8年」这种区间再说。
|
|
545
|
+
*/
|
|
546
|
+
export async function selectNormalSearchExp(frame, exp) {
|
|
547
|
+
const target = exp.trim();
|
|
548
|
+
if (!target) {
|
|
549
|
+
return '';
|
|
550
|
+
}
|
|
551
|
+
const picked = await clickFilterItem(frame, '.exp-list-ui', '.exp-item', target, {
|
|
552
|
+
skipIfActive: true,
|
|
553
|
+
});
|
|
554
|
+
if (!picked.ok) {
|
|
555
|
+
if (picked.reason === 'not_found') {
|
|
556
|
+
throw new Error(`经验要求没有「${target}」这一项。可选:${(picked.options ?? []).join('|')}`);
|
|
557
|
+
}
|
|
558
|
+
throw new Error(`未找到经验要求控件(.exp-list-ui),等了 ${FILTER_PANEL_TIMEOUT_MS / 1000}s。`);
|
|
559
|
+
}
|
|
560
|
+
if (!picked.already) {
|
|
561
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
562
|
+
}
|
|
563
|
+
return picked.label ?? target;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* 年龄要求:`.age-list-ui` 里的单选项(20-25 / 25-30 / 30-35 / 35-40 / 40-50 / 50以上)。
|
|
567
|
+
* 「自定义」那对下拉(`.age-custom`,默认 `display:none`)同样没做。
|
|
568
|
+
*/
|
|
569
|
+
export async function selectNormalSearchAge(frame, age) {
|
|
570
|
+
const target = age.trim();
|
|
571
|
+
if (!target) {
|
|
572
|
+
return '';
|
|
573
|
+
}
|
|
574
|
+
const picked = await clickFilterItem(frame, '.age-list-ui', '.age-item', target, {
|
|
575
|
+
skipIfActive: true,
|
|
576
|
+
});
|
|
577
|
+
if (!picked.ok) {
|
|
578
|
+
if (picked.reason === 'not_found') {
|
|
579
|
+
throw new Error(`年龄要求没有「${target}」这一项。可选:${(picked.options ?? []).join('|')}`);
|
|
580
|
+
}
|
|
581
|
+
throw new Error(`未找到年龄要求控件(.age-list-ui),等了 ${FILTER_PANEL_TIMEOUT_MS / 1000}s。`);
|
|
582
|
+
}
|
|
583
|
+
if (!picked.already) {
|
|
584
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
585
|
+
}
|
|
586
|
+
return picked.label ?? target;
|
|
587
|
+
}
|
|
588
|
+
const EXP_SLIDER = {
|
|
589
|
+
wrap: '.experience-select-custom-slider',
|
|
590
|
+
stops: EXP_SLIDER_STOPS,
|
|
591
|
+
label: expSliderLabel,
|
|
592
|
+
name: '经验',
|
|
593
|
+
};
|
|
594
|
+
const DEGREE_SLIDER = {
|
|
595
|
+
wrap: '.degree-select-custom-slider',
|
|
596
|
+
stops: DEGREE_SLIDER_STOPS,
|
|
597
|
+
label: degreeSliderLabel,
|
|
598
|
+
name: '学历',
|
|
599
|
+
};
|
|
600
|
+
/** 读滑块的当前档位;`null` 表示还是默认的整段(=没用自定义)。 */
|
|
601
|
+
async function readSliderRange(frame, spec) {
|
|
602
|
+
const { min, max } = await readSliderIndices(frame, spec);
|
|
603
|
+
if (!Number.isInteger(min) || !Number.isInteger(max)) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
if (min === 1 && max === spec.stops) {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
return { min, max };
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* 拖经验滑块到指定档位区间。
|
|
613
|
+
*
|
|
614
|
+
* ⚠️ **拖完必须按 hidden input 的真实档位校验并纠偏**,不能拖完就当成了:
|
|
615
|
+
* 本机实测按百分比一把拖过去经常差一格(要第 4 档落在第 3 档、要第 9 档落在第 8 档),
|
|
616
|
+
* 因为按下的位置是手柄中心、而手柄本身有 12px 宽,半格的偏移足够跨过吸附边界。
|
|
617
|
+
* 差一格就是搜错人群,而且用户完全看不出来。
|
|
618
|
+
*
|
|
619
|
+
* 也没有键盘可用——手柄拿不到焦点,方向键实测无效。
|
|
620
|
+
*/
|
|
621
|
+
async function dragSliderRange(page, frame, spec, minIndex, maxIndex) {
|
|
622
|
+
const bar = await frame.$(`${spec.wrap} .ui-slider-wrap`);
|
|
623
|
+
const barBox = await bar?.boundingBox();
|
|
624
|
+
if (!barBox) {
|
|
625
|
+
throw new Error(`未找到${spec.name}自定义滑块(${spec.wrap})。`);
|
|
626
|
+
}
|
|
627
|
+
const stepPx = barBox.width / (spec.stops - 1);
|
|
628
|
+
// 先拖右手柄再拖左手柄:清空后默认是整段,右手柄往左收不会被左手柄挡住。
|
|
629
|
+
await dragOneHandle(page, frame, spec, barBox.x, stepPx, 1, maxIndex);
|
|
630
|
+
await dragOneHandle(page, frame, spec, barBox.x, stepPx, 0, minIndex);
|
|
631
|
+
}
|
|
632
|
+
async function dragOneHandle(page, frame, spec, barLeft, stepPx, handleIndex, targetIndex) {
|
|
633
|
+
const targetX = barLeft + (targetIndex - 1) * stepPx;
|
|
634
|
+
/**
|
|
635
|
+
* 拖完按 hidden 的真实档位校验,没到位就把「差了几格」累积成像素偏置,
|
|
636
|
+
* 拖到**绝对目标位置 + 偏置**再来一次。实测三轮内收敛。
|
|
637
|
+
*
|
|
638
|
+
* 不按「从当前手柄位置相对移动几格」算:那样会在差一格的位置反复横跳,六次全废。
|
|
639
|
+
* 也不要试图给某个手柄写死一个固定偏移——组件的响应跟手柄的起始位置有关
|
|
640
|
+
* (同一个指针位置,从第 7 档拖过来落在第 4 档,从第 6 档拖过来落在第 5 档),
|
|
641
|
+
* 写死偏移能修好学历滑块就会弄坏经验滑块。只有"拖完读真值再纠"这条路是通的。
|
|
642
|
+
*/
|
|
643
|
+
let biasPx = 0;
|
|
644
|
+
for (let attempt = 0; attempt < SLIDER_MAX_ATTEMPTS; attempt++) {
|
|
645
|
+
const handles = await frame.$$(`${spec.wrap} .ui-slider-button-wrap`);
|
|
646
|
+
const box = await handles[handleIndex]?.boundingBox();
|
|
647
|
+
if (!box) {
|
|
648
|
+
throw new Error(`未找到${spec.name}滑块的拖动手柄(.ui-slider-button-wrap)。`);
|
|
649
|
+
}
|
|
650
|
+
const from = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
651
|
+
const landed = await readSliderIndices(frame, spec);
|
|
652
|
+
const current = handleIndex === 0 ? landed.min : landed.max;
|
|
653
|
+
if (current === targetIndex) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (attempt > 0) {
|
|
657
|
+
biasPx += (targetIndex - current) * stepPx;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* 钳住:**不许拖到对面手柄的位置上去**。
|
|
661
|
+
* 两个手柄一旦重叠,`$$` 出来的第 0 / 第 1 个就分不清谁是谁了,后面每一轮都在抓错的那个,
|
|
662
|
+
* 纠偏彻底失效——本机实测就这么把 [1,7] 拖成了 [7,7] 然后六轮全废。
|
|
663
|
+
*/
|
|
664
|
+
const otherIndex = handleIndex === 0 ? landed.max : landed.min;
|
|
665
|
+
const otherX = barLeft + (otherIndex - 1) * stepPx;
|
|
666
|
+
const to = handleIndex === 0
|
|
667
|
+
? Math.min(targetX + biasPx, otherX - stepPx / 2)
|
|
668
|
+
: Math.max(targetX + biasPx, otherX + stepPx / 2);
|
|
669
|
+
/**
|
|
670
|
+
* 松手前那一下 0.5px 的微动 + 停顿是必须的:最后一个 mousemove 会被帧合并吃掉,
|
|
671
|
+
* 组件按上一帧的位置吸附。加上之后实测 5 次里 4 次一把到位。
|
|
672
|
+
*/
|
|
673
|
+
await page.mouse.move(from.x, from.y);
|
|
674
|
+
await page.mouse.down();
|
|
675
|
+
await sleepRandom(120, 200);
|
|
676
|
+
await page.mouse.move(to, from.y, { steps: 20 });
|
|
677
|
+
await sleepRandom(120, 200);
|
|
678
|
+
await page.mouse.move(to + 0.5, from.y);
|
|
679
|
+
await sleepRandom(300, 420);
|
|
680
|
+
await page.mouse.up();
|
|
681
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
682
|
+
}
|
|
683
|
+
const final = await readSliderIndices(frame, spec);
|
|
684
|
+
const got = handleIndex === 0 ? final.min : final.max;
|
|
685
|
+
if (got !== targetIndex) {
|
|
686
|
+
throw new Error(`${spec.name}滑块拖了 ${SLIDER_MAX_ATTEMPTS} 次也没停在「${spec.label(targetIndex)}」,当前停在「${spec.label(got)}」。`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
async function readSliderIndices(frame, spec) {
|
|
690
|
+
const raw = (await frame.evaluate(`(() => document.querySelector("${spec.wrap} .ui-slider input[type=hidden]")?.value ?? "")()`));
|
|
691
|
+
const [min, max] = raw.split(',').map((s) => Number(s));
|
|
692
|
+
return { min, max };
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* 经验要求(自定义区间):拖 `.experience-select-custom-slider` 的双手柄滑块。
|
|
696
|
+
* 一拖动,预设那排的「不限」就会自动取消,两者是互斥的。
|
|
697
|
+
*/
|
|
698
|
+
export async function selectNormalSearchExpRange(page, frame, minToken, maxToken) {
|
|
699
|
+
return setSliderRange(page, frame, EXP_SLIDER, expTokenToIndex(minToken), expTokenToIndex(maxToken));
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* 学历要求(自定义区间):拖 `.degree-select-custom-slider`,七档
|
|
703
|
+
* 初中及以下 → 博士。预设那排只有「本科及以上 / 硕士及以上 / 博士」,
|
|
704
|
+
* 想要「大专-本科」这种带上限的区间只能走这里。
|
|
705
|
+
*/
|
|
706
|
+
export async function selectNormalSearchDegreeRange(page, frame, minToken, maxToken) {
|
|
707
|
+
return setSliderRange(page, frame, DEGREE_SLIDER, degreeTokenToIndex(minToken), degreeTokenToIndex(maxToken));
|
|
708
|
+
}
|
|
709
|
+
async function setSliderRange(page, frame, spec, minIndex, maxIndex) {
|
|
710
|
+
if (minIndex > maxIndex) {
|
|
711
|
+
throw new Error(`${spec.name}区间的下限「${spec.label(minIndex)}」比上限「${spec.label(maxIndex)}」还大。`);
|
|
712
|
+
}
|
|
713
|
+
await dragSliderRange(page, frame, spec, minIndex, maxIndex);
|
|
714
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
715
|
+
return `${spec.label(minIndex)}-${spec.label(maxIndex)}`;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* 读年龄自定义的两个下拉;没展开或没选满两头就返回 `null`。
|
|
719
|
+
*
|
|
720
|
+
* ⚠️ 值在 **`input[type=hidden]`** 上,不在可见的那个 `input.ipt`——后者选完仍然是空的
|
|
721
|
+
* (和「跳槽频率」那种把文案写进 `span.ipt` 的下拉不是一个渲染方式)。
|
|
722
|
+
* 取值实测:普通年龄存数字本身,「46岁+」存 `10000`,「不限」存 `-1`,没选过是 `0`。
|
|
723
|
+
*/
|
|
724
|
+
async function readAgeCustomRange(frame) {
|
|
725
|
+
const raw = (await frame.evaluate(`(() => {
|
|
726
|
+
const box = document.querySelector(".age-custom");
|
|
727
|
+
if (!box || getComputedStyle(box).display === "none") return null;
|
|
728
|
+
const vals = Array.from(box.querySelectorAll(".dropdown-wrap")).map((w) => w.querySelector('input[type=hidden]')?.value ?? "");
|
|
729
|
+
return vals.length === 2 ? vals : null;
|
|
730
|
+
})()`));
|
|
731
|
+
if (!raw) {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
const labels = raw.map((v) => ageHiddenToLabel(v));
|
|
735
|
+
if (labels.some((v) => !v)) {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
return { min: labels[0], max: labels[1] };
|
|
739
|
+
}
|
|
740
|
+
/** 年龄下拉的 hidden 值 → 页面文案;`0`(没选)和 `-1`(不限)都算"没筛"。 */
|
|
741
|
+
function ageHiddenToLabel(raw) {
|
|
742
|
+
const n = Number(raw);
|
|
743
|
+
if (!Number.isFinite(n) || n === 0 || n === -1) {
|
|
744
|
+
return '';
|
|
745
|
+
}
|
|
746
|
+
return n >= AGE_PLUS_HIDDEN_VALUE ? '46岁+' : `${n}岁`;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* 年龄要求(自定义区间):点「自定义」展开 `.age-custom`,里面是两个下拉(16岁…46岁+)。
|
|
750
|
+
* 和经验那个滑块不一样,这里是规规矩矩的下拉,选完读回 `span.ipt` 校验。
|
|
751
|
+
*/
|
|
752
|
+
export async function selectNormalSearchAgeRange(frame, minToken, maxToken) {
|
|
753
|
+
const minLabel = ageTokenToLabel(minToken);
|
|
754
|
+
const maxLabel = ageTokenToLabel(maxToken);
|
|
755
|
+
const opened = await evalWithPanelRetry(frame, `(() => {
|
|
756
|
+
const custom = document.querySelector(".age-list-ui .custom");
|
|
757
|
+
if (!(custom instanceof HTMLElement)) return { ok: false, reason: "no_root" };
|
|
758
|
+
const box = document.querySelector(".age-custom");
|
|
759
|
+
if (box && getComputedStyle(box).display !== "none") return { ok: true };
|
|
760
|
+
custom.scrollIntoView({ block: "center", inline: "nearest" });
|
|
761
|
+
custom.click();
|
|
762
|
+
return { ok: true };
|
|
763
|
+
})()`);
|
|
764
|
+
if (!opened.ok) {
|
|
765
|
+
throw new Error(`未找到年龄的「自定义」入口(.age-list-ui .custom),等了 ${FILTER_PANEL_TIMEOUT_MS / 1000}s。`);
|
|
766
|
+
}
|
|
767
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
768
|
+
await pickAgeCustomDropdown(frame, 0, minLabel);
|
|
769
|
+
await pickAgeCustomDropdown(frame, 1, maxLabel);
|
|
770
|
+
// 两头都选完再校验一次页面上真实显示的值,别拿入参当结果。
|
|
771
|
+
const got = await readAgeCustomRange(frame);
|
|
772
|
+
if (!got || got.min !== minLabel || got.max !== maxLabel) {
|
|
773
|
+
throw new Error(`年龄自定义区间没落到「${minLabel}-${maxLabel}」,页面现在显示:${got ? `${got.min}-${got.max}` : '(空)'}。`);
|
|
774
|
+
}
|
|
775
|
+
return `${minLabel}-${maxLabel}`;
|
|
776
|
+
}
|
|
777
|
+
async function pickAgeCustomDropdown(frame, index, label) {
|
|
778
|
+
const opened = await evalWithPanelRetry(frame, `(() => {
|
|
779
|
+
const wrap = document.querySelectorAll(".age-custom .dropdown-wrap")[${index}];
|
|
780
|
+
if (!wrap) return { ok: false, reason: "no_root" };
|
|
781
|
+
const trigger = wrap.querySelector(".dropdown-select");
|
|
782
|
+
if (!(trigger instanceof HTMLElement)) return { ok: false, reason: "no_root" };
|
|
783
|
+
trigger.scrollIntoView({ block: "center", inline: "nearest" });
|
|
784
|
+
trigger.click();
|
|
785
|
+
return { ok: true };
|
|
786
|
+
})()`);
|
|
787
|
+
if (!opened.ok) {
|
|
788
|
+
throw new Error('未找到年龄自定义的下拉(.age-custom .dropdown-wrap)。');
|
|
789
|
+
}
|
|
790
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
791
|
+
const picked = (await frame.evaluate(`(() => {
|
|
792
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, "").trim();
|
|
793
|
+
const wrap = document.querySelectorAll(".age-custom .dropdown-wrap")[${index}];
|
|
794
|
+
if (!wrap) return { ok: false, reason: "no_root" };
|
|
795
|
+
const options = Array.from(wrap.querySelectorAll(".dropdown-menu li"));
|
|
796
|
+
const hit = options.find((el) => norm(el.textContent) === norm(${JSON.stringify(label)}));
|
|
797
|
+
if (!(hit instanceof HTMLElement)) {
|
|
798
|
+
return { ok: false, reason: "not_found", options: options.map((el) => norm(el.textContent)) };
|
|
799
|
+
}
|
|
800
|
+
hit.click();
|
|
801
|
+
return { ok: true, label: norm(hit.textContent) };
|
|
802
|
+
})()`));
|
|
803
|
+
if (!picked.ok) {
|
|
804
|
+
throw new Error(`年龄下拉里没有「${label}」这一项。可选:${(picked.options ?? []).join('|') || '(菜单未展开)'}`);
|
|
805
|
+
}
|
|
806
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* 院校要求:`.school-ui` 里的多选框(统招本科 / 双一流院校 / 211院校 / 985院校 /
|
|
810
|
+
* 留学生 / QS 100 / QS 500),以及单独一个「只看第一学历」(提示语写明=第一学历为全日制本科)。
|
|
811
|
+
*
|
|
812
|
+
* 逐个点,每点一个歇一下——一次性连点是很明显的机器行为。
|
|
813
|
+
*/
|
|
814
|
+
export async function selectNormalSearchSchools(frame, labels) {
|
|
815
|
+
const wanted = labels.map((s) => s.trim()).filter(Boolean);
|
|
816
|
+
const done = [];
|
|
817
|
+
for (const label of wanted) {
|
|
818
|
+
const picked = await clickFilterItem(frame, '.school-ui', '.checkbox-text', label);
|
|
819
|
+
if (!picked.ok) {
|
|
820
|
+
if (picked.reason === 'not_found') {
|
|
821
|
+
throw new Error(`院校要求没有「${label}」这一项。可选:${(picked.options ?? []).join('|')}`);
|
|
822
|
+
}
|
|
823
|
+
throw new Error(`未找到院校要求控件(.school-ui),等了 ${FILTER_PANEL_TIMEOUT_MS / 1000}s。`);
|
|
824
|
+
}
|
|
825
|
+
done.push(picked.label ?? label);
|
|
826
|
+
// 逐个点、每点一个歇一下——一次性连点是很明显的机器行为。
|
|
827
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
828
|
+
}
|
|
829
|
+
return done;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* 读页面上**当前生效**的学历要求;「不限」=没筛,返回空串。
|
|
833
|
+
*
|
|
834
|
+
* 回显必须读页面,不能把入参原样打印:筛选条件是平台侧状态、跨命令粘着,
|
|
835
|
+
* 打入参的话「上次设过、这次没传」的条件会照样生效却不显示,用户会以为没筛。
|
|
836
|
+
*/
|
|
837
|
+
async function readSelectedDegree(frame) {
|
|
838
|
+
const preset = await readActiveFilterItem(frame, '.degree-ui', '.degree-item');
|
|
839
|
+
if (preset) {
|
|
840
|
+
return preset;
|
|
841
|
+
}
|
|
842
|
+
const range = await readSliderRange(frame, DEGREE_SLIDER);
|
|
843
|
+
return range
|
|
844
|
+
? `${degreeSliderLabel(range.min)}-${degreeSliderLabel(range.max)}(自定义)`
|
|
845
|
+
: '';
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* 读「经验要求」/「年龄要求」当前选中项;「不限」=没筛,返回空串。
|
|
849
|
+
*
|
|
850
|
+
* 这两块和 `.degree-ui` 长得一模一样(单选 `span`,选中带 `.active`),
|
|
851
|
+
* 但它们**不在**「其他筛选」里,是两个独立的顶层块,别到 `.more-filter-container` 里找。
|
|
852
|
+
*/
|
|
853
|
+
async function readActiveFilterItem(frame, rootSel, itemSel) {
|
|
854
|
+
return (await frame.evaluate(`(() => {
|
|
855
|
+
const el = document.querySelector("${rootSel} ${itemSel}.active");
|
|
856
|
+
const t = (el?.textContent ?? "").replace(/\\s+/g, " ").trim();
|
|
857
|
+
return t === "不限" ? "" : t;
|
|
858
|
+
})()`));
|
|
859
|
+
}
|
|
860
|
+
/** 读页面上当前勾中的院校要求。勾中态在 `input.checkbox-input` 的 `checked` 属性上。 */
|
|
861
|
+
async function readSelectedSchools(frame) {
|
|
862
|
+
return (await frame.evaluate(`(() => {
|
|
863
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, " ").trim();
|
|
864
|
+
return Array.from(document.querySelectorAll(".school-ui .checkbox"))
|
|
865
|
+
.filter((el) => el.querySelector("input.checkbox-input")?.checked)
|
|
866
|
+
.map((el) => norm(el.querySelector(".checkbox-text")?.textContent))
|
|
867
|
+
.filter(Boolean);
|
|
868
|
+
})()`));
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* 读「其他筛选」里当前非默认的条件。
|
|
872
|
+
*
|
|
873
|
+
* 三类控件的判据不一样:
|
|
874
|
+
* - 下拉型(性别 / 牛人活跃度 / 跳槽频率):`input.ipt` 的 `placeholder` 一直在,
|
|
875
|
+
* 拿它和显示文案 `span.ipt` 比,不同即已选。这也是选中之后唯一还认得出「这是哪一项」的锚点。
|
|
876
|
+
* - 多选型(求职状态 / 牛人职位要求):选中后 `.input-container` 挂上 `not-default-select`,
|
|
877
|
+
* 占位文案 `.defalut-select` 被替换成若干 `.select-item`——**没有** placeholder 可读,
|
|
878
|
+
* 所以只回显值不回显项名(值本身够自解释,如「离职-随时到岗」)。
|
|
879
|
+
* - 专业 / 资格证书:`.major-input-ui` 的文案直接被选中项替换掉。
|
|
880
|
+
*/
|
|
881
|
+
async function readOtherFilters(frame) {
|
|
882
|
+
return (await frame.evaluate(`(() => {
|
|
883
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, " ").trim();
|
|
884
|
+
const out = [];
|
|
885
|
+
const root = document.querySelector(".more-filter-container");
|
|
886
|
+
if (!root) return out;
|
|
887
|
+
|
|
888
|
+
root.querySelectorAll(".dropdown-wrap").forEach((wrap) => {
|
|
889
|
+
const ph = norm(wrap.querySelector("input.ipt")?.getAttribute("placeholder"));
|
|
890
|
+
const shown = norm(wrap.querySelector("span.ipt")?.textContent);
|
|
891
|
+
if (ph && shown && shown !== ph && shown !== "不限") out.push(ph + ":" + shown);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
root.querySelectorAll(".input-container.not-default-select").forEach((box) => {
|
|
895
|
+
const vals = Array.from(box.querySelectorAll(".select-item")).map((el) => norm(el.textContent));
|
|
896
|
+
if (vals.length > 0) out.push(vals.join("/"));
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
root.querySelectorAll(".major-input-ui").forEach((el) => {
|
|
900
|
+
const t = norm(el.textContent);
|
|
901
|
+
if (t && t !== "专业" && t !== "资格证书") out.push("专业:" + t);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
const salary = norm(root.querySelector(".salary-container")?.textContent);
|
|
905
|
+
if (salary && salary !== "薪资区间") out.push("薪资:" + salary);
|
|
906
|
+
|
|
907
|
+
return out;
|
|
908
|
+
})()`));
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* 点「清空筛选」把筛选条件归零。
|
|
912
|
+
*
|
|
913
|
+
* 为什么每次搜索前都要清:条件存在**平台侧**、跨命令粘着。不清的话「这次怎么只有 3 个人」
|
|
914
|
+
* 永远得靠猜是不是上次设的条件还挂着——这跟「不传 --job 就切不限职位」是同一个理由。
|
|
915
|
+
*
|
|
916
|
+
* 实测 `.reset-btn` 清的是学历 / 院校 / 其他筛选;关键词、当前岗位、城市**不动**
|
|
917
|
+
* (那三项本来就由 `--job` / `--city` / 关键词各自显式管)。
|
|
918
|
+
*
|
|
919
|
+
* 已经全是默认态就跳过——少一次无谓点击就少一分自动化特征。
|
|
920
|
+
*/
|
|
921
|
+
export async function resetNormalSearchFilters(frame) {
|
|
922
|
+
const dirty = (await readSelectedDegree(frame)) !== '' ||
|
|
923
|
+
(await readSelectedExp(frame)) !== '' ||
|
|
924
|
+
(await readSelectedAge(frame)) !== '' ||
|
|
925
|
+
(await readSelectedSchools(frame)).length > 0 ||
|
|
926
|
+
(await readOtherFilters(frame)).length > 0;
|
|
927
|
+
if (!dirty) {
|
|
928
|
+
return false;
|
|
929
|
+
}
|
|
930
|
+
const clicked = await evalWithPanelRetry(frame, `(() => {
|
|
931
|
+
const btn = document.querySelector(".reset-btn");
|
|
932
|
+
if (!btn) return { ok: false, reason: "no_root" };
|
|
933
|
+
btn.scrollIntoView({ block: "center", inline: "nearest" });
|
|
934
|
+
btn.click();
|
|
935
|
+
return { ok: true };
|
|
936
|
+
})()`);
|
|
937
|
+
if (!clicked.ok) {
|
|
938
|
+
throw new Error(`未找到「清空筛选」按钮(.reset-btn),等了 ${FILTER_PANEL_TIMEOUT_MS / 1000}s。`);
|
|
939
|
+
}
|
|
940
|
+
// 清空会让整个筛选区重渲染一遍,等它落定再去设新条件,否则后面的点击会扑空。
|
|
941
|
+
await frame.waitForFunction(`(() => {
|
|
942
|
+
const isDefault = (sel) => {
|
|
943
|
+
const el = document.querySelector(sel);
|
|
944
|
+
return !el || (el.textContent ?? "").replace(/\\s+/g, "").trim() === "不限";
|
|
945
|
+
};
|
|
946
|
+
const schoolOk = Array.from(document.querySelectorAll(".school-ui input.checkbox-input")).every((el) => !el.checked);
|
|
947
|
+
// 自定义区间也要回到默认,否则下一轮会在上一轮的滑块位置上继续拖。
|
|
948
|
+
const sliderDefault = (sel, stops) => {
|
|
949
|
+
const el = document.querySelector(sel + " .ui-slider input[type=hidden]");
|
|
950
|
+
return !el || el.value === "1," + stops;
|
|
951
|
+
};
|
|
952
|
+
const sliderOk = sliderDefault(".experience-select-custom-slider", ${EXP_SLIDER_STOPS})
|
|
953
|
+
&& sliderDefault(".degree-select-custom-slider", ${DEGREE_SLIDER_STOPS});
|
|
954
|
+
const ageBox = document.querySelector(".age-custom");
|
|
955
|
+
const ageOk = !ageBox || getComputedStyle(ageBox).display === "none";
|
|
956
|
+
return isDefault(".degree-ui .degree-item.active")
|
|
957
|
+
&& isDefault(".exp-list-ui .exp-item.active")
|
|
958
|
+
&& isDefault(".age-list-ui .age-item.active")
|
|
959
|
+
&& schoolOk && sliderOk && ageOk;
|
|
960
|
+
})()`, { timeout: FILTER_PANEL_TIMEOUT_MS });
|
|
961
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* 求职状态:`.mutil-select` 多选下拉(离职-随时到岗 / 在职-暂不考虑 / 在职-考虑机会 / 在职-月内到岗)。
|
|
966
|
+
*
|
|
967
|
+
* 只能先展开再点选项——菜单是展开时才挂上 DOM 的。而占位文案「求职状态」一旦选中第一项就被
|
|
968
|
+
* 替换掉,所以**先记住它在 `.filter-2-item` 里的下标**,后续都按下标操作,不再按文案找。
|
|
969
|
+
* (这里能靠下标是因为每次搜索前都清过筛选,展开时占位文案一定还在。)
|
|
970
|
+
*/
|
|
971
|
+
export async function selectNormalSearchStatus(frame, labels) {
|
|
972
|
+
const wanted = labels.map((s) => s.trim()).filter(Boolean);
|
|
973
|
+
if (wanted.length === 0) {
|
|
974
|
+
return [];
|
|
975
|
+
}
|
|
976
|
+
const opened = await evalWithPanelRetry(frame, `(() => {
|
|
977
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, "").trim();
|
|
978
|
+
const items = Array.from(document.querySelectorAll(".more-filter-container .filter-2-item"));
|
|
979
|
+
if (items.length === 0) return { ok: false, reason: "no_root" };
|
|
980
|
+
const index = items.findIndex((el) => norm(el.querySelector(".defalut-select")?.textContent) === "求职状态");
|
|
981
|
+
if (index < 0) return { ok: false, reason: "not_found" };
|
|
982
|
+
const trigger = items[index].querySelector(".input-container");
|
|
983
|
+
if (!(trigger instanceof HTMLElement)) return { ok: false, reason: "not_found" };
|
|
984
|
+
trigger.scrollIntoView({ block: "center", inline: "nearest" });
|
|
985
|
+
trigger.click();
|
|
986
|
+
return { ok: true, index };
|
|
987
|
+
})()`);
|
|
988
|
+
if (!opened.ok) {
|
|
989
|
+
throw new Error('未找到「求职状态」筛选项(.more-filter-container 里的 .mutil-select)。');
|
|
990
|
+
}
|
|
991
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
992
|
+
const done = [];
|
|
993
|
+
for (const label of wanted) {
|
|
994
|
+
const picked = (await frame.evaluate(`(() => {
|
|
995
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, "").trim();
|
|
996
|
+
const item = document.querySelectorAll(".more-filter-container .filter-2-item")[${opened.index}];
|
|
997
|
+
if (!item) return { ok: false, reason: "no_root" };
|
|
998
|
+
const options = Array.from(item.querySelectorAll('li[ka="search_dropdown_menu_click"]'));
|
|
999
|
+
const target = options.find((el) => norm(el.textContent) === norm(${JSON.stringify(label)}));
|
|
1000
|
+
if (!(target instanceof HTMLElement)) {
|
|
1001
|
+
return { ok: false, reason: "not_found", options: options.map((el) => norm(el.textContent)) };
|
|
1002
|
+
}
|
|
1003
|
+
target.click();
|
|
1004
|
+
return { ok: true, label: norm(target.textContent) };
|
|
1005
|
+
})()`));
|
|
1006
|
+
if (!picked.ok) {
|
|
1007
|
+
throw new Error(`求职状态没有「${label}」这一项。可选:${(picked.options ?? []).join('|') || '(菜单未展开)'}`);
|
|
1008
|
+
}
|
|
1009
|
+
done.push(picked.label ?? label);
|
|
1010
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
1011
|
+
}
|
|
1012
|
+
// 收起菜单,别让它挂在页面上挡住后面的点击。
|
|
1013
|
+
await frame.evaluate(`(() => {
|
|
1014
|
+
const item = document.querySelectorAll(".more-filter-container .filter-2-item")[${opened.index}];
|
|
1015
|
+
const trigger = item?.querySelector(".input-container");
|
|
1016
|
+
if (trigger instanceof HTMLElement) trigger.click();
|
|
1017
|
+
})()`);
|
|
1018
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
1019
|
+
return done;
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* 跳槽频率:`.work-year-select` 单选下拉(不限 / 5年少于3份 / 时间≥1年)。
|
|
1023
|
+
*
|
|
1024
|
+
* 锚点用 `input.ipt` 的 placeholder 而不是文案——「性别」和「牛人活跃度」共用
|
|
1025
|
+
* `.gender-select` 这个 class,只有 placeholder 能把三个下拉分开。
|
|
1026
|
+
*/
|
|
1027
|
+
export async function selectNormalSearchJobHop(frame, label) {
|
|
1028
|
+
const target = normalizeFilterLabel(label);
|
|
1029
|
+
if (!target) {
|
|
1030
|
+
return '';
|
|
1031
|
+
}
|
|
1032
|
+
const opened = await evalWithPanelRetry(frame, `(() => {
|
|
1033
|
+
const wrap = document.querySelector('.more-filter-container input.ipt[placeholder="跳槽频率"]')?.closest(".dropdown-wrap");
|
|
1034
|
+
if (!wrap) return { ok: false, reason: "no_root" };
|
|
1035
|
+
const trigger = wrap.querySelector(".dropdown-select");
|
|
1036
|
+
if (!(trigger instanceof HTMLElement)) return { ok: false, reason: "no_root" };
|
|
1037
|
+
trigger.scrollIntoView({ block: "center", inline: "nearest" });
|
|
1038
|
+
trigger.click();
|
|
1039
|
+
return { ok: true };
|
|
1040
|
+
})()`);
|
|
1041
|
+
if (!opened.ok) {
|
|
1042
|
+
throw new Error(`未找到「跳槽频率」筛选项,等了 ${FILTER_PANEL_TIMEOUT_MS / 1000}s。`);
|
|
1043
|
+
}
|
|
1044
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
1045
|
+
const picked = (await frame.evaluate(`(() => {
|
|
1046
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, "").trim();
|
|
1047
|
+
const wrap = document.querySelector('.more-filter-container input.ipt[placeholder="跳槽频率"]')?.closest(".dropdown-wrap");
|
|
1048
|
+
if (!wrap) return { ok: false, reason: "no_root" };
|
|
1049
|
+
const options = Array.from(wrap.querySelectorAll(".dropdown-menu li"));
|
|
1050
|
+
const want = norm(${JSON.stringify(target)});
|
|
1051
|
+
const hit = options.find((el) => norm(el.textContent) === want);
|
|
1052
|
+
if (!(hit instanceof HTMLElement)) {
|
|
1053
|
+
return { ok: false, reason: "not_found", options: options.map((el) => norm(el.textContent)) };
|
|
1054
|
+
}
|
|
1055
|
+
hit.click();
|
|
1056
|
+
return { ok: true, label: norm(hit.textContent) };
|
|
1057
|
+
})()`));
|
|
1058
|
+
if (!picked.ok) {
|
|
1059
|
+
throw new Error(`跳槽频率没有「${target}」这一项。可选:${(picked.options ?? []).join('|') || '(菜单未展开)'}`);
|
|
1060
|
+
}
|
|
1061
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
1062
|
+
return picked.label ?? target;
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* 专业:点 `.major-input-ui` 弹出 `.major-dialog` 弹层(`v-transfer-dom`,挂在 body 上,
|
|
1066
|
+
* 但仍在同一个 iframe 文档里),在搜索框里打专业名 → 点联想项 → 点「确定」。
|
|
1067
|
+
*
|
|
1068
|
+
* 和城市一样**只认完全匹配**:搜「计算机」会出几十条(计算机科学与技术 / 计算机应用工程 /
|
|
1069
|
+
* 计算机速录……),替用户挑一条的代价是整轮白跑且他不知道。平台限选 10 个。
|
|
1070
|
+
*
|
|
1071
|
+
* ⚠️ **一个专业开一次弹层**。平台的搜索框选中一项之后就不再出联想了——本机实测:
|
|
1072
|
+
* 选完「计算机科学与技术」再打「软件工程」,`.major-lists` 根本不渲染,失焦重聚焦、
|
|
1073
|
+
* 多敲一个字再退格都救不回来。而「确定」关掉再重开,搜索框就恢复正常,
|
|
1074
|
+
* 且已选的专业还在。所以多个专业只能一个一个来,这是平台行为不是效率取舍。
|
|
1075
|
+
*/
|
|
1076
|
+
export async function selectNormalSearchMajors(frame, labels) {
|
|
1077
|
+
const wanted = labels.map((s) => s.trim()).filter(Boolean);
|
|
1078
|
+
if (wanted.length === 0) {
|
|
1079
|
+
return [];
|
|
1080
|
+
}
|
|
1081
|
+
if (wanted.length > MAJOR_MAX_SELECT) {
|
|
1082
|
+
throw new Error(`专业最多选 ${MAJOR_MAX_SELECT} 个,收到 ${wanted.length} 个。`);
|
|
1083
|
+
}
|
|
1084
|
+
// 上一次跑崩留下的弹层会挡住后面所有点击,先收干净再开工。
|
|
1085
|
+
await closeMajorDialog(frame, false);
|
|
1086
|
+
const done = [];
|
|
1087
|
+
for (const label of wanted) {
|
|
1088
|
+
const input = await openMajorDialog(frame);
|
|
1089
|
+
try {
|
|
1090
|
+
await input.click();
|
|
1091
|
+
await input.type(label, { delay: 90 });
|
|
1092
|
+
try {
|
|
1093
|
+
await frame.waitForFunction(`(() => (${MAJOR_SUGGEST_ITEMS}).length > 0)()`, {
|
|
1094
|
+
timeout: CITY_SUGGEST_TIMEOUT_MS,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
const blank = (await frame.evaluate(`(() => (${VISIBLE_MAJOR_DIALOG}?.querySelector(".major-lists .blank")?.textContent ?? "").replace(/\\s+/g, " ").trim())()`));
|
|
1099
|
+
throw new Error(blank
|
|
1100
|
+
? `专业「${label}」查无此项(平台提示:${blank})。`
|
|
1101
|
+
: `专业「${label}」在 ${CITY_SUGGEST_TIMEOUT_MS / 1000}s 内没等到联想结果。`);
|
|
1102
|
+
}
|
|
1103
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
1104
|
+
const picked = (await frame.evaluate(`(() => {
|
|
1105
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, "").trim();
|
|
1106
|
+
const options = ${MAJOR_SUGGEST_ITEMS};
|
|
1107
|
+
if (options.length === 0) return { ok: false, reason: "no_root" };
|
|
1108
|
+
const want = norm(${JSON.stringify(label)});
|
|
1109
|
+
const hit = options.find((el) => norm(el.textContent) === want);
|
|
1110
|
+
if (!(hit instanceof HTMLElement)) {
|
|
1111
|
+
return { ok: false, reason: "not_found", options: options.map((el) => norm(el.textContent)).slice(0, 20) };
|
|
1112
|
+
}
|
|
1113
|
+
hit.click();
|
|
1114
|
+
return { ok: true, label: norm(hit.textContent) };
|
|
1115
|
+
})()`));
|
|
1116
|
+
if (!picked.ok) {
|
|
1117
|
+
throw new Error(`专业「${label}」没有完全匹配项,已中止以免选错。候选(最多列 20 条):${(picked.options ?? []).join('|')}`);
|
|
1118
|
+
}
|
|
1119
|
+
done.push(picked.label ?? label);
|
|
1120
|
+
}
|
|
1121
|
+
catch (e) {
|
|
1122
|
+
// 报错也得把弹层收掉,否则下一条命令一上来就被这个遮罩挡死。
|
|
1123
|
+
await closeMajorDialog(frame, false);
|
|
1124
|
+
throw e;
|
|
1125
|
+
}
|
|
1126
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
1127
|
+
await closeMajorDialog(frame, true);
|
|
1128
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
1129
|
+
}
|
|
1130
|
+
return done;
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* 展开「专业」弹层,返回里面的搜索框。
|
|
1134
|
+
*
|
|
1135
|
+
* 触发器认「`.more-filter-container` 里那个没被 `display:none` 藏起来的 `.major-input-ui`」——
|
|
1136
|
+
* 不能按文案找「专业」:选中之后文案会被选中的专业名替换掉,第二轮就找不着了。
|
|
1137
|
+
* 另一个 `.major-input-ui` 是「资格证书」,它所在的 `.filter-2-item` 一直是 `display:none`。
|
|
1138
|
+
*/
|
|
1139
|
+
async function openMajorDialog(frame) {
|
|
1140
|
+
const opened = await evalWithPanelRetry(frame, `(() => {
|
|
1141
|
+
const el = Array.from(document.querySelectorAll(".more-filter-container .major-input-ui"))
|
|
1142
|
+
.find((node) => {
|
|
1143
|
+
const item = node.closest(".filter-2-item");
|
|
1144
|
+
return item && getComputedStyle(item).display !== "none";
|
|
1145
|
+
});
|
|
1146
|
+
if (!(el instanceof HTMLElement)) return { ok: false, reason: "no_root" };
|
|
1147
|
+
el.scrollIntoView({ block: "center", inline: "nearest" });
|
|
1148
|
+
el.click();
|
|
1149
|
+
return { ok: true };
|
|
1150
|
+
})()`);
|
|
1151
|
+
if (!opened.ok) {
|
|
1152
|
+
throw new Error(`未找到「专业」筛选项(.major-input-ui),等了 ${FILTER_PANEL_TIMEOUT_MS / 1000}s。`);
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* ⚠️ 必须带 `visible: true`:页面上有**两个** `.major-dialog`(专业、资格证书),
|
|
1156
|
+
* 没展开的那个是 `display:none` 但仍在 DOM 里,不挑可见的会抓到「资格证书」的输入框。
|
|
1157
|
+
*/
|
|
1158
|
+
const input = await frame.waitForSelector('.major-dialog .major-input input', {
|
|
1159
|
+
timeout: FILTER_PANEL_TIMEOUT_MS,
|
|
1160
|
+
visible: true,
|
|
1161
|
+
});
|
|
1162
|
+
if (!input) {
|
|
1163
|
+
throw new Error('「专业」弹层没出来(.major-dialog .major-input input)。');
|
|
1164
|
+
}
|
|
1165
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
1166
|
+
return input;
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* 关「专业」弹层:`confirm` 为真点「确定」落选中项,否则点「取消」丢弃。
|
|
1170
|
+
* 弹层本来就没开时点不到按钮,对「取消」来说这不是错(收拾残局用得上)。
|
|
1171
|
+
*/
|
|
1172
|
+
async function closeMajorDialog(frame, confirm) {
|
|
1173
|
+
const wantText = confirm ? '确定' : '取消';
|
|
1174
|
+
const ok = (await frame.evaluate(`(() => {
|
|
1175
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, "").trim();
|
|
1176
|
+
const btns = Array.from(${VISIBLE_MAJOR_DIALOG}?.querySelectorAll(".major-footer-btns *") ?? []);
|
|
1177
|
+
const hit = btns.find((el) => norm(el.textContent) === ${JSON.stringify(wantText)});
|
|
1178
|
+
if (!(hit instanceof HTMLElement)) return false;
|
|
1179
|
+
hit.click();
|
|
1180
|
+
return true;
|
|
1181
|
+
})()`));
|
|
1182
|
+
if (!ok && confirm) {
|
|
1183
|
+
throw new Error('「专业」弹层里没找到「确定」按钮,选中的专业可能没生效。');
|
|
1184
|
+
}
|
|
1185
|
+
if (ok) {
|
|
1186
|
+
await sleepRandom(FILTER_SETTLE_MS.min, FILTER_SETTLE_MS.max);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
174
1189
|
export async function openNormalSearchResumePreview(frame, target) {
|
|
175
1190
|
const targetLiteral = JSON.stringify(target.trim());
|
|
176
|
-
const opened = (await frame.evaluate(`(() => {
|
|
177
|
-
const raw = ${targetLiteral};
|
|
178
|
-
const bare = raw.replace(/\\*/g, "");
|
|
179
|
-
const norm = (v) => (v ?? "").replace(/\\s+/g, " ").trim();
|
|
180
|
-
const cards = Array.from(document.querySelectorAll(".geek-info-card"));
|
|
181
|
-
if (cards.length === 0) return false;
|
|
182
|
-
const targetCard =
|
|
183
|
-
cards.find((item) => {
|
|
184
|
-
const name = norm(item.querySelector(".name-label")?.textContent);
|
|
185
|
-
return name === raw || (!!bare && name.includes(bare));
|
|
186
|
-
}) ?? null;
|
|
187
|
-
if (!(targetCard instanceof HTMLElement)) return false;
|
|
188
|
-
|
|
189
|
-
function tryOpen(el) {
|
|
190
|
-
if (!(el instanceof HTMLElement)) return false;
|
|
191
|
-
const st = window.getComputedStyle(el);
|
|
192
|
-
if (st.pointerEvents === "none" || Number(st.opacity) < 0.3) return false;
|
|
193
|
-
el.scrollIntoView({ block: "center", inline: "nearest" });
|
|
194
|
-
el.click();
|
|
195
|
-
return true;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (tryOpen(targetCard.querySelector(".name-label"))) return true;
|
|
199
|
-
if (tryOpen(targetCard.querySelector(".info-detail"))) return true;
|
|
200
|
-
if (tryOpen(targetCard.querySelector(".geek-info-main, .geek-card-main, .card-content"))) return true;
|
|
201
|
-
if (tryOpen(targetCard.querySelector("a"))) return true;
|
|
202
|
-
return tryOpen(targetCard);
|
|
1191
|
+
const opened = (await frame.evaluate(`(() => {
|
|
1192
|
+
const raw = ${targetLiteral};
|
|
1193
|
+
const bare = raw.replace(/\\*/g, "");
|
|
1194
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, " ").trim();
|
|
1195
|
+
const cards = Array.from(document.querySelectorAll(".geek-info-card"));
|
|
1196
|
+
if (cards.length === 0) return false;
|
|
1197
|
+
const targetCard =
|
|
1198
|
+
cards.find((item) => {
|
|
1199
|
+
const name = norm(item.querySelector(".name-label")?.textContent);
|
|
1200
|
+
return name === raw || (!!bare && name.includes(bare));
|
|
1201
|
+
}) ?? null;
|
|
1202
|
+
if (!(targetCard instanceof HTMLElement)) return false;
|
|
1203
|
+
|
|
1204
|
+
function tryOpen(el) {
|
|
1205
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
1206
|
+
const st = window.getComputedStyle(el);
|
|
1207
|
+
if (st.pointerEvents === "none" || Number(st.opacity) < 0.3) return false;
|
|
1208
|
+
el.scrollIntoView({ block: "center", inline: "nearest" });
|
|
1209
|
+
el.click();
|
|
1210
|
+
return true;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (tryOpen(targetCard.querySelector(".name-label"))) return true;
|
|
1214
|
+
if (tryOpen(targetCard.querySelector(".info-detail"))) return true;
|
|
1215
|
+
if (tryOpen(targetCard.querySelector(".geek-info-main, .geek-card-main, .card-content"))) return true;
|
|
1216
|
+
if (tryOpen(targetCard.querySelector("a"))) return true;
|
|
1217
|
+
return tryOpen(targetCard);
|
|
203
1218
|
})()`));
|
|
204
1219
|
if (opened) {
|
|
205
1220
|
await sleepRandom(RESUME_PREVIEW_OPEN_GAP_MS.min, RESUME_PREVIEW_OPEN_GAP_MS.max);
|
|
@@ -216,88 +1231,99 @@ export async function openNormalSearchResumePreview(frame, target) {
|
|
|
216
1231
|
async function clickSearchPoolContactButton(frame, locator) {
|
|
217
1232
|
const indexLiteral = JSON.stringify(locator.index ?? null);
|
|
218
1233
|
const targetLiteral = JSON.stringify((locator.target ?? '').trim());
|
|
219
|
-
return (await frame.evaluate(`(() => {
|
|
220
|
-
const wantIndex = ${indexLiteral};
|
|
221
|
-
const raw = ${targetLiteral};
|
|
222
|
-
const norm = (v) => (v ?? "").replace(/\\s+/g, " ").trim();
|
|
223
|
-
const loose = (v) => norm(v).replace(/[**\\s]/g, "").toLowerCase();
|
|
224
|
-
const cards = Array.from(document.querySelectorAll(".geek-info-card"));
|
|
225
|
-
if (cards.length === 0) return { kind: "empty" };
|
|
226
|
-
|
|
227
|
-
const describe = (card, i) =>
|
|
228
|
-
(i + 1) + ". " + norm(card.querySelector(".name-label")?.textContent);
|
|
229
|
-
|
|
230
|
-
let picked = null;
|
|
231
|
-
let pickedIndex = -1;
|
|
232
|
-
if (wantIndex !== null) {
|
|
233
|
-
if (wantIndex < 1 || wantIndex > cards.length) {
|
|
234
|
-
return { kind: "not_found", hint: cards.map(describe) };
|
|
235
|
-
}
|
|
236
|
-
picked = cards[wantIndex - 1];
|
|
237
|
-
pickedIndex = wantIndex - 1;
|
|
238
|
-
} else {
|
|
239
|
-
const key = loose(raw);
|
|
240
|
-
const hits = [];
|
|
241
|
-
cards.forEach((card, i) => {
|
|
242
|
-
const name = loose(card.querySelector(".name-label")?.textContent);
|
|
243
|
-
const summary = loose(card.querySelector(".info-detail")?.textContent);
|
|
244
|
-
if ((key && name.includes(key)) || (key && summary.includes(key))) {
|
|
245
|
-
hits.push({ card, i });
|
|
246
|
-
}
|
|
247
|
-
});
|
|
248
|
-
if (hits.length === 0) return { kind: "not_found", hint: cards.map(describe) };
|
|
249
|
-
if (hits.length > 1) return { kind: "ambiguous", hits: hits.map((h) => describe(h.card, h.i)) };
|
|
250
|
-
picked = hits[0].card;
|
|
251
|
-
pickedIndex = hits[0].i;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const name = norm(picked.querySelector(".name-label")?.textContent);
|
|
255
|
-
const summary = norm(picked.querySelector(".info-detail")?.textContent).slice(0, 80);
|
|
256
|
-
const btn = picked.querySelector("button.btn-getcontact, .btn-getcontact");
|
|
257
|
-
if (!(btn instanceof HTMLElement)) return { kind: "no_btn", name };
|
|
258
|
-
const text = norm(btn.textContent);
|
|
259
|
-
const cls = btn.className || "";
|
|
260
|
-
if (/disabled|forbid|ban/i.test(cls) || btn.getAttribute("disabled") !== null) {
|
|
261
|
-
return { kind: "disabled", name, text };
|
|
262
|
-
}
|
|
263
|
-
btn.scrollIntoView({ block: "center", inline: "nearest" });
|
|
264
|
-
btn.click();
|
|
265
|
-
return { kind: "clicked", index: pickedIndex + 1, name, text, summary };
|
|
1234
|
+
return (await frame.evaluate(`(() => {
|
|
1235
|
+
const wantIndex = ${indexLiteral};
|
|
1236
|
+
const raw = ${targetLiteral};
|
|
1237
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, " ").trim();
|
|
1238
|
+
const loose = (v) => norm(v).replace(/[**\\s]/g, "").toLowerCase();
|
|
1239
|
+
const cards = Array.from(document.querySelectorAll(".geek-info-card"));
|
|
1240
|
+
if (cards.length === 0) return { kind: "empty" };
|
|
1241
|
+
|
|
1242
|
+
const describe = (card, i) =>
|
|
1243
|
+
(i + 1) + ". " + norm(card.querySelector(".name-label")?.textContent);
|
|
1244
|
+
|
|
1245
|
+
let picked = null;
|
|
1246
|
+
let pickedIndex = -1;
|
|
1247
|
+
if (wantIndex !== null) {
|
|
1248
|
+
if (wantIndex < 1 || wantIndex > cards.length) {
|
|
1249
|
+
return { kind: "not_found", hint: cards.map(describe) };
|
|
1250
|
+
}
|
|
1251
|
+
picked = cards[wantIndex - 1];
|
|
1252
|
+
pickedIndex = wantIndex - 1;
|
|
1253
|
+
} else {
|
|
1254
|
+
const key = loose(raw);
|
|
1255
|
+
const hits = [];
|
|
1256
|
+
cards.forEach((card, i) => {
|
|
1257
|
+
const name = loose(card.querySelector(".name-label")?.textContent);
|
|
1258
|
+
const summary = loose(card.querySelector(".info-detail")?.textContent);
|
|
1259
|
+
if ((key && name.includes(key)) || (key && summary.includes(key))) {
|
|
1260
|
+
hits.push({ card, i });
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
if (hits.length === 0) return { kind: "not_found", hint: cards.map(describe) };
|
|
1264
|
+
if (hits.length > 1) return { kind: "ambiguous", hits: hits.map((h) => describe(h.card, h.i)) };
|
|
1265
|
+
picked = hits[0].card;
|
|
1266
|
+
pickedIndex = hits[0].i;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const name = norm(picked.querySelector(".name-label")?.textContent);
|
|
1270
|
+
const summary = norm(picked.querySelector(".info-detail")?.textContent).slice(0, 80);
|
|
1271
|
+
const btn = picked.querySelector("button.btn-getcontact, .btn-getcontact");
|
|
1272
|
+
if (!(btn instanceof HTMLElement)) return { kind: "no_btn", name };
|
|
1273
|
+
const text = norm(btn.textContent);
|
|
1274
|
+
const cls = btn.className || "";
|
|
1275
|
+
if (/disabled|forbid|ban/i.test(cls) || btn.getAttribute("disabled") !== null) {
|
|
1276
|
+
return { kind: "disabled", name, text };
|
|
1277
|
+
}
|
|
1278
|
+
btn.scrollIntoView({ block: "center", inline: "nearest" });
|
|
1279
|
+
btn.click();
|
|
1280
|
+
return { kind: "clicked", index: pickedIndex + 1, name, text, summary };
|
|
266
1281
|
})()`));
|
|
267
1282
|
}
|
|
268
1283
|
async function readNormalSearchCandidates(frame) {
|
|
269
|
-
return (await frame.evaluate(`(() => {
|
|
270
|
-
const norm = (v) => (v ?? "").replace(/\\s+/g, " ").trim();
|
|
271
|
-
const unique = (items) => Array.from(new Set(items.map(norm).filter(Boolean)));
|
|
272
|
-
return Array.from(document.querySelectorAll(".geek-info-card")).map((card) => {
|
|
273
|
-
const labels = unique(Array.from(card.querySelectorAll(".card-label")).map((el) => el.textContent));
|
|
274
|
-
const tags = unique(
|
|
275
|
-
Array.from(card.querySelectorAll(".info-tags:not(.info-tags-measure) .info-tags-item"))
|
|
276
|
-
.map((el) => el.textContent),
|
|
277
|
-
);
|
|
278
|
-
const work = Array.from(card.querySelectorAll(".work-exp-box .work-exp-item"))
|
|
279
|
-
.map((el) => norm(el.textContent))
|
|
280
|
-
.filter(Boolean);
|
|
281
|
-
return {
|
|
282
|
-
name: norm(card.querySelector(".name-label")?.textContent),
|
|
283
|
-
active: norm(card.querySelector(".active-desc-text")?.textContent),
|
|
284
|
-
labels,
|
|
285
|
-
basicInfo: norm(card.querySelector(".info-labels")?.textContent),
|
|
286
|
-
summary: norm(card.querySelector(".info-detail")?.textContent),
|
|
287
|
-
tags,
|
|
288
|
-
expectation: norm(card.querySelector(".expect-exp-box")?.textContent),
|
|
289
|
-
work,
|
|
290
|
-
education: norm(card.querySelector(".edu-exp-box")?.textContent),
|
|
291
|
-
reason: norm(card.querySelector(".recommend-reason")?.textContent),
|
|
292
|
-
contactText: norm(card.querySelector(".btn-getcontact")?.textContent),
|
|
293
|
-
};
|
|
294
|
-
}).filter((item) => item.name);
|
|
1284
|
+
return (await frame.evaluate(`(() => {
|
|
1285
|
+
const norm = (v) => (v ?? "").replace(/\\s+/g, " ").trim();
|
|
1286
|
+
const unique = (items) => Array.from(new Set(items.map(norm).filter(Boolean)));
|
|
1287
|
+
return Array.from(document.querySelectorAll(".geek-info-card")).map((card) => {
|
|
1288
|
+
const labels = unique(Array.from(card.querySelectorAll(".card-label")).map((el) => el.textContent));
|
|
1289
|
+
const tags = unique(
|
|
1290
|
+
Array.from(card.querySelectorAll(".info-tags:not(.info-tags-measure) .info-tags-item"))
|
|
1291
|
+
.map((el) => el.textContent),
|
|
1292
|
+
);
|
|
1293
|
+
const work = Array.from(card.querySelectorAll(".work-exp-box .work-exp-item"))
|
|
1294
|
+
.map((el) => norm(el.textContent))
|
|
1295
|
+
.filter(Boolean);
|
|
1296
|
+
return {
|
|
1297
|
+
name: norm(card.querySelector(".name-label")?.textContent),
|
|
1298
|
+
active: norm(card.querySelector(".active-desc-text")?.textContent),
|
|
1299
|
+
labels,
|
|
1300
|
+
basicInfo: norm(card.querySelector(".info-labels")?.textContent),
|
|
1301
|
+
summary: norm(card.querySelector(".info-detail")?.textContent),
|
|
1302
|
+
tags,
|
|
1303
|
+
expectation: norm(card.querySelector(".expect-exp-box")?.textContent),
|
|
1304
|
+
work,
|
|
1305
|
+
education: norm(card.querySelector(".edu-exp-box")?.textContent),
|
|
1306
|
+
reason: norm(card.querySelector(".recommend-reason")?.textContent),
|
|
1307
|
+
contactText: norm(card.querySelector(".btn-getcontact")?.textContent),
|
|
1308
|
+
};
|
|
1309
|
+
}).filter((item) => item.name);
|
|
295
1310
|
})()`));
|
|
296
1311
|
}
|
|
297
1312
|
function renderNormalSearchCandidates(candidates, meta) {
|
|
298
1313
|
const titleKeyword = meta.keyword ? `关键词:${meta.keyword}` : '关键词:默认/热门词';
|
|
1314
|
+
// 生效的筛选条件要回显:否则「怎么只有 3 个人」得靠猜是不是自己上次设的条件还挂着。
|
|
1315
|
+
// meta 里的每一项都来自**页面实时状态**,不是入参——打入参的话粘在平台上的旧条件不会显示。
|
|
1316
|
+
const conds = [
|
|
1317
|
+
meta.job ? `当前岗位:${meta.job}` : '',
|
|
1318
|
+
meta.city ? `城市:${meta.city}` : '',
|
|
1319
|
+
meta.degree ? `学历:${meta.degree}` : '',
|
|
1320
|
+
(meta.schools ?? []).length > 0 ? `院校:${(meta.schools ?? []).join('+')}` : '',
|
|
1321
|
+
meta.exp ? `经验:${meta.exp}` : '',
|
|
1322
|
+
meta.age ? `年龄:${meta.age}` : '',
|
|
1323
|
+
...(meta.others ?? []),
|
|
1324
|
+
].filter(Boolean);
|
|
299
1325
|
const lines = [
|
|
300
|
-
`常规搜索结果(${titleKeyword}${
|
|
1326
|
+
`常规搜索结果(${titleKeyword}${conds.length > 0 ? `;${conds.join(';')}` : ''})`,
|
|
301
1327
|
`共 ${candidates.length} 人`,
|
|
302
1328
|
];
|
|
303
1329
|
if (candidates.length === 0) {
|
|
@@ -405,29 +1431,114 @@ export async function runSearchPoolGreet(options) {
|
|
|
405
1431
|
ensureMenuList: false,
|
|
406
1432
|
});
|
|
407
1433
|
}
|
|
408
|
-
export async function runNormalSearch(
|
|
409
|
-
const kw = (keyword ?? '').trim();
|
|
410
|
-
const jobKw = (jobKeyword ?? '').trim();
|
|
1434
|
+
export async function runNormalSearch(opts = {}) {
|
|
1435
|
+
const kw = (opts.keyword ?? '').trim();
|
|
1436
|
+
const jobKw = (opts.jobKeyword ?? '').trim();
|
|
1437
|
+
const city = (opts.city ?? defaultSearchCityFromEnv()).trim();
|
|
1438
|
+
const degree = (opts.degree ?? '').trim();
|
|
1439
|
+
const degreeRange = (opts.degreeRange ?? '').trim();
|
|
1440
|
+
const schools = (opts.schools ?? []).map((s) => s.trim()).filter(Boolean);
|
|
1441
|
+
const exp = (opts.exp ?? '').trim();
|
|
1442
|
+
const expRange = (opts.expRange ?? '').trim();
|
|
1443
|
+
const age = (opts.age ?? '').trim();
|
|
1444
|
+
const ageRange = (opts.ageRange ?? '').trim();
|
|
1445
|
+
if (degree && degreeRange) {
|
|
1446
|
+
throw new Error('--degree 和 --degree-range 只能给一个:预设档位和自定义区间在页面上是互斥的。');
|
|
1447
|
+
}
|
|
1448
|
+
if (exp && expRange) {
|
|
1449
|
+
throw new Error('--exp 和 --exp-range 只能给一个:预设档位和自定义区间在页面上是互斥的。');
|
|
1450
|
+
}
|
|
1451
|
+
if (age && ageRange) {
|
|
1452
|
+
throw new Error('--age 和 --age-range 只能给一个:预设档位和自定义区间在页面上是互斥的。');
|
|
1453
|
+
}
|
|
1454
|
+
const status = (opts.status ?? []).map((s) => s.trim()).filter(Boolean);
|
|
1455
|
+
const jobHop = (opts.jobHop ?? '').trim();
|
|
1456
|
+
const majors = (opts.majors ?? []).map((s) => s.trim()).filter(Boolean);
|
|
411
1457
|
if (kw.length > 20) {
|
|
412
1458
|
throw new Error('常规搜索关键词最多 20 个字符。');
|
|
413
1459
|
}
|
|
414
1460
|
try {
|
|
415
1461
|
return await withBossSessionPage(async (page) => {
|
|
416
1462
|
const frame = await ensureInNormalSearchPage(page);
|
|
1463
|
+
/**
|
|
1464
|
+
* 岗位:给了关键字就按关键字选,**没给就切「不限职位」**。
|
|
1465
|
+
* 以前没给是「保持上次的岗位」——跨命令沿用上一次的状态,结果不可预测,
|
|
1466
|
+
* 同一条命令跑两次可能搜的是两个池子。
|
|
1467
|
+
*/
|
|
417
1468
|
if (jobKw) {
|
|
418
1469
|
await selectNormalSearchJob(frame, jobKw);
|
|
419
1470
|
}
|
|
1471
|
+
else {
|
|
1472
|
+
await selectNormalSearchAnyJob(frame);
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* 清空上一轮残留的筛选条件,**再**按本次参数重设。
|
|
1476
|
+
* 不清的话「这次怎么只有 3 个人」永远得靠猜是不是上次的条件还挂着。
|
|
1477
|
+
* 必须在设条件之前——它会把学历 / 院校 / 其他筛选一起归零。
|
|
1478
|
+
*/
|
|
1479
|
+
await resetNormalSearchFilters(frame);
|
|
1480
|
+
// 顺序固定:岗位 → 清空 → 城市 → 学历 → 院校 → 其他筛选 → 关键词。
|
|
1481
|
+
// 筛选条件都在关键词之前落定,否则先搜后筛会多打一次列表请求。
|
|
1482
|
+
if (city) {
|
|
1483
|
+
await selectNormalSearchCity(frame, city);
|
|
1484
|
+
}
|
|
1485
|
+
if (degree) {
|
|
1486
|
+
await selectNormalSearchDegree(frame, degree);
|
|
1487
|
+
}
|
|
1488
|
+
if (degreeRange) {
|
|
1489
|
+
const { min, max } = parseRangeArg(degreeRange);
|
|
1490
|
+
await selectNormalSearchDegreeRange(page, frame, min, max);
|
|
1491
|
+
}
|
|
1492
|
+
if (schools.length > 0) {
|
|
1493
|
+
await selectNormalSearchSchools(frame, schools);
|
|
1494
|
+
}
|
|
1495
|
+
if (exp) {
|
|
1496
|
+
await selectNormalSearchExp(frame, exp);
|
|
1497
|
+
}
|
|
1498
|
+
if (expRange) {
|
|
1499
|
+
const { min, max } = parseRangeArg(expRange);
|
|
1500
|
+
await selectNormalSearchExpRange(page, frame, min, max);
|
|
1501
|
+
}
|
|
1502
|
+
if (age) {
|
|
1503
|
+
await selectNormalSearchAge(frame, age);
|
|
1504
|
+
}
|
|
1505
|
+
if (ageRange) {
|
|
1506
|
+
const { min, max } = parseRangeArg(ageRange);
|
|
1507
|
+
await selectNormalSearchAgeRange(frame, min, max);
|
|
1508
|
+
}
|
|
1509
|
+
if (status.length > 0) {
|
|
1510
|
+
await selectNormalSearchStatus(frame, status);
|
|
1511
|
+
}
|
|
1512
|
+
if (jobHop) {
|
|
1513
|
+
await selectNormalSearchJobHop(frame, jobHop);
|
|
1514
|
+
}
|
|
1515
|
+
if (majors.length > 0) {
|
|
1516
|
+
await selectNormalSearchMajors(frame, majors);
|
|
1517
|
+
}
|
|
420
1518
|
if (kw) {
|
|
421
1519
|
await runKeywordSearch(frame, kw);
|
|
422
1520
|
}
|
|
423
|
-
|
|
1521
|
+
// 回显一律读页面实时状态,不打入参——见 readSelectedDegree 的注释。
|
|
1522
|
+
const [currentKeyword, currentJob, currentCity, currentDegree, currentSchools, currentExp, currentAge, others, candidates,] = await Promise.all([
|
|
424
1523
|
readNormalSearchKeyword(frame),
|
|
425
1524
|
readCurrentSearchJob(frame),
|
|
1525
|
+
readSelectedCity(frame),
|
|
1526
|
+
readSelectedDegree(frame),
|
|
1527
|
+
readSelectedSchools(frame),
|
|
1528
|
+
readSelectedExp(frame),
|
|
1529
|
+
readSelectedAge(frame),
|
|
1530
|
+
readOtherFilters(frame),
|
|
426
1531
|
readNormalSearchCandidates(frame),
|
|
427
1532
|
]);
|
|
428
1533
|
return renderNormalSearchCandidates(candidates, {
|
|
429
1534
|
keyword: currentKeyword || kw,
|
|
430
1535
|
job: currentJob,
|
|
1536
|
+
city: currentCity,
|
|
1537
|
+
degree: currentDegree,
|
|
1538
|
+
schools: currentSchools,
|
|
1539
|
+
exp: currentExp,
|
|
1540
|
+
age: currentAge,
|
|
1541
|
+
others,
|
|
431
1542
|
});
|
|
432
1543
|
});
|
|
433
1544
|
}
|