@reconcrap/boss-recommend-mcp 0.1.2 → 0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -20,6 +20,39 @@ function parsePositiveInteger(raw) {
20
20
  return Number.isFinite(value) && value > 0 ? value : null;
21
21
  }
22
22
 
23
+ function sortSchoolSelection(values) {
24
+ const order = new Map(SCHOOL_TAG_OPTIONS.map((label, index) => [label, index]));
25
+ const unique = Array.from(new Set((values || []).filter((item) => order.has(item))));
26
+ if (!unique.length) return [];
27
+ if (unique.includes("不限")) {
28
+ return unique.length === 1
29
+ ? ["不限"]
30
+ : unique.filter((item) => item !== "不限").sort((left, right) => order.get(left) - order.get(right));
31
+ }
32
+ return unique.sort((left, right) => order.get(left) - order.get(right));
33
+ }
34
+
35
+ function parseSchoolSelection(raw) {
36
+ const text = normalizeText(raw);
37
+ if (!text) return null;
38
+ if (text === "不限") return ["不限"];
39
+
40
+ const selected = [];
41
+ for (const chunk of text.split(/[,,、/|]/)) {
42
+ const value = normalizeText(chunk);
43
+ if (SCHOOL_TAG_OPTIONS.includes(value)) {
44
+ selected.push(value);
45
+ }
46
+ }
47
+ for (const label of SCHOOL_TAG_OPTIONS) {
48
+ if (label !== "不限" && text.includes(label)) {
49
+ selected.push(label);
50
+ }
51
+ }
52
+ const normalized = sortSchoolSelection(selected);
53
+ return normalized.length ? normalized : null;
54
+ }
55
+
23
56
  function normalizeDegree(value) {
24
57
  const normalized = normalizeText(value);
25
58
  if (!normalized) return null;
@@ -72,7 +105,7 @@ function parseDegreeSelection(raw) {
72
105
 
73
106
  function parseArgs(argv) {
74
107
  const args = {
75
- schoolTag: "不限",
108
+ schoolTag: ["不限"],
76
109
  degree: ["不限"],
77
110
  gender: "不限",
78
111
  recentNotView: "不限",
@@ -91,7 +124,7 @@ function parseArgs(argv) {
91
124
  const token = argv[index];
92
125
  const next = argv[index + 1];
93
126
  if (token === "--school-tag" && next) {
94
- args.schoolTag = next;
127
+ args.schoolTag = parseSchoolSelection(next);
95
128
  args.__provided.schoolTag = true;
96
129
  index += 1;
97
130
  } else if (token === "--degree" && next) {
@@ -134,7 +167,12 @@ async function promptValue(ask, question, validate, defaultValue) {
134
167
 
135
168
  async function enrichArgsFromPrompt(args) {
136
169
  if (!isInteractiveTTY() || args.help) return args;
137
- const askTargets = Object.values(args.__provided || {}).some((item) => item === false) || !Array.isArray(args.degree) || args.degree.length === 0;
170
+ const askTargets =
171
+ Object.values(args.__provided || {}).some((item) => item === false)
172
+ || !Array.isArray(args.schoolTag)
173
+ || args.schoolTag.length === 0
174
+ || !Array.isArray(args.degree)
175
+ || args.degree.length === 0;
138
176
  if (!askTargets) return args;
139
177
 
140
178
  const rl = readline.createInterface({
@@ -144,11 +182,12 @@ async function enrichArgsFromPrompt(args) {
144
182
  const ask = (question) => new Promise((resolve) => rl.question(question, resolve));
145
183
  try {
146
184
  if (!args.__provided.schoolTag) {
185
+ const current = Array.isArray(args.schoolTag) && args.schoolTag.length > 0 ? args.schoolTag.join("/") : "不限";
147
186
  args.schoolTag = await promptValue(
148
187
  ask,
149
- `学校标签(${SCHOOL_TAG_OPTIONS.join("/")},默认: ${args.schoolTag}): `,
150
- (value) => SCHOOL_TAG_OPTIONS.includes(value) ? value : null,
151
- args.schoolTag
188
+ `学校标签(可多选,逗号/斜杠分隔;${SCHOOL_TAG_OPTIONS.join("/")},默认: ${current}): `,
189
+ (value) => parseSchoolSelection(value),
190
+ Array.isArray(args.schoolTag) && args.schoolTag.length > 0 ? args.schoolTag : ["不限"]
152
191
  );
153
192
  }
154
193
  if (!args.__provided.gender) {
@@ -159,6 +198,14 @@ async function enrichArgsFromPrompt(args) {
159
198
  args.gender
160
199
  );
161
200
  }
201
+ if (!args.__provided.recentNotView) {
202
+ args.recentNotView = await promptValue(
203
+ ask,
204
+ `近14天已看过滤(${RECENT_NOT_VIEW_OPTIONS.join("/")},默认: ${args.recentNotView}): `,
205
+ (value) => RECENT_NOT_VIEW_OPTIONS.includes(value) ? value : null,
206
+ args.recentNotView
207
+ );
208
+ }
162
209
  if (!args.__provided.degree || !Array.isArray(args.degree) || args.degree.length === 0) {
163
210
  const current = Array.isArray(args.degree) && args.degree.length > 0 ? args.degree.join(",") : "不限";
164
211
  args.degree = await promptValue(
@@ -168,14 +215,6 @@ async function enrichArgsFromPrompt(args) {
168
215
  Array.isArray(args.degree) && args.degree.length > 0 ? args.degree : ["不限"]
169
216
  );
170
217
  }
171
- if (!args.__provided.recentNotView) {
172
- args.recentNotView = await promptValue(
173
- ask,
174
- `近14天已看过滤(${RECENT_NOT_VIEW_OPTIONS.join("/")},默认: ${args.recentNotView}): `,
175
- (value) => RECENT_NOT_VIEW_OPTIONS.includes(value) ? value : null,
176
- args.recentNotView
177
- );
178
- }
179
218
  if (!args.__provided.port) {
180
219
  args.port = await promptValue(
181
220
  ask,
@@ -361,11 +400,10 @@ class RecommendSearchCli {
361
400
  const rect = el.getBoundingClientRect();
362
401
  return rect.width > 2 && rect.height > 2;
363
402
  };
364
- const school = doc.querySelector('.check-box.school');
365
- const degree = doc.querySelector('.check-box.degree');
366
- const gender = doc.querySelector('.check-box.gender');
367
- const recent = doc.querySelector('.check-box.recentNotView');
368
- return Boolean((school && degree && gender && recent) || isVisible(panel));
403
+ const groups = Array.from(doc.querySelectorAll('.check-box'));
404
+ const visibleGroups = groups.filter((group) => isVisible(group));
405
+ if (visibleGroups.length >= 2) return true;
406
+ return Boolean(isVisible(panel) && visibleGroups.length >= 1);
369
407
  })()`);
370
408
  return result === true;
371
409
  }
@@ -522,11 +560,46 @@ class RecommendSearchCli {
522
560
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
523
561
  }
524
562
  const doc = frame.contentDocument;
525
- const group = doc.querySelector('.check-box.' + groupClass);
563
+ const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
564
+ const groupCandidates = Array.from(doc.querySelectorAll('.check-box'));
565
+ const getOptionSet = (group) => new Set(
566
+ Array.from(group.querySelectorAll('.default.option, .options .option, .option'))
567
+ .map((item) => normalize(item.textContent))
568
+ .filter(Boolean)
569
+ );
570
+ const findGroup = () => {
571
+ const direct = doc.querySelector('.check-box.' + groupClass);
572
+ if (direct) return direct;
573
+ if (groupClass === 'school') {
574
+ return groupCandidates.find((group) => {
575
+ const set = getOptionSet(group);
576
+ return set.has('985') || set.has('211') || set.has('双一流院校');
577
+ }) || null;
578
+ }
579
+ if (groupClass === 'degree') {
580
+ return groupCandidates.find((group) => {
581
+ const set = getOptionSet(group);
582
+ return set.has('大专') || set.has('本科') || set.has('硕士') || set.has('博士');
583
+ }) || null;
584
+ }
585
+ if (groupClass === 'gender') {
586
+ return groupCandidates.find((group) => {
587
+ const set = getOptionSet(group);
588
+ return set.has('男') || set.has('女');
589
+ }) || null;
590
+ }
591
+ if (groupClass === 'recentNotView') {
592
+ return groupCandidates.find((group) => {
593
+ const set = getOptionSet(group);
594
+ return set.has('近14天没有');
595
+ }) || null;
596
+ }
597
+ return null;
598
+ };
599
+ const group = findGroup();
526
600
  if (!group) {
527
601
  return { ok: false, error: 'GROUP_NOT_FOUND' };
528
602
  }
529
- const normalize = (value) => String(value || '').replace(/\s+/g, '').trim();
530
603
  const frameRect = frame.getBoundingClientRect();
531
604
  const getPoint = (el) => {
532
605
  const rect = el.getBoundingClientRect();
@@ -555,19 +628,191 @@ class RecommendSearchCli {
555
628
  })(${JSON.stringify(groupClass)}, ${JSON.stringify(label)})`);
556
629
  }
557
630
 
631
+ async ensureGroupReady(groupClass) {
632
+ return this.evaluate(`((groupClass) => {
633
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
634
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
635
+ || document.querySelector('iframe');
636
+ if (!frame || !frame.contentDocument) {
637
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
638
+ }
639
+ const doc = frame.contentDocument;
640
+ const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
641
+ const groupCandidates = Array.from(doc.querySelectorAll('.check-box'));
642
+ const getOptionSet = (group) => new Set(
643
+ Array.from(group.querySelectorAll('.default.option, .options .option, .option'))
644
+ .map((item) => normalize(item.textContent))
645
+ .filter(Boolean)
646
+ );
647
+ const findGroup = () => {
648
+ const direct = doc.querySelector('.check-box.' + groupClass);
649
+ if (direct) return direct;
650
+ if (groupClass === 'school') {
651
+ return groupCandidates.find((group) => {
652
+ const set = getOptionSet(group);
653
+ return set.has('985') || set.has('211') || set.has('双一流院校');
654
+ }) || null;
655
+ }
656
+ if (groupClass === 'degree') {
657
+ return groupCandidates.find((group) => {
658
+ const set = getOptionSet(group);
659
+ return set.has('大专') || set.has('本科') || set.has('硕士') || set.has('博士');
660
+ }) || null;
661
+ }
662
+ if (groupClass === 'gender') {
663
+ return groupCandidates.find((group) => {
664
+ const set = getOptionSet(group);
665
+ return set.has('男') || set.has('女');
666
+ }) || null;
667
+ }
668
+ if (groupClass === 'recentNotView') {
669
+ return groupCandidates.find((group) => {
670
+ const set = getOptionSet(group);
671
+ return set.has('近14天没有');
672
+ }) || null;
673
+ }
674
+ return null;
675
+ };
676
+
677
+ const scrollGroupIntoView = (group) => {
678
+ try {
679
+ group.scrollIntoView({ behavior: 'instant', block: 'center' });
680
+ } catch {
681
+ try { group.scrollIntoView({ block: 'center' }); } catch {}
682
+ }
683
+ };
684
+
685
+ let group = findGroup();
686
+ if (group) {
687
+ scrollGroupIntoView(group);
688
+ return { ok: true, found: true, scrolled: false };
689
+ }
690
+
691
+ const topScroller = doc.querySelector('.recommend-filter.op-filter .filter-panel .top')
692
+ || doc.querySelector('.recommend-filter.op-filter .top')
693
+ || doc.querySelector('.recommend-filter.op-filter .filter-panel');
694
+ if (!topScroller) {
695
+ return { ok: false, error: 'FILTER_SCROLL_CONTAINER_NOT_FOUND' };
696
+ }
697
+ const maxScrollTop = Math.max(0, topScroller.scrollHeight - topScroller.clientHeight);
698
+ const steps = 14;
699
+ for (let index = 0; index <= steps; index += 1) {
700
+ const nextTop = maxScrollTop <= 0 ? 0 : Math.round((maxScrollTop * index) / steps);
701
+ topScroller.scrollTop = nextTop;
702
+ group = findGroup();
703
+ if (group) {
704
+ scrollGroupIntoView(group);
705
+ return { ok: true, found: true, scrolled: true, step: index };
706
+ }
707
+ }
708
+ return { ok: false, error: 'GROUP_NOT_FOUND' };
709
+ })(${JSON.stringify(groupClass)})`);
710
+ }
711
+
558
712
  async selectOption(groupClass, label) {
559
- const option = await this.getOptionInfo(groupClass, label);
713
+ let option = await this.getOptionInfo(groupClass, label);
714
+ if (!option?.ok && option?.error === "GROUP_NOT_FOUND") {
715
+ await this.openFilterPanel();
716
+ const ensure = await this.ensureGroupReady(groupClass);
717
+ if (!ensure?.ok) {
718
+ throw new Error(ensure?.error || "GROUP_NOT_FOUND");
719
+ }
720
+ await sleep(humanDelay(180, 60));
721
+ option = await this.getOptionInfo(groupClass, label);
722
+ }
560
723
  if (!option?.ok) {
561
724
  throw new Error(option?.error || 'OPTION_NOT_FOUND');
562
725
  }
563
726
  if (option.alreadySelected) {
564
727
  return;
565
728
  }
729
+ const domClick = await this.clickOptionBySelector(groupClass, label);
730
+ if (!domClick?.ok) {
731
+ throw new Error(domClick?.error || "OPTION_DOM_CLICK_FAILED");
732
+ }
733
+ if (await this.waitOptionSelected(groupClass, label, 10)) {
734
+ return;
735
+ }
736
+
566
737
  await this.simulateHumanClick(option.x, option.y);
567
- await sleep(humanDelay(300, 80));
738
+ if (!(await this.waitOptionSelected(groupClass, label, 10))) {
739
+ throw new Error("OPTION_SELECTION_NOT_APPLIED");
740
+ }
568
741
  }
569
742
 
570
- async getDegreeFilterState() {
743
+ async clickOptionBySelector(groupClass, label) {
744
+ return this.evaluate(`((groupClass, label) => {
745
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
746
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
747
+ || document.querySelector('iframe');
748
+ if (!frame || !frame.contentDocument) {
749
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
750
+ }
751
+ const doc = frame.contentDocument;
752
+ const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
753
+ const groupCandidates = Array.from(doc.querySelectorAll('.check-box'));
754
+ const getOptionSet = (group) => new Set(
755
+ Array.from(group.querySelectorAll('.default.option, .options .option, .option'))
756
+ .map((item) => normalize(item.textContent))
757
+ .filter(Boolean)
758
+ );
759
+ const findGroup = () => {
760
+ const direct = doc.querySelector('.check-box.' + groupClass);
761
+ if (direct) return direct;
762
+ if (groupClass === 'school') {
763
+ return groupCandidates.find((group) => {
764
+ const set = getOptionSet(group);
765
+ return set.has('985') || set.has('211') || set.has('双一流院校');
766
+ }) || null;
767
+ }
768
+ if (groupClass === 'degree') {
769
+ return groupCandidates.find((group) => {
770
+ const set = getOptionSet(group);
771
+ return set.has('大专') || set.has('本科') || set.has('硕士') || set.has('博士');
772
+ }) || null;
773
+ }
774
+ if (groupClass === 'gender') {
775
+ return groupCandidates.find((group) => {
776
+ const set = getOptionSet(group);
777
+ return set.has('男') || set.has('女');
778
+ }) || null;
779
+ }
780
+ if (groupClass === 'recentNotView') {
781
+ return groupCandidates.find((group) => {
782
+ const set = getOptionSet(group);
783
+ return set.has('近14天没有');
784
+ }) || null;
785
+ }
786
+ return null;
787
+ };
788
+ const group = findGroup();
789
+ if (!group) {
790
+ return { ok: false, error: 'GROUP_NOT_FOUND' };
791
+ }
792
+ const options = Array.from(group.querySelectorAll('.options .option, .option'));
793
+ const target = label === '不限'
794
+ ? (group.querySelector('.default.option') || options.find((item) => normalize(item.textContent) === '不限'))
795
+ : options.find((item) => normalize(item.textContent) === normalize(label));
796
+ if (!target) {
797
+ return { ok: false, error: 'OPTION_NOT_FOUND' };
798
+ }
799
+ target.click();
800
+ return { ok: true };
801
+ })(${JSON.stringify(groupClass)}, ${JSON.stringify(label)})`);
802
+ }
803
+
804
+ async waitOptionSelected(groupClass, label, rounds = 8) {
805
+ for (let index = 0; index < rounds; index += 1) {
806
+ const state = await this.getOptionInfo(groupClass, label);
807
+ if (state?.ok && state.alreadySelected) {
808
+ return true;
809
+ }
810
+ await sleep(120 + index * 40);
811
+ }
812
+ return false;
813
+ }
814
+
815
+ async getSchoolFilterState() {
571
816
  return this.evaluate(`(() => {
572
817
  const frame = document.querySelector('iframe[name="recommendFrame"]')
573
818
  || document.querySelector('iframe[src*="/web/frame/recommend/"]')
@@ -576,11 +821,88 @@ class RecommendSearchCli {
576
821
  return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
577
822
  }
578
823
  const doc = frame.contentDocument;
579
- const group = doc.querySelector('.check-box.degree');
824
+ const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
825
+ const groups = Array.from(doc.querySelectorAll('.check-box'));
826
+ const group = doc.querySelector('.check-box.school')
827
+ || groups.find((item) => {
828
+ const set = new Set(
829
+ Array.from(item.querySelectorAll('.default.option, .options .option, .option'))
830
+ .map((node) => normalize(node.textContent))
831
+ .filter(Boolean)
832
+ );
833
+ return set.has('985') || set.has('211') || set.has('双一流院校');
834
+ });
580
835
  if (!group) {
581
836
  return { ok: false, error: 'GROUP_NOT_FOUND' };
582
837
  }
838
+ const labels = ${JSON.stringify(SCHOOL_TAG_OPTIONS)};
839
+ const activeLabels = labels.filter((label) => {
840
+ const node = Array.from(group.querySelectorAll('.options .option, .option'))
841
+ .find((item) => normalize(item.textContent) === normalize(label));
842
+ return Boolean(node && node.classList.contains('active'));
843
+ });
844
+ const defaultOption = group.querySelector('.default.option');
845
+ return {
846
+ ok: true,
847
+ defaultActive: Boolean(defaultOption && defaultOption.classList.contains('active')),
848
+ activeLabels
849
+ };
850
+ })()`);
851
+ }
852
+
853
+ async selectSchoolFilter(labels) {
854
+ const ensure = await this.ensureGroupReady("school");
855
+ if (!ensure?.ok) {
856
+ throw new Error(ensure?.error || "GROUP_NOT_FOUND");
857
+ }
858
+
859
+ const targetLabels = Array.isArray(labels) && labels.length > 0 ? labels : ["不限"];
860
+ if (targetLabels.includes("不限")) {
861
+ await this.selectOption("school", "不限");
862
+ return;
863
+ }
864
+
865
+ const currentState = await this.getSchoolFilterState();
866
+ if (!currentState?.ok) {
867
+ throw new Error(currentState?.error || "SCHOOL_FILTER_STATE_FAILED");
868
+ }
869
+ const current = sortSchoolSelection(currentState.activeLabels || []);
870
+ const desired = sortSchoolSelection(targetLabels);
871
+ const same =
872
+ !currentState.defaultActive
873
+ && current.length === desired.length
874
+ && current.every((value, index) => value === desired[index]);
875
+ if (same) return;
876
+
877
+ await this.selectOption("school", "不限");
878
+ for (const label of desired) {
879
+ await this.selectOption("school", label);
880
+ }
881
+ }
882
+
883
+ async getDegreeFilterState() {
884
+ return this.evaluate(`(() => {
885
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
886
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
887
+ || document.querySelector('iframe');
888
+ if (!frame || !frame.contentDocument) {
889
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
890
+ }
891
+ const doc = frame.contentDocument;
583
892
  const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
893
+ const groups = Array.from(doc.querySelectorAll('.check-box'));
894
+ const group = doc.querySelector('.check-box.degree')
895
+ || groups.find((item) => {
896
+ const set = new Set(
897
+ Array.from(item.querySelectorAll('.default.option, .options .option, .option'))
898
+ .map((node) => normalize(node.textContent))
899
+ .filter(Boolean)
900
+ );
901
+ return set.has('大专') || set.has('本科') || set.has('硕士') || set.has('博士');
902
+ });
903
+ if (!group) {
904
+ return { ok: false, error: 'GROUP_NOT_FOUND' };
905
+ }
584
906
  const labels = ${JSON.stringify(DEGREE_OPTIONS)};
585
907
  const activeLabels = labels.filter((label) => {
586
908
  const node = Array.from(group.querySelectorAll('.options .option'))
@@ -597,6 +919,11 @@ class RecommendSearchCli {
597
919
  }
598
920
 
599
921
  async selectDegreeFilter(labels) {
922
+ const ensure = await this.ensureGroupReady("degree");
923
+ if (!ensure?.ok) {
924
+ throw new Error(ensure?.error || "GROUP_NOT_FOUND");
925
+ }
926
+
600
927
  const targetLabels = Array.isArray(labels) && labels.length > 0 ? labels : ["不限"];
601
928
  if (targetLabels.includes("不限")) {
602
929
  await this.selectOption("degree", "不限");
@@ -670,11 +997,14 @@ class RecommendSearchCli {
670
997
  console.log(JSON.stringify({
671
998
  status: "COMPLETED",
672
999
  result: {
673
- usage: "node src/cli.js --school-tag 985 --degree 本科及以上 --gender 男 --recent-not-view 近14天没有 --port 9222"
1000
+ usage: "node src/cli.js --school-tag 985/211 --degree 本科及以上 --gender 男 --recent-not-view 近14天没有 --port 9222"
674
1001
  }
675
1002
  }));
676
1003
  return;
677
1004
  }
1005
+ if (!Array.isArray(this.args.schoolTag) || this.args.schoolTag.length === 0) {
1006
+ throw new Error("INVALID_SCHOOL_TAG_INPUT");
1007
+ }
678
1008
  if (!Array.isArray(this.args.degree) || this.args.degree.length === 0) {
679
1009
  throw new Error("INVALID_DEGREE_INPUT");
680
1010
  }
@@ -687,10 +1017,10 @@ class RecommendSearchCli {
687
1017
  }
688
1018
 
689
1019
  await this.openFilterPanel();
690
- await this.selectOption("school", this.args.schoolTag);
691
- await this.selectDegreeFilter(this.args.degree);
1020
+ await this.selectSchoolFilter(this.args.schoolTag);
692
1021
  await this.selectOption("gender", this.args.gender);
693
1022
  await this.selectOption("recentNotView", this.args.recentNotView);
1023
+ await this.selectDegreeFilter(this.args.degree);
694
1024
  await this.closeFilterPanel();
695
1025
  const candidateInfo = await this.waitForCandidateCountStable();
696
1026