@reconcrap/boss-recommend-mcp 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,41 +1,41 @@
1
- #!/usr/bin/env node
2
- import process from "node:process";
3
- import readline from "node:readline";
4
- import { pathToFileURL } from "node:url";
5
- import CDP from "chrome-remote-interface";
6
-
7
- const DEFAULT_PORT = 9222;
8
- const RECOMMEND_URL_FRAGMENT = "/web/chat/recommend";
9
- const BOSS_LOGIN_URL = "https://www.zhipin.com/web/user/?ka=bticket";
10
- const BOSS_LOGIN_URL_PATTERN = /(?:zhipin\.com\/web\/user(?:\/|\?|$)|passport\.zhipin\.com)/i;
11
- const BOSS_LOGIN_TITLE_PATTERN = /登录|signin|扫码登录|BOSS直聘登录/i;
12
- const SCHOOL_TAG_OPTIONS = ["不限", "985", "211", "双一流院校", "留学", "国内外名校", "公办本科"];
13
- const DEGREE_OPTIONS = ["不限", "初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"];
14
- const DEGREE_ORDER = ["初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"];
15
- const GENDER_OPTIONS = ["不限", "男", "女"];
16
- const RECENT_NOT_VIEW_OPTIONS = ["不限", "近14天没有"];
17
-
18
- function normalizeText(value) {
19
- return String(value || "").replace(/\s+/g, " ").trim();
20
- }
21
-
22
- function normalizeJobTitle(value) {
23
- const text = normalizeText(value);
24
- if (!text) return "";
25
- const byGap = text.split(/\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
26
- const strippedRange = byGap
27
- .replace(/\s+\d+(?:\.\d+)?\s*(?:-|~|—|至)\s*\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)?$/u, "")
28
- .trim();
29
- const strippedSingle = strippedRange
30
- .replace(/\s+\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)$/u, "")
31
- .trim();
32
- return strippedSingle || byGap;
33
- }
34
-
35
- function parsePositiveInteger(raw) {
36
- const value = Number.parseInt(String(raw || ""), 10);
37
- return Number.isFinite(value) && value > 0 ? value : null;
38
- }
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import readline from "node:readline";
4
+ import { pathToFileURL } from "node:url";
5
+ import CDP from "chrome-remote-interface";
6
+
7
+ const DEFAULT_PORT = 9222;
8
+ const RECOMMEND_URL_FRAGMENT = "/web/chat/recommend";
9
+ const BOSS_LOGIN_URL = "https://www.zhipin.com/web/user/?ka=bticket";
10
+ const BOSS_LOGIN_URL_PATTERN = /(?:zhipin\.com\/web\/user(?:\/|\?|$)|passport\.zhipin\.com)/i;
11
+ const BOSS_LOGIN_TITLE_PATTERN = /登录|signin|扫码登录|BOSS直聘登录/i;
12
+ const SCHOOL_TAG_OPTIONS = ["不限", "985", "211", "双一流院校", "留学", "国内外名校", "公办本科"];
13
+ const DEGREE_OPTIONS = ["不限", "初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"];
14
+ const DEGREE_ORDER = ["初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"];
15
+ const GENDER_OPTIONS = ["不限", "男", "女"];
16
+ const RECENT_NOT_VIEW_OPTIONS = ["不限", "近14天没有"];
17
+
18
+ function normalizeText(value) {
19
+ return String(value || "").replace(/\s+/g, " ").trim();
20
+ }
21
+
22
+ function normalizeJobTitle(value) {
23
+ const text = normalizeText(value);
24
+ if (!text) return "";
25
+ const byGap = text.split(/\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
26
+ const strippedRange = byGap
27
+ .replace(/\s+\d+(?:\.\d+)?\s*(?:-|~|—|至)\s*\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)?$/u, "")
28
+ .trim();
29
+ const strippedSingle = strippedRange
30
+ .replace(/\s+\d+(?:\.\d+)?\s*(?:k|K|千|万|元\/天|元\/月|元\/年|K\/月|k\/月|万\/月|万\/年)$/u, "")
31
+ .trim();
32
+ return strippedSingle || byGap;
33
+ }
34
+
35
+ function parsePositiveInteger(raw) {
36
+ const value = Number.parseInt(String(raw || ""), 10);
37
+ return Number.isFinite(value) && value > 0 ? value : null;
38
+ }
39
39
 
40
40
  function sortSchoolSelection(values) {
41
41
  const order = new Map(SCHOOL_TAG_OPTIONS.map((label, index) => [label, index]));
@@ -79,25 +79,25 @@ function normalizeDegree(value) {
79
79
  return DEGREE_OPTIONS.includes(normalized) ? normalized : null;
80
80
  }
81
81
 
82
- function sortDegreeSelection(values) {
83
- return Array.from(new Set(values.filter(Boolean))).sort((left, right) => DEGREE_ORDER.indexOf(left) - DEGREE_ORDER.indexOf(right));
84
- }
85
-
86
- function selectionEquals(left, right) {
87
- if (!Array.isArray(left) || !Array.isArray(right)) return false;
88
- if (left.length !== right.length) return false;
89
- return left.every((value, index) => value === right[index]);
90
- }
91
-
92
- function uniqueNormalizedLabels(values) {
93
- return Array.from(
94
- new Set(
95
- (values || [])
96
- .map((item) => normalizeText(item))
97
- .filter(Boolean)
98
- )
99
- );
100
- }
82
+ function sortDegreeSelection(values) {
83
+ return Array.from(new Set(values.filter(Boolean))).sort((left, right) => DEGREE_ORDER.indexOf(left) - DEGREE_ORDER.indexOf(right));
84
+ }
85
+
86
+ function selectionEquals(left, right) {
87
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
88
+ if (left.length !== right.length) return false;
89
+ return left.every((value, index) => value === right[index]);
90
+ }
91
+
92
+ function uniqueNormalizedLabels(values) {
93
+ return Array.from(
94
+ new Set(
95
+ (values || [])
96
+ .map((item) => normalizeText(item))
97
+ .filter(Boolean)
98
+ )
99
+ );
100
+ }
101
101
 
102
102
  function expandDegreeAtOrAbove(value) {
103
103
  const normalized = normalizeDegree(value);
@@ -107,7 +107,7 @@ function expandDegreeAtOrAbove(value) {
107
107
  return DEGREE_ORDER.slice(index);
108
108
  }
109
109
 
110
- function parseDegreeSelection(raw) {
110
+ function parseDegreeSelection(raw) {
111
111
  const text = normalizeText(raw);
112
112
  if (!text) return null;
113
113
  if (text === "不限") return ["不限"];
@@ -132,29 +132,29 @@ function parseDegreeSelection(raw) {
132
132
  }
133
133
  }
134
134
 
135
- const normalized = sortDegreeSelection(selected);
136
- return normalized.length ? normalized : null;
137
- }
135
+ const normalized = sortDegreeSelection(selected);
136
+ return normalized.length ? normalized : null;
137
+ }
138
138
 
139
139
  function parseArgs(argv) {
140
140
  const args = {
141
141
  schoolTag: ["不限"],
142
142
  degree: ["不限"],
143
143
  gender: "不限",
144
- recentNotView: "不限",
145
- port: DEFAULT_PORT,
146
- listJobs: false,
147
- job: null,
148
- help: false,
149
- __provided: {
150
- schoolTag: false,
151
- degree: false,
152
- gender: false,
153
- recentNotView: false,
154
- port: false,
155
- job: false
156
- }
157
- };
144
+ recentNotView: "不限",
145
+ port: DEFAULT_PORT,
146
+ listJobs: false,
147
+ job: null,
148
+ help: false,
149
+ __provided: {
150
+ schoolTag: false,
151
+ degree: false,
152
+ gender: false,
153
+ recentNotView: false,
154
+ port: false,
155
+ job: false
156
+ }
157
+ };
158
158
 
159
159
  for (let index = 0; index < argv.length; index += 1) {
160
160
  const token = argv[index];
@@ -175,20 +175,20 @@ function parseArgs(argv) {
175
175
  args.recentNotView = next;
176
176
  args.__provided.recentNotView = true;
177
177
  index += 1;
178
- } else if (token === "--port" && next) {
179
- args.port = parsePositiveInteger(next) || DEFAULT_PORT;
180
- args.__provided.port = true;
181
- index += 1;
182
- } else if (token === "--job" && next) {
183
- args.job = normalizeText(next) || null;
184
- args.__provided.job = true;
185
- index += 1;
186
- } else if (token === "--list-jobs") {
187
- args.listJobs = true;
188
- } else if (token === "--help" || token === "-h") {
189
- args.help = true;
190
- }
191
- }
178
+ } else if (token === "--port" && next) {
179
+ args.port = parsePositiveInteger(next) || DEFAULT_PORT;
180
+ args.__provided.port = true;
181
+ index += 1;
182
+ } else if (token === "--job" && next) {
183
+ args.job = normalizeText(next) || null;
184
+ args.__provided.job = true;
185
+ index += 1;
186
+ } else if (token === "--list-jobs") {
187
+ args.listJobs = true;
188
+ } else if (token === "--help" || token === "-h") {
189
+ args.help = true;
190
+ }
191
+ }
192
192
 
193
193
  return args;
194
194
  }
@@ -207,12 +207,12 @@ async function promptValue(ask, question, validate, defaultValue) {
207
207
  }
208
208
  }
209
209
 
210
- async function enrichArgsFromPrompt(args) {
211
- if (!isInteractiveTTY() || args.help) return args;
212
- if (args.listJobs) return args;
213
- const askTargets =
214
- Object.values(args.__provided || {}).some((item) => item === false)
215
- || !Array.isArray(args.schoolTag)
210
+ async function enrichArgsFromPrompt(args) {
211
+ if (!isInteractiveTTY() || args.help) return args;
212
+ if (args.listJobs) return args;
213
+ const askTargets =
214
+ Object.values(args.__provided || {}).some((item) => item === false)
215
+ || !Array.isArray(args.schoolTag)
216
216
  || args.schoolTag.length === 0
217
217
  || !Array.isArray(args.degree)
218
218
  || args.degree.length === 0;
@@ -296,14 +296,14 @@ function generateBezierPath(start, end, steps = 18) {
296
296
  return path;
297
297
  }
298
298
 
299
- class RecommendSearchCli {
300
- constructor(args) {
301
- this.args = args;
302
- this.client = null;
303
- this.Runtime = null;
304
- this.Input = null;
305
- this.target = null;
306
- }
299
+ class RecommendSearchCli {
300
+ constructor(args) {
301
+ this.args = args;
302
+ this.client = null;
303
+ this.Runtime = null;
304
+ this.Input = null;
305
+ this.target = null;
306
+ }
307
307
 
308
308
  async connect() {
309
309
  const targets = await CDP.List({ port: this.args.port });
@@ -386,42 +386,42 @@ class RecommendSearchCli {
386
386
  });
387
387
  }
388
388
 
389
- async getFrameState() {
390
- return this.evaluate(`(() => {
391
- const currentUrl = (() => {
392
- try { return String(window.location.href || ''); } catch { return ''; }
393
- })();
394
- const title = (() => {
395
- try { return String(document.title || ''); } catch { return ''; }
396
- })();
397
- const isLogin = ${BOSS_LOGIN_URL_PATTERN}.test(currentUrl)
398
- || ${BOSS_LOGIN_TITLE_PATTERN}.test(title);
399
- if (isLogin) {
400
- return {
401
- ok: false,
402
- error: 'LOGIN_REQUIRED',
403
- currentUrl: currentUrl || ${JSON.stringify(BOSS_LOGIN_URL)},
404
- title
405
- };
406
- }
407
- const frame = document.querySelector('iframe[name="recommendFrame"]')
408
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
409
- || document.querySelector('iframe');
410
- if (!frame || !frame.contentDocument) {
411
- return { ok: false, error: 'NO_RECOMMEND_IFRAME', currentUrl, title };
412
- }
413
- return {
414
- ok: true,
415
- currentUrl,
416
- title,
417
- frameUrl: (() => {
418
- try { return String(frame.contentWindow.location.href || ''); } catch { return ''; }
419
- })()
420
- };
421
- })()`);
389
+ async getFrameState() {
390
+ return this.evaluate(`(() => {
391
+ const currentUrl = (() => {
392
+ try { return String(window.location.href || ''); } catch { return ''; }
393
+ })();
394
+ const title = (() => {
395
+ try { return String(document.title || ''); } catch { return ''; }
396
+ })();
397
+ const isLogin = ${BOSS_LOGIN_URL_PATTERN}.test(currentUrl)
398
+ || ${BOSS_LOGIN_TITLE_PATTERN}.test(title);
399
+ if (isLogin) {
400
+ return {
401
+ ok: false,
402
+ error: 'LOGIN_REQUIRED',
403
+ currentUrl: currentUrl || ${JSON.stringify(BOSS_LOGIN_URL)},
404
+ title
405
+ };
406
+ }
407
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
408
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
409
+ || document.querySelector('iframe');
410
+ if (!frame || !frame.contentDocument) {
411
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME', currentUrl, title };
412
+ }
413
+ return {
414
+ ok: true,
415
+ currentUrl,
416
+ title,
417
+ frameUrl: (() => {
418
+ try { return String(frame.contentWindow.location.href || ''); } catch { return ''; }
419
+ })()
420
+ };
421
+ })()`);
422
422
  }
423
423
 
424
- async getFilterEntryPoint() {
424
+ async getFilterEntryPoint() {
425
425
  return this.evaluate(`(() => {
426
426
  const frame = document.querySelector('iframe[name="recommendFrame"]')
427
427
  || document.querySelector('iframe[src*="/web/frame/recommend/"]')
@@ -442,248 +442,248 @@ class RecommendSearchCli {
442
442
  y: frameRect.top + rect.top + rect.height / 2
443
443
  };
444
444
  })()`);
445
- }
446
-
447
- async getJobListState() {
448
- return this.evaluate(`(() => {
449
- const frame = document.querySelector('iframe[name="recommendFrame"]')
450
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
451
- || document.querySelector('iframe');
452
- if (!frame || !frame.contentDocument) {
453
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
454
- }
455
- const doc = frame.contentDocument;
456
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
457
- const normalizeTitle = (value) => {
458
- const text = normalize(value);
459
- if (!text) return '';
460
- const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
461
- const strippedRange = byGap
462
- .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
463
- .trim();
464
- const strippedSingle = strippedRange
465
- .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
466
- .trim();
467
- return strippedSingle || byGap;
468
- };
469
- const isVisible = (el) => {
470
- if (!el) return false;
471
- const style = getComputedStyle(el);
472
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
473
- return false;
474
- }
475
- const rect = el.getBoundingClientRect();
476
- return rect.width > 2 && rect.height > 2;
477
- };
478
-
479
- const items = Array.from(doc.querySelectorAll([
480
- '.ui-dropmenu-list .job-list .job-item',
481
- '.job-selecter-options .job-list .job-item',
482
- '.job-selector-options .job-list .job-item',
483
- '.dropmenu-list .job-list .job-item',
484
- '.job-list .job-item'
485
- ].join(',')));
486
- const jobs = [];
487
- const seen = new Set();
488
- for (const item of items) {
489
- const label = normalize(item.querySelector('.label')?.textContent || item.textContent || '');
490
- const title = normalizeTitle(label);
491
- const value = normalize(item.getAttribute('value') || item.dataset?.value || '');
492
- const dedupeKey = value || title || label;
493
- if (!dedupeKey || seen.has(dedupeKey)) continue;
494
- seen.add(dedupeKey);
495
- jobs.push({
496
- value: value || null,
497
- title: title || label || null,
498
- label: label || null,
499
- current: item.classList.contains('curr') || item.classList.contains('active'),
500
- visible: isVisible(item)
501
- });
502
- }
503
-
504
- const selectedLabelNode = doc.querySelector('.chat-job-name, .job-selecter .label, .job-selecter .job-name, .job-select .label');
505
- return {
506
- ok: true,
507
- jobs,
508
- selected_label: normalize(selectedLabelNode ? selectedLabelNode.textContent : ''),
509
- frame_url: (() => {
510
- try { return String(frame.contentWindow.location.href || ''); } catch { return ''; }
511
- })()
512
- };
513
- })()`);
514
- }
515
-
516
- async clickJobDropdownTriggerBySelector() {
517
- return this.evaluate(`(() => {
518
- const frame = document.querySelector('iframe[name="recommendFrame"]')
519
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
520
- || document.querySelector('iframe');
521
- if (!frame || !frame.contentDocument) {
522
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
523
- }
524
- const doc = frame.contentDocument;
525
- const selectors = [
526
- '.chat-job-select',
527
- '.chat-job-selector',
528
- '.job-selecter',
529
- '.job-selector',
530
- '.job-select-wrap',
531
- '.job-select',
532
- '.job-select-box',
533
- '.job-wrap',
534
- '.chat-job-name',
535
- '.top-chat-search'
536
- ];
537
- const isVisible = (el) => {
538
- if (!el) return false;
539
- const style = getComputedStyle(el);
540
- if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
541
- return false;
542
- }
543
- const rect = el.getBoundingClientRect();
544
- return rect.width > 2 && rect.height > 2;
545
- };
546
- for (const selector of selectors) {
547
- const el = doc.querySelector(selector);
548
- if (el && isVisible(el)) {
549
- el.click();
550
- return { ok: true };
551
- }
552
- }
553
- return { ok: false, error: 'JOB_TRIGGER_NOT_FOUND' };
554
- })()`);
555
- }
556
-
557
- async ensureJobListReady() {
558
- let lastError = "JOB_LIST_NOT_FOUND";
559
- for (let attempt = 0; attempt < 4; attempt += 1) {
560
- const state = await this.getJobListState();
561
- if (state?.ok && Array.isArray(state.jobs) && state.jobs.length > 0) {
562
- return state;
563
- }
564
- lastError = state?.error || lastError;
565
- const clickResult = await this.clickJobDropdownTriggerBySelector();
566
- if (!clickResult?.ok) {
567
- lastError = clickResult?.error || lastError;
568
- }
569
- await sleep(220 + attempt * 80);
570
- }
571
- throw new Error(lastError);
572
- }
573
-
574
- findJobMatch(jobList, requestedJobRaw) {
575
- const requested = normalizeText(requestedJobRaw);
576
- if (!requested) return null;
577
- const normalizedRequestedTitle = normalizeJobTitle(requested);
578
- const normalize = (value) => normalizeText(value).toLowerCase();
579
- const byValue = jobList.find((job) => normalize(job.value || "") === normalize(requested));
580
- if (byValue) return byValue;
581
- const exactTitle = jobList.find((job) => normalize(job.title || "") === normalize(normalizedRequestedTitle));
582
- if (exactTitle) return exactTitle;
583
- const exactLabel = jobList.find((job) => normalize(job.label || "") === normalize(requested));
584
- if (exactLabel) return exactLabel;
585
- const contains = jobList.filter((job) => {
586
- const title = normalize(job.title || "");
587
- const label = normalize(job.label || "");
588
- const target = normalize(normalizedRequestedTitle);
589
- return (
590
- (title && (title.includes(target) || target.includes(title)))
591
- || (label && (label.includes(normalize(requested)) || normalize(requested).includes(label)))
592
- );
593
- });
594
- if (contains.length === 1) return contains[0];
595
- if (contains.length > 1) {
596
- throw new Error("JOB_SELECTION_AMBIGUOUS");
597
- }
598
- return null;
599
- }
600
-
601
- async clickJobBySelector(job) {
602
- return this.evaluate(`((job) => {
603
- const frame = document.querySelector('iframe[name="recommendFrame"]')
604
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
605
- || document.querySelector('iframe');
606
- if (!frame || !frame.contentDocument) {
607
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
608
- }
609
- const doc = frame.contentDocument;
610
- const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
611
- const normalizeTitle = (value) => {
612
- const text = normalize(value);
613
- if (!text) return '';
614
- const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
615
- const strippedRange = byGap
616
- .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
617
- .trim();
618
- const strippedSingle = strippedRange
619
- .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
620
- .trim();
621
- return strippedSingle || byGap;
622
- };
623
- const items = Array.from(doc.querySelectorAll([
624
- '.ui-dropmenu-list .job-list .job-item',
625
- '.job-selecter-options .job-list .job-item',
626
- '.job-selector-options .job-list .job-item',
627
- '.dropmenu-list .job-list .job-item',
628
- '.job-list .job-item'
629
- ].join(',')));
630
- const target = items.find((item) => {
631
- const value = normalize(item.getAttribute('value') || item.dataset?.value || '');
632
- const label = normalize(item.querySelector('.label')?.textContent || item.textContent || '');
633
- const title = normalizeTitle(label);
634
- const matchValue = job.value && value && value === normalize(job.value);
635
- const matchTitle = job.title && title && title === normalize(job.title);
636
- const matchLabel = job.label && label && label === normalize(job.label);
637
- return matchValue || matchTitle || matchLabel;
638
- });
639
- if (!target) {
640
- return { ok: false, error: 'JOB_OPTION_NOT_FOUND' };
641
- }
642
- target.click();
643
- return { ok: true };
644
- })(${JSON.stringify(job)})`);
645
- }
646
-
647
- async waitJobSelected(job, rounds = 8) {
648
- const selectedValue = normalizeText(job.value || "");
649
- const selectedTitle = normalizeText(job.title || "");
650
- const selectedLabel = normalizeText(job.label || "");
651
- for (let index = 0; index < rounds; index += 1) {
652
- const state = await this.getJobListState();
653
- if (state?.ok) {
654
- const current = (state.jobs || []).find((item) => item.current);
655
- if (current) {
656
- const sameValue = selectedValue && normalizeText(current.value || "") === selectedValue;
657
- const sameTitle = selectedTitle && normalizeText(current.title || "") === selectedTitle;
658
- const sameLabel = selectedLabel && normalizeText(current.label || "") === selectedLabel;
659
- if (sameValue || sameTitle || sameLabel) return true;
660
- }
661
- const selectedText = normalizeText(state.selected_label || "");
662
- if (selectedTitle && selectedText && (selectedText === selectedTitle || selectedText.includes(selectedTitle))) {
663
- return true;
664
- }
665
- }
666
- await sleep(150 + index * 40);
667
- }
668
- return false;
669
- }
670
-
671
- async selectJob(jobSelection) {
672
- const state = await this.ensureJobListReady();
673
- const matched = this.findJobMatch(state.jobs || [], jobSelection);
674
- if (!matched) {
675
- throw new Error("JOB_OPTION_NOT_FOUND");
676
- }
677
- const clicked = await this.clickJobBySelector(matched);
678
- if (!clicked?.ok) {
679
- throw new Error(clicked?.error || "JOB_SELECT_FAILED");
680
- }
681
- const selected = await this.waitJobSelected(matched, 10);
682
- if (!selected) {
683
- throw new Error("JOB_SELECTION_NOT_APPLIED");
684
- }
685
- return matched;
686
- }
445
+ }
446
+
447
+ async getJobListState() {
448
+ return this.evaluate(`(() => {
449
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
450
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
451
+ || document.querySelector('iframe');
452
+ if (!frame || !frame.contentDocument) {
453
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
454
+ }
455
+ const doc = frame.contentDocument;
456
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
457
+ const normalizeTitle = (value) => {
458
+ const text = normalize(value);
459
+ if (!text) return '';
460
+ const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
461
+ const strippedRange = byGap
462
+ .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
463
+ .trim();
464
+ const strippedSingle = strippedRange
465
+ .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
466
+ .trim();
467
+ return strippedSingle || byGap;
468
+ };
469
+ const isVisible = (el) => {
470
+ if (!el) return false;
471
+ const style = getComputedStyle(el);
472
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
473
+ return false;
474
+ }
475
+ const rect = el.getBoundingClientRect();
476
+ return rect.width > 2 && rect.height > 2;
477
+ };
478
+
479
+ const items = Array.from(doc.querySelectorAll([
480
+ '.ui-dropmenu-list .job-list .job-item',
481
+ '.job-selecter-options .job-list .job-item',
482
+ '.job-selector-options .job-list .job-item',
483
+ '.dropmenu-list .job-list .job-item',
484
+ '.job-list .job-item'
485
+ ].join(',')));
486
+ const jobs = [];
487
+ const seen = new Set();
488
+ for (const item of items) {
489
+ const label = normalize(item.querySelector('.label')?.textContent || item.textContent || '');
490
+ const title = normalizeTitle(label);
491
+ const value = normalize(item.getAttribute('value') || item.dataset?.value || '');
492
+ const dedupeKey = value || title || label;
493
+ if (!dedupeKey || seen.has(dedupeKey)) continue;
494
+ seen.add(dedupeKey);
495
+ jobs.push({
496
+ value: value || null,
497
+ title: title || label || null,
498
+ label: label || null,
499
+ current: item.classList.contains('curr') || item.classList.contains('active'),
500
+ visible: isVisible(item)
501
+ });
502
+ }
503
+
504
+ const selectedLabelNode = doc.querySelector('.chat-job-name, .job-selecter .label, .job-selecter .job-name, .job-select .label');
505
+ return {
506
+ ok: true,
507
+ jobs,
508
+ selected_label: normalize(selectedLabelNode ? selectedLabelNode.textContent : ''),
509
+ frame_url: (() => {
510
+ try { return String(frame.contentWindow.location.href || ''); } catch { return ''; }
511
+ })()
512
+ };
513
+ })()`);
514
+ }
515
+
516
+ async clickJobDropdownTriggerBySelector() {
517
+ return this.evaluate(`(() => {
518
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
519
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
520
+ || document.querySelector('iframe');
521
+ if (!frame || !frame.contentDocument) {
522
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
523
+ }
524
+ const doc = frame.contentDocument;
525
+ const selectors = [
526
+ '.chat-job-select',
527
+ '.chat-job-selector',
528
+ '.job-selecter',
529
+ '.job-selector',
530
+ '.job-select-wrap',
531
+ '.job-select',
532
+ '.job-select-box',
533
+ '.job-wrap',
534
+ '.chat-job-name',
535
+ '.top-chat-search'
536
+ ];
537
+ const isVisible = (el) => {
538
+ if (!el) return false;
539
+ const style = getComputedStyle(el);
540
+ if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.01) {
541
+ return false;
542
+ }
543
+ const rect = el.getBoundingClientRect();
544
+ return rect.width > 2 && rect.height > 2;
545
+ };
546
+ for (const selector of selectors) {
547
+ const el = doc.querySelector(selector);
548
+ if (el && isVisible(el)) {
549
+ el.click();
550
+ return { ok: true };
551
+ }
552
+ }
553
+ return { ok: false, error: 'JOB_TRIGGER_NOT_FOUND' };
554
+ })()`);
555
+ }
556
+
557
+ async ensureJobListReady() {
558
+ let lastError = "JOB_LIST_NOT_FOUND";
559
+ for (let attempt = 0; attempt < 4; attempt += 1) {
560
+ const state = await this.getJobListState();
561
+ if (state?.ok && Array.isArray(state.jobs) && state.jobs.length > 0) {
562
+ return state;
563
+ }
564
+ lastError = state?.error || lastError;
565
+ const clickResult = await this.clickJobDropdownTriggerBySelector();
566
+ if (!clickResult?.ok) {
567
+ lastError = clickResult?.error || lastError;
568
+ }
569
+ await sleep(220 + attempt * 80);
570
+ }
571
+ throw new Error(lastError);
572
+ }
573
+
574
+ findJobMatch(jobList, requestedJobRaw) {
575
+ const requested = normalizeText(requestedJobRaw);
576
+ if (!requested) return null;
577
+ const normalizedRequestedTitle = normalizeJobTitle(requested);
578
+ const normalize = (value) => normalizeText(value).toLowerCase();
579
+ const byValue = jobList.find((job) => normalize(job.value || "") === normalize(requested));
580
+ if (byValue) return byValue;
581
+ const exactTitle = jobList.find((job) => normalize(job.title || "") === normalize(normalizedRequestedTitle));
582
+ if (exactTitle) return exactTitle;
583
+ const exactLabel = jobList.find((job) => normalize(job.label || "") === normalize(requested));
584
+ if (exactLabel) return exactLabel;
585
+ const contains = jobList.filter((job) => {
586
+ const title = normalize(job.title || "");
587
+ const label = normalize(job.label || "");
588
+ const target = normalize(normalizedRequestedTitle);
589
+ return (
590
+ (title && (title.includes(target) || target.includes(title)))
591
+ || (label && (label.includes(normalize(requested)) || normalize(requested).includes(label)))
592
+ );
593
+ });
594
+ if (contains.length === 1) return contains[0];
595
+ if (contains.length > 1) {
596
+ throw new Error("JOB_SELECTION_AMBIGUOUS");
597
+ }
598
+ return null;
599
+ }
600
+
601
+ async clickJobBySelector(job) {
602
+ return this.evaluate(`((job) => {
603
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
604
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
605
+ || document.querySelector('iframe');
606
+ if (!frame || !frame.contentDocument) {
607
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
608
+ }
609
+ const doc = frame.contentDocument;
610
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
611
+ const normalizeTitle = (value) => {
612
+ const text = normalize(value);
613
+ if (!text) return '';
614
+ const byGap = text.split(/\\s{2,}/).map((item) => item.trim()).filter(Boolean)[0] || text;
615
+ const strippedRange = byGap
616
+ .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:-|~|—|至)\\s*\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)?$/u, '')
617
+ .trim();
618
+ const strippedSingle = strippedRange
619
+ .replace(/\\s+\\d+(?:\\.\\d+)?\\s*(?:k|K|千|万|元\\/天|元\\/月|元\\/年|K\\/月|k\\/月|万\\/月|万\\/年)$/u, '')
620
+ .trim();
621
+ return strippedSingle || byGap;
622
+ };
623
+ const items = Array.from(doc.querySelectorAll([
624
+ '.ui-dropmenu-list .job-list .job-item',
625
+ '.job-selecter-options .job-list .job-item',
626
+ '.job-selector-options .job-list .job-item',
627
+ '.dropmenu-list .job-list .job-item',
628
+ '.job-list .job-item'
629
+ ].join(',')));
630
+ const target = items.find((item) => {
631
+ const value = normalize(item.getAttribute('value') || item.dataset?.value || '');
632
+ const label = normalize(item.querySelector('.label')?.textContent || item.textContent || '');
633
+ const title = normalizeTitle(label);
634
+ const matchValue = job.value && value && value === normalize(job.value);
635
+ const matchTitle = job.title && title && title === normalize(job.title);
636
+ const matchLabel = job.label && label && label === normalize(job.label);
637
+ return matchValue || matchTitle || matchLabel;
638
+ });
639
+ if (!target) {
640
+ return { ok: false, error: 'JOB_OPTION_NOT_FOUND' };
641
+ }
642
+ target.click();
643
+ return { ok: true };
644
+ })(${JSON.stringify(job)})`);
645
+ }
646
+
647
+ async waitJobSelected(job, rounds = 8) {
648
+ const selectedValue = normalizeText(job.value || "");
649
+ const selectedTitle = normalizeText(job.title || "");
650
+ const selectedLabel = normalizeText(job.label || "");
651
+ for (let index = 0; index < rounds; index += 1) {
652
+ const state = await this.getJobListState();
653
+ if (state?.ok) {
654
+ const current = (state.jobs || []).find((item) => item.current);
655
+ if (current) {
656
+ const sameValue = selectedValue && normalizeText(current.value || "") === selectedValue;
657
+ const sameTitle = selectedTitle && normalizeText(current.title || "") === selectedTitle;
658
+ const sameLabel = selectedLabel && normalizeText(current.label || "") === selectedLabel;
659
+ if (sameValue || sameTitle || sameLabel) return true;
660
+ }
661
+ const selectedText = normalizeText(state.selected_label || "");
662
+ if (selectedTitle && selectedText && (selectedText === selectedTitle || selectedText.includes(selectedTitle))) {
663
+ return true;
664
+ }
665
+ }
666
+ await sleep(150 + index * 40);
667
+ }
668
+ return false;
669
+ }
670
+
671
+ async selectJob(jobSelection) {
672
+ const state = await this.ensureJobListReady();
673
+ const matched = this.findJobMatch(state.jobs || [], jobSelection);
674
+ if (!matched) {
675
+ throw new Error("JOB_OPTION_NOT_FOUND");
676
+ }
677
+ const clicked = await this.clickJobBySelector(matched);
678
+ if (!clicked?.ok) {
679
+ throw new Error(clicked?.error || "JOB_SELECT_FAILED");
680
+ }
681
+ const selected = await this.waitJobSelected(matched, 10);
682
+ if (!selected) {
683
+ throw new Error("JOB_SELECTION_NOT_APPLIED");
684
+ }
685
+ return matched;
686
+ }
687
687
 
688
688
  async isFilterPanelVisible() {
689
689
  const result = await this.evaluate(`(() => {
@@ -792,7 +792,7 @@ class RecommendSearchCli {
792
792
  })()`);
793
793
  }
794
794
 
795
- async openFilterPanel() {
795
+ async openFilterPanel() {
796
796
  if (await this.isFilterPanelVisible()) return;
797
797
  let lastError = 'FILTER_PANEL_UNAVAILABLE';
798
798
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -821,8 +821,8 @@ class RecommendSearchCli {
821
821
  lastError = fallback?.error || lastError;
822
822
  }
823
823
  }
824
- throw new Error(lastError === 'FILTER_TRIGGER_NOT_FOUND' ? lastError : 'FILTER_PANEL_UNAVAILABLE');
825
- }
824
+ throw new Error(lastError === 'FILTER_TRIGGER_NOT_FOUND' ? lastError : 'FILTER_PANEL_UNAVAILABLE');
825
+ }
826
826
 
827
827
  async closeFilterPanel() {
828
828
  if (!(await this.isFilterPanelVisible())) {
@@ -1114,313 +1114,313 @@ class RecommendSearchCli {
1114
1114
  return false;
1115
1115
  }
1116
1116
 
1117
- async getFilterGroupState(groupClass) {
1118
- return this.evaluate(`((groupClass) => {
1119
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1120
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1121
- || document.querySelector('iframe');
1122
- if (!frame || !frame.contentDocument) {
1123
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1124
- }
1125
- const doc = frame.contentDocument;
1126
- const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
1127
- const groupCandidates = Array.from(doc.querySelectorAll('.check-box'));
1128
- const getOptionSet = (group) => new Set(
1129
- Array.from(group.querySelectorAll('.default.option, .options .option, .option'))
1130
- .map((item) => normalize(item.textContent))
1131
- .filter(Boolean)
1132
- );
1133
- const findGroup = () => {
1134
- const direct = doc.querySelector('.check-box.' + groupClass);
1135
- if (direct) return direct;
1136
- if (groupClass === 'school') {
1137
- return groupCandidates.find((group) => {
1138
- const set = getOptionSet(group);
1139
- return set.has('985') || set.has('211') || set.has('双一流院校');
1140
- }) || null;
1141
- }
1142
- if (groupClass === 'degree') {
1143
- return groupCandidates.find((group) => {
1144
- const set = getOptionSet(group);
1145
- return set.has('大专') || set.has('本科') || set.has('硕士') || set.has('博士');
1146
- }) || null;
1147
- }
1148
- if (groupClass === 'gender') {
1149
- return groupCandidates.find((group) => {
1150
- const set = getOptionSet(group);
1151
- return set.has('男') || set.has('女');
1152
- }) || null;
1153
- }
1154
- if (groupClass === 'recentNotView') {
1155
- return groupCandidates.find((group) => {
1156
- const set = getOptionSet(group);
1157
- return set.has('近14天没有');
1158
- }) || null;
1159
- }
1160
- return null;
1161
- };
1162
-
1163
- const group = findGroup();
1164
- if (!group) {
1165
- return { ok: false, error: 'GROUP_NOT_FOUND' };
1166
- }
1167
-
1168
- const defaultOption = group.querySelector('.default.option');
1169
- const options = Array.from(group.querySelectorAll('.default.option, .options .option, .option'));
1170
- const byLabel = new Map();
1171
- for (const node of options) {
1172
- const label = normalize(node.textContent);
1173
- if (!label) continue;
1174
- const className = String(node.className || '').trim();
1175
- const active = node.classList.contains('active');
1176
- const existing = byLabel.get(label);
1177
- if (existing) {
1178
- existing.active = existing.active || active;
1179
- if (className && !existing.classNames.includes(className)) {
1180
- existing.classNames.push(className);
1181
- }
1182
- } else {
1183
- byLabel.set(label, {
1184
- label,
1185
- active,
1186
- classNames: className ? [className] : []
1187
- });
1188
- }
1189
- }
1190
-
1191
- const normalizedOptions = Array.from(byLabel.values()).map((item) => ({
1192
- label: item.label,
1193
- active: item.active,
1194
- class_name: item.classNames.join(' | ')
1195
- }));
1196
- return {
1197
- ok: true,
1198
- group_class: groupClass,
1199
- defaultActive: Boolean(defaultOption && defaultOption.classList.contains('active')),
1200
- defaultClassName: defaultOption ? String(defaultOption.className || '').trim() : '',
1201
- options: normalizedOptions,
1202
- activeLabels: normalizedOptions.filter((item) => item.active).map((item) => item.label)
1203
- };
1204
- })(${JSON.stringify(groupClass)})`);
1205
- }
1206
-
1207
- async getSchoolFilterState() {
1208
- return this.getFilterGroupState("school");
1209
- }
1210
-
1211
- async selectSchoolFilter(labels) {
1212
- const ensure = await this.ensureGroupReady("school");
1213
- if (!ensure?.ok) {
1214
- throw new Error(ensure?.error || "GROUP_NOT_FOUND");
1215
- }
1216
-
1217
- const targetLabels = Array.isArray(labels) && labels.length > 0 ? labels : ["不限"];
1218
- const desired = sortSchoolSelection(targetLabels);
1219
- const expectDefaultOnly = desired.includes("不限");
1220
- let lastState = null;
1221
-
1222
- for (let attempt = 0; attempt < 3; attempt += 1) {
1223
- const state = await this.getSchoolFilterState();
1224
- if (!state?.ok) {
1225
- throw new Error(state?.error || "SCHOOL_FILTER_STATE_FAILED");
1226
- }
1227
- lastState = state;
1228
- const current = sortSchoolSelection(state.activeLabels || []);
1229
- const matched = expectDefaultOnly
1230
- ? Boolean(state.defaultActive)
1231
- : (!state.defaultActive && selectionEquals(current, desired));
1232
- if (matched) {
1233
- return;
1234
- }
1235
-
1236
- if (expectDefaultOnly) {
1237
- await this.selectOption("school", "不限");
1238
- await sleep(humanDelay(180, 50));
1239
- continue;
1240
- }
1241
-
1242
- if (state.defaultActive) {
1243
- const clearDefault = await this.clickOptionBySelector("school", "不限");
1244
- if (!clearDefault?.ok) {
1245
- throw new Error(clearDefault?.error || "SCHOOL_DEFAULT_CLEAR_FAILED");
1246
- }
1247
- await sleep(humanDelay(180, 50));
1248
- }
1249
- for (const label of desired) {
1250
- await this.selectOption("school", label);
1251
- await sleep(humanDelay(120, 40));
1252
- }
1253
- await sleep(humanDelay(180, 50));
1254
- }
1255
-
1256
- throw new Error(`SCHOOL_FILTER_VERIFY_FAILED:${JSON.stringify(lastState || {})}`);
1257
- }
1258
-
1259
- async getDegreeFilterState() {
1260
- return this.getFilterGroupState("degree");
1261
- }
1262
-
1263
- async getGenderFilterState() {
1264
- return this.getFilterGroupState("gender");
1265
- }
1266
-
1267
- async getRecentNotViewFilterState() {
1268
- return this.getFilterGroupState("recentNotView");
1269
- }
1270
-
1271
- async selectDegreeFilter(labels) {
1272
- const ensure = await this.ensureGroupReady("degree");
1273
- if (!ensure?.ok) {
1274
- throw new Error(ensure?.error || "GROUP_NOT_FOUND");
1275
- }
1276
-
1277
- const targetLabels = Array.isArray(labels) && labels.length > 0 ? labels : ["不限"];
1278
- const desired = sortDegreeSelection(targetLabels);
1279
- const expectDefaultOnly = desired.includes("不限");
1280
- let lastState = null;
1281
-
1282
- for (let attempt = 0; attempt < 3; attempt += 1) {
1283
- const state = await this.getDegreeFilterState();
1284
- if (!state?.ok) {
1285
- throw new Error(state?.error || "DEGREE_FILTER_STATE_FAILED");
1286
- }
1287
- lastState = state;
1288
- const current = sortDegreeSelection(state.activeLabels || []);
1289
- const matched = expectDefaultOnly
1290
- ? Boolean(state.defaultActive)
1291
- : (!state.defaultActive && selectionEquals(current, desired));
1292
- if (matched) {
1293
- return;
1294
- }
1295
-
1296
- if (expectDefaultOnly) {
1297
- await this.selectOption("degree", "不限");
1298
- await sleep(humanDelay(180, 50));
1299
- continue;
1300
- }
1301
-
1302
- if (state.defaultActive) {
1303
- const clearDefault = await this.clickOptionBySelector("degree", "不限");
1304
- if (!clearDefault?.ok) {
1305
- throw new Error(clearDefault?.error || "DEGREE_DEFAULT_CLEAR_FAILED");
1306
- }
1307
- await sleep(humanDelay(180, 50));
1308
- }
1309
- for (const label of desired) {
1310
- await this.selectOption("degree", label);
1311
- await sleep(humanDelay(120, 40));
1312
- }
1313
- await sleep(humanDelay(180, 50));
1314
- }
1315
-
1316
- throw new Error(`DEGREE_FILTER_VERIFY_FAILED:${JSON.stringify(lastState || {})}`);
1317
- }
1318
-
1319
- buildGroupClassVerification(groupName, state, expectedLabels, availableOptions, sortFn) {
1320
- if (!state?.ok) {
1321
- return {
1322
- group: groupName,
1323
- ok: false,
1324
- reason: state?.error || "GROUP_STATE_UNAVAILABLE",
1325
- expected_labels: expectedLabels,
1326
- state: state || null
1327
- };
1328
- }
1329
-
1330
- const expectedSorted = sortFn(uniqueNormalizedLabels(expectedLabels));
1331
- const expectedSet = new Set(expectedSorted);
1332
- const allowedSet = new Set(uniqueNormalizedLabels(availableOptions));
1333
- const optionMap = new Map();
1334
- for (const option of state.options || []) {
1335
- optionMap.set(normalizeText(option.label), option);
1336
- }
1337
-
1338
- const selectedNotActive = [];
1339
- const unselectedButActive = [];
1340
- for (const label of expectedSorted) {
1341
- const option = optionMap.get(label);
1342
- if (!option || option.active !== true) {
1343
- selectedNotActive.push(label);
1344
- }
1345
- }
1346
- for (const label of allowedSet) {
1347
- if (expectedSet.has(label)) continue;
1348
- const option = optionMap.get(label);
1349
- if (option?.active === true) {
1350
- unselectedButActive.push(label);
1351
- }
1352
- }
1353
-
1354
- const expectDefault = expectedSet.has("不限");
1355
- const defaultMismatch = expectDefault ? !state.defaultActive : Boolean(state.defaultActive);
1356
- const ok = (
1357
- selectedNotActive.length === 0
1358
- && unselectedButActive.length === 0
1359
- && !defaultMismatch
1360
- );
1361
-
1362
- return {
1363
- group: groupName,
1364
- ok,
1365
- expected_labels: expectedSorted,
1366
- actual_active_labels: sortFn(uniqueNormalizedLabels(state.activeLabels || [])),
1367
- default_active: Boolean(state.defaultActive),
1368
- selected_not_active: selectedNotActive,
1369
- unselected_but_active: unselectedButActive,
1370
- default_mismatch: defaultMismatch,
1371
- options: state.options || []
1372
- };
1373
- }
1374
-
1375
- async verifyFilterDomClassStates(expected) {
1376
- const schoolState = await this.getSchoolFilterState();
1377
- const degreeState = await this.getDegreeFilterState();
1378
- const genderState = await this.getGenderFilterState();
1379
- const recentState = await this.getRecentNotViewFilterState();
1380
-
1381
- const checks = [
1382
- this.buildGroupClassVerification(
1383
- "school",
1384
- schoolState,
1385
- Array.isArray(expected?.schoolTag) && expected.schoolTag.length > 0 ? expected.schoolTag : ["不限"],
1386
- SCHOOL_TAG_OPTIONS,
1387
- sortSchoolSelection
1388
- ),
1389
- this.buildGroupClassVerification(
1390
- "degree",
1391
- degreeState,
1392
- Array.isArray(expected?.degree) && expected.degree.length > 0 ? expected.degree : ["不限"],
1393
- DEGREE_OPTIONS,
1394
- sortDegreeSelection
1395
- ),
1396
- this.buildGroupClassVerification(
1397
- "gender",
1398
- genderState,
1399
- [normalizeText(expected?.gender || "不限")],
1400
- GENDER_OPTIONS,
1401
- uniqueNormalizedLabels
1402
- ),
1403
- this.buildGroupClassVerification(
1404
- "recent_not_view",
1405
- recentState,
1406
- [normalizeText(expected?.recentNotView || "不限")],
1407
- RECENT_NOT_VIEW_OPTIONS,
1408
- uniqueNormalizedLabels
1409
- )
1410
- ];
1411
- const failures = checks.filter((item) => item.ok === false);
1412
- return {
1413
- ok: failures.length === 0,
1414
- checks,
1415
- failures,
1416
- states: {
1417
- school: schoolState,
1418
- degree: degreeState,
1419
- gender: genderState,
1420
- recent_not_view: recentState
1421
- }
1422
- };
1423
- }
1117
+ async getFilterGroupState(groupClass) {
1118
+ return this.evaluate(`((groupClass) => {
1119
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
1120
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1121
+ || document.querySelector('iframe');
1122
+ if (!frame || !frame.contentDocument) {
1123
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1124
+ }
1125
+ const doc = frame.contentDocument;
1126
+ const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
1127
+ const groupCandidates = Array.from(doc.querySelectorAll('.check-box'));
1128
+ const getOptionSet = (group) => new Set(
1129
+ Array.from(group.querySelectorAll('.default.option, .options .option, .option'))
1130
+ .map((item) => normalize(item.textContent))
1131
+ .filter(Boolean)
1132
+ );
1133
+ const findGroup = () => {
1134
+ const direct = doc.querySelector('.check-box.' + groupClass);
1135
+ if (direct) return direct;
1136
+ if (groupClass === 'school') {
1137
+ return groupCandidates.find((group) => {
1138
+ const set = getOptionSet(group);
1139
+ return set.has('985') || set.has('211') || set.has('双一流院校');
1140
+ }) || null;
1141
+ }
1142
+ if (groupClass === 'degree') {
1143
+ return groupCandidates.find((group) => {
1144
+ const set = getOptionSet(group);
1145
+ return set.has('大专') || set.has('本科') || set.has('硕士') || set.has('博士');
1146
+ }) || null;
1147
+ }
1148
+ if (groupClass === 'gender') {
1149
+ return groupCandidates.find((group) => {
1150
+ const set = getOptionSet(group);
1151
+ return set.has('男') || set.has('女');
1152
+ }) || null;
1153
+ }
1154
+ if (groupClass === 'recentNotView') {
1155
+ return groupCandidates.find((group) => {
1156
+ const set = getOptionSet(group);
1157
+ return set.has('近14天没有');
1158
+ }) || null;
1159
+ }
1160
+ return null;
1161
+ };
1162
+
1163
+ const group = findGroup();
1164
+ if (!group) {
1165
+ return { ok: false, error: 'GROUP_NOT_FOUND' };
1166
+ }
1167
+
1168
+ const defaultOption = group.querySelector('.default.option');
1169
+ const options = Array.from(group.querySelectorAll('.default.option, .options .option, .option'));
1170
+ const byLabel = new Map();
1171
+ for (const node of options) {
1172
+ const label = normalize(node.textContent);
1173
+ if (!label) continue;
1174
+ const className = String(node.className || '').trim();
1175
+ const active = node.classList.contains('active');
1176
+ const existing = byLabel.get(label);
1177
+ if (existing) {
1178
+ existing.active = existing.active || active;
1179
+ if (className && !existing.classNames.includes(className)) {
1180
+ existing.classNames.push(className);
1181
+ }
1182
+ } else {
1183
+ byLabel.set(label, {
1184
+ label,
1185
+ active,
1186
+ classNames: className ? [className] : []
1187
+ });
1188
+ }
1189
+ }
1190
+
1191
+ const normalizedOptions = Array.from(byLabel.values()).map((item) => ({
1192
+ label: item.label,
1193
+ active: item.active,
1194
+ class_name: item.classNames.join(' | ')
1195
+ }));
1196
+ return {
1197
+ ok: true,
1198
+ group_class: groupClass,
1199
+ defaultActive: Boolean(defaultOption && defaultOption.classList.contains('active')),
1200
+ defaultClassName: defaultOption ? String(defaultOption.className || '').trim() : '',
1201
+ options: normalizedOptions,
1202
+ activeLabels: normalizedOptions.filter((item) => item.active).map((item) => item.label)
1203
+ };
1204
+ })(${JSON.stringify(groupClass)})`);
1205
+ }
1206
+
1207
+ async getSchoolFilterState() {
1208
+ return this.getFilterGroupState("school");
1209
+ }
1210
+
1211
+ async selectSchoolFilter(labels) {
1212
+ const ensure = await this.ensureGroupReady("school");
1213
+ if (!ensure?.ok) {
1214
+ throw new Error(ensure?.error || "GROUP_NOT_FOUND");
1215
+ }
1216
+
1217
+ const targetLabels = Array.isArray(labels) && labels.length > 0 ? labels : ["不限"];
1218
+ const desired = sortSchoolSelection(targetLabels);
1219
+ const expectDefaultOnly = desired.includes("不限");
1220
+ let lastState = null;
1221
+
1222
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1223
+ const state = await this.getSchoolFilterState();
1224
+ if (!state?.ok) {
1225
+ throw new Error(state?.error || "SCHOOL_FILTER_STATE_FAILED");
1226
+ }
1227
+ lastState = state;
1228
+ const current = sortSchoolSelection(state.activeLabels || []);
1229
+ const matched = expectDefaultOnly
1230
+ ? Boolean(state.defaultActive)
1231
+ : (!state.defaultActive && selectionEquals(current, desired));
1232
+ if (matched) {
1233
+ return;
1234
+ }
1235
+
1236
+ if (expectDefaultOnly) {
1237
+ await this.selectOption("school", "不限");
1238
+ await sleep(humanDelay(180, 50));
1239
+ continue;
1240
+ }
1241
+
1242
+ if (state.defaultActive) {
1243
+ const clearDefault = await this.clickOptionBySelector("school", "不限");
1244
+ if (!clearDefault?.ok) {
1245
+ throw new Error(clearDefault?.error || "SCHOOL_DEFAULT_CLEAR_FAILED");
1246
+ }
1247
+ await sleep(humanDelay(180, 50));
1248
+ }
1249
+ for (const label of desired) {
1250
+ await this.selectOption("school", label);
1251
+ await sleep(humanDelay(120, 40));
1252
+ }
1253
+ await sleep(humanDelay(180, 50));
1254
+ }
1255
+
1256
+ throw new Error(`SCHOOL_FILTER_VERIFY_FAILED:${JSON.stringify(lastState || {})}`);
1257
+ }
1258
+
1259
+ async getDegreeFilterState() {
1260
+ return this.getFilterGroupState("degree");
1261
+ }
1262
+
1263
+ async getGenderFilterState() {
1264
+ return this.getFilterGroupState("gender");
1265
+ }
1266
+
1267
+ async getRecentNotViewFilterState() {
1268
+ return this.getFilterGroupState("recentNotView");
1269
+ }
1270
+
1271
+ async selectDegreeFilter(labels) {
1272
+ const ensure = await this.ensureGroupReady("degree");
1273
+ if (!ensure?.ok) {
1274
+ throw new Error(ensure?.error || "GROUP_NOT_FOUND");
1275
+ }
1276
+
1277
+ const targetLabels = Array.isArray(labels) && labels.length > 0 ? labels : ["不限"];
1278
+ const desired = sortDegreeSelection(targetLabels);
1279
+ const expectDefaultOnly = desired.includes("不限");
1280
+ let lastState = null;
1281
+
1282
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1283
+ const state = await this.getDegreeFilterState();
1284
+ if (!state?.ok) {
1285
+ throw new Error(state?.error || "DEGREE_FILTER_STATE_FAILED");
1286
+ }
1287
+ lastState = state;
1288
+ const current = sortDegreeSelection(state.activeLabels || []);
1289
+ const matched = expectDefaultOnly
1290
+ ? Boolean(state.defaultActive)
1291
+ : (!state.defaultActive && selectionEquals(current, desired));
1292
+ if (matched) {
1293
+ return;
1294
+ }
1295
+
1296
+ if (expectDefaultOnly) {
1297
+ await this.selectOption("degree", "不限");
1298
+ await sleep(humanDelay(180, 50));
1299
+ continue;
1300
+ }
1301
+
1302
+ if (state.defaultActive) {
1303
+ const clearDefault = await this.clickOptionBySelector("degree", "不限");
1304
+ if (!clearDefault?.ok) {
1305
+ throw new Error(clearDefault?.error || "DEGREE_DEFAULT_CLEAR_FAILED");
1306
+ }
1307
+ await sleep(humanDelay(180, 50));
1308
+ }
1309
+ for (const label of desired) {
1310
+ await this.selectOption("degree", label);
1311
+ await sleep(humanDelay(120, 40));
1312
+ }
1313
+ await sleep(humanDelay(180, 50));
1314
+ }
1315
+
1316
+ throw new Error(`DEGREE_FILTER_VERIFY_FAILED:${JSON.stringify(lastState || {})}`);
1317
+ }
1318
+
1319
+ buildGroupClassVerification(groupName, state, expectedLabels, availableOptions, sortFn) {
1320
+ if (!state?.ok) {
1321
+ return {
1322
+ group: groupName,
1323
+ ok: false,
1324
+ reason: state?.error || "GROUP_STATE_UNAVAILABLE",
1325
+ expected_labels: expectedLabels,
1326
+ state: state || null
1327
+ };
1328
+ }
1329
+
1330
+ const expectedSorted = sortFn(uniqueNormalizedLabels(expectedLabels));
1331
+ const expectedSet = new Set(expectedSorted);
1332
+ const allowedSet = new Set(uniqueNormalizedLabels(availableOptions));
1333
+ const optionMap = new Map();
1334
+ for (const option of state.options || []) {
1335
+ optionMap.set(normalizeText(option.label), option);
1336
+ }
1337
+
1338
+ const selectedNotActive = [];
1339
+ const unselectedButActive = [];
1340
+ for (const label of expectedSorted) {
1341
+ const option = optionMap.get(label);
1342
+ if (!option || option.active !== true) {
1343
+ selectedNotActive.push(label);
1344
+ }
1345
+ }
1346
+ for (const label of allowedSet) {
1347
+ if (expectedSet.has(label)) continue;
1348
+ const option = optionMap.get(label);
1349
+ if (option?.active === true) {
1350
+ unselectedButActive.push(label);
1351
+ }
1352
+ }
1353
+
1354
+ const expectDefault = expectedSet.has("不限");
1355
+ const defaultMismatch = expectDefault ? !state.defaultActive : Boolean(state.defaultActive);
1356
+ const ok = (
1357
+ selectedNotActive.length === 0
1358
+ && unselectedButActive.length === 0
1359
+ && !defaultMismatch
1360
+ );
1361
+
1362
+ return {
1363
+ group: groupName,
1364
+ ok,
1365
+ expected_labels: expectedSorted,
1366
+ actual_active_labels: sortFn(uniqueNormalizedLabels(state.activeLabels || [])),
1367
+ default_active: Boolean(state.defaultActive),
1368
+ selected_not_active: selectedNotActive,
1369
+ unselected_but_active: unselectedButActive,
1370
+ default_mismatch: defaultMismatch,
1371
+ options: state.options || []
1372
+ };
1373
+ }
1374
+
1375
+ async verifyFilterDomClassStates(expected) {
1376
+ const schoolState = await this.getSchoolFilterState();
1377
+ const degreeState = await this.getDegreeFilterState();
1378
+ const genderState = await this.getGenderFilterState();
1379
+ const recentState = await this.getRecentNotViewFilterState();
1380
+
1381
+ const checks = [
1382
+ this.buildGroupClassVerification(
1383
+ "school",
1384
+ schoolState,
1385
+ Array.isArray(expected?.schoolTag) && expected.schoolTag.length > 0 ? expected.schoolTag : ["不限"],
1386
+ SCHOOL_TAG_OPTIONS,
1387
+ sortSchoolSelection
1388
+ ),
1389
+ this.buildGroupClassVerification(
1390
+ "degree",
1391
+ degreeState,
1392
+ Array.isArray(expected?.degree) && expected.degree.length > 0 ? expected.degree : ["不限"],
1393
+ DEGREE_OPTIONS,
1394
+ sortDegreeSelection
1395
+ ),
1396
+ this.buildGroupClassVerification(
1397
+ "gender",
1398
+ genderState,
1399
+ [normalizeText(expected?.gender || "不限")],
1400
+ GENDER_OPTIONS,
1401
+ uniqueNormalizedLabels
1402
+ ),
1403
+ this.buildGroupClassVerification(
1404
+ "recent_not_view",
1405
+ recentState,
1406
+ [normalizeText(expected?.recentNotView || "不限")],
1407
+ RECENT_NOT_VIEW_OPTIONS,
1408
+ uniqueNormalizedLabels
1409
+ )
1410
+ ];
1411
+ const failures = checks.filter((item) => item.ok === false);
1412
+ return {
1413
+ ok: failures.length === 0,
1414
+ checks,
1415
+ failures,
1416
+ states: {
1417
+ school: schoolState,
1418
+ degree: degreeState,
1419
+ gender: genderState,
1420
+ recent_not_view: recentState
1421
+ }
1422
+ };
1423
+ }
1424
1424
 
1425
1425
  async countCandidates() {
1426
1426
  return this.evaluate(`(() => {
@@ -1466,95 +1466,95 @@ class RecommendSearchCli {
1466
1466
  return latest;
1467
1467
  }
1468
1468
 
1469
- async run() {
1470
- if (this.args.help) {
1471
- console.log(JSON.stringify({
1472
- status: "COMPLETED",
1473
- result: {
1474
- usage: "node src/cli.js --school-tag 985/211 --degree 本科及以上 --gender 男 --recent-not-view 近14天没有 --job \"算法工程师(视频/图像模型方向) _ 杭州\" --port 9222",
1475
- list_jobs_usage: "node src/cli.js --list-jobs --port 9222"
1476
- }
1477
- }));
1478
- return;
1479
- }
1469
+ async run() {
1470
+ if (this.args.help) {
1471
+ console.log(JSON.stringify({
1472
+ status: "COMPLETED",
1473
+ result: {
1474
+ usage: "node src/cli.js --school-tag 985/211 --degree 本科及以上 --gender 男 --recent-not-view 近14天没有 --job \"算法工程师(视频/图像模型方向) _ 杭州\" --port 9222",
1475
+ list_jobs_usage: "node src/cli.js --list-jobs --port 9222"
1476
+ }
1477
+ }));
1478
+ return;
1479
+ }
1480
1480
  if (!Array.isArray(this.args.schoolTag) || this.args.schoolTag.length === 0) {
1481
1481
  throw new Error("INVALID_SCHOOL_TAG_INPUT");
1482
1482
  }
1483
- if (!Array.isArray(this.args.degree) || this.args.degree.length === 0) {
1484
- throw new Error("INVALID_DEGREE_INPUT");
1485
- }
1486
-
1487
- await this.connect();
1488
- try {
1489
- const frameState = await this.getFrameState();
1490
- if (!frameState?.ok) {
1491
- if (frameState?.error === "LOGIN_REQUIRED") {
1492
- throw new Error("LOGIN_REQUIRED");
1493
- }
1494
- throw new Error(frameState?.error || 'NO_RECOMMEND_IFRAME');
1495
- }
1496
-
1497
- if (this.args.listJobs) {
1498
- const jobState = await this.ensureJobListReady();
1499
- console.log(JSON.stringify({
1500
- status: "COMPLETED",
1501
- result: {
1502
- jobs: jobState.jobs || [],
1503
- page_state: {
1504
- target_url: this.target?.url || null,
1505
- frame_url: frameState.frameUrl || jobState.frame_url || null
1506
- }
1507
- }
1508
- }));
1509
- return;
1510
- }
1511
-
1512
- let selectedJob = null;
1513
- if (this.args.job) {
1514
- selectedJob = await this.selectJob(this.args.job);
1515
- await sleep(humanDelay(220, 70));
1516
- }
1517
-
1518
- await this.openFilterPanel();
1519
- await this.selectSchoolFilter(this.args.schoolTag);
1520
- await this.selectOption("gender", this.args.gender);
1521
- await this.selectOption("recentNotView", this.args.recentNotView);
1522
- await this.selectDegreeFilter(this.args.degree);
1523
- const domClassVerification = await this.verifyFilterDomClassStates({
1524
- schoolTag: this.args.schoolTag,
1525
- degree: this.args.degree,
1526
- gender: this.args.gender,
1527
- recentNotView: this.args.recentNotView
1528
- });
1529
- if (!domClassVerification.ok) {
1530
- throw new Error(`FILTER_DOM_CLASS_VERIFY_FAILED:${JSON.stringify(domClassVerification.failures)}`);
1531
- }
1532
- await this.closeFilterPanel();
1533
- const candidateInfo = await this.waitForCandidateCountStable();
1534
-
1535
- console.log(JSON.stringify({
1536
- status: "COMPLETED",
1537
- result: {
1538
- applied_filters: {
1539
- school_tag: this.args.schoolTag,
1540
- degree: this.args.degree,
1541
- gender: this.args.gender,
1542
- recent_not_view: this.args.recentNotView
1543
- },
1544
- verified_filters: {
1545
- school: domClassVerification.states.school,
1546
- degree: domClassVerification.states.degree,
1547
- gender: domClassVerification.states.gender,
1548
- recent_not_view: domClassVerification.states.recent_not_view,
1549
- dom_class_check: {
1550
- ok: domClassVerification.ok,
1551
- checks: domClassVerification.checks
1552
- }
1553
- },
1554
- selected_job: selectedJob,
1555
- candidate_count: candidateInfo?.candidateCount ?? null,
1556
- page_state: {
1557
- target_url: this.target?.url || null,
1483
+ if (!Array.isArray(this.args.degree) || this.args.degree.length === 0) {
1484
+ throw new Error("INVALID_DEGREE_INPUT");
1485
+ }
1486
+
1487
+ await this.connect();
1488
+ try {
1489
+ const frameState = await this.getFrameState();
1490
+ if (!frameState?.ok) {
1491
+ if (frameState?.error === "LOGIN_REQUIRED") {
1492
+ throw new Error("LOGIN_REQUIRED");
1493
+ }
1494
+ throw new Error(frameState?.error || 'NO_RECOMMEND_IFRAME');
1495
+ }
1496
+
1497
+ if (this.args.listJobs) {
1498
+ const jobState = await this.ensureJobListReady();
1499
+ console.log(JSON.stringify({
1500
+ status: "COMPLETED",
1501
+ result: {
1502
+ jobs: jobState.jobs || [],
1503
+ page_state: {
1504
+ target_url: this.target?.url || null,
1505
+ frame_url: frameState.frameUrl || jobState.frame_url || null
1506
+ }
1507
+ }
1508
+ }));
1509
+ return;
1510
+ }
1511
+
1512
+ let selectedJob = null;
1513
+ if (this.args.job) {
1514
+ selectedJob = await this.selectJob(this.args.job);
1515
+ await sleep(humanDelay(220, 70));
1516
+ }
1517
+
1518
+ await this.openFilterPanel();
1519
+ await this.selectSchoolFilter(this.args.schoolTag);
1520
+ await this.selectOption("gender", this.args.gender);
1521
+ await this.selectOption("recentNotView", this.args.recentNotView);
1522
+ await this.selectDegreeFilter(this.args.degree);
1523
+ const domClassVerification = await this.verifyFilterDomClassStates({
1524
+ schoolTag: this.args.schoolTag,
1525
+ degree: this.args.degree,
1526
+ gender: this.args.gender,
1527
+ recentNotView: this.args.recentNotView
1528
+ });
1529
+ if (!domClassVerification.ok) {
1530
+ throw new Error(`FILTER_DOM_CLASS_VERIFY_FAILED:${JSON.stringify(domClassVerification.failures)}`);
1531
+ }
1532
+ await this.closeFilterPanel();
1533
+ const candidateInfo = await this.waitForCandidateCountStable();
1534
+
1535
+ console.log(JSON.stringify({
1536
+ status: "COMPLETED",
1537
+ result: {
1538
+ applied_filters: {
1539
+ school_tag: this.args.schoolTag,
1540
+ degree: this.args.degree,
1541
+ gender: this.args.gender,
1542
+ recent_not_view: this.args.recentNotView
1543
+ },
1544
+ verified_filters: {
1545
+ school: domClassVerification.states.school,
1546
+ degree: domClassVerification.states.degree,
1547
+ gender: domClassVerification.states.gender,
1548
+ recent_not_view: domClassVerification.states.recent_not_view,
1549
+ dom_class_check: {
1550
+ ok: domClassVerification.ok,
1551
+ checks: domClassVerification.checks
1552
+ }
1553
+ },
1554
+ selected_job: selectedJob,
1555
+ candidate_count: candidateInfo?.candidateCount ?? null,
1556
+ page_state: {
1557
+ target_url: this.target?.url || null,
1558
1558
  frame_url: frameState.frameUrl || null
1559
1559
  }
1560
1560
  }
@@ -1565,39 +1565,39 @@ class RecommendSearchCli {
1565
1565
  }
1566
1566
  }
1567
1567
 
1568
- async function main() {
1569
- const args = parseArgs(process.argv.slice(2));
1570
- const finalArgs = await enrichArgsFromPrompt(args);
1571
- const cli = new RecommendSearchCli(finalArgs);
1572
- await cli.run();
1573
- }
1574
-
1575
- function isDirectExecution() {
1576
- const entry = process.argv?.[1];
1577
- if (!entry) return false;
1578
- try {
1579
- return import.meta.url === pathToFileURL(entry).href;
1580
- } catch {
1581
- return false;
1582
- }
1583
- }
1584
-
1585
- if (isDirectExecution()) {
1586
- main().catch((error) => {
1587
- console.log(JSON.stringify({
1588
- status: "FAILED",
1589
- error: {
1590
- code: error.message || "RECOMMEND_SEARCH_FAILED",
1591
- message: error.message || "推荐页筛选执行失败。",
1592
- retryable: true
1593
- }
1594
- }));
1595
- process.exitCode = 1;
1596
- });
1597
- }
1598
-
1599
- export {
1600
- RecommendSearchCli,
1601
- normalizeJobTitle,
1602
- parseArgs
1603
- };
1568
+ async function main() {
1569
+ const args = parseArgs(process.argv.slice(2));
1570
+ const finalArgs = await enrichArgsFromPrompt(args);
1571
+ const cli = new RecommendSearchCli(finalArgs);
1572
+ await cli.run();
1573
+ }
1574
+
1575
+ function isDirectExecution() {
1576
+ const entry = process.argv?.[1];
1577
+ if (!entry) return false;
1578
+ try {
1579
+ return import.meta.url === pathToFileURL(entry).href;
1580
+ } catch {
1581
+ return false;
1582
+ }
1583
+ }
1584
+
1585
+ if (isDirectExecution()) {
1586
+ main().catch((error) => {
1587
+ console.log(JSON.stringify({
1588
+ status: "FAILED",
1589
+ error: {
1590
+ code: error.message || "RECOMMEND_SEARCH_FAILED",
1591
+ message: error.message || "推荐页筛选执行失败。",
1592
+ retryable: true
1593
+ }
1594
+ }));
1595
+ process.exitCode = 1;
1596
+ });
1597
+ }
1598
+
1599
+ export {
1600
+ RecommendSearchCli,
1601
+ normalizeJobTitle,
1602
+ parseArgs
1603
+ };