@reconcrap/boss-recommend-mcp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,8 @@ MCP 工具名:`run_recommend_pipeline`
20
20
 
21
21
  - 页面目标固定为 `https://www.zhipin.com/web/chat/recommend`
22
22
  - 支持推荐页原生筛选:学校标签 / 性别 / 近14天没有
23
+ - 支持推荐页原生筛选:学校标签 / 学历 / 性别 / 近14天没有
24
+ - 学历支持单选与多选语义:如“本科及以上”会展开为 `本科/硕士/博士`;如“大专、本科”只勾选这两项
23
25
  - `post_action` 必须在每次完整运行开始时确认一次
24
26
  - `target_count` 会在每次运行开始时询问一次(可留空,不设上限)
25
27
  - 当 `post_action=greet` 时,必须在运行开始时确认 `max_greet_count`
@@ -102,6 +104,7 @@ node src/cli.js run --instruction-file request.txt --confirmation-file confirmat
102
104
  },
103
105
  "overrides": {
104
106
  "school_tag": "211",
107
+ "degree": ["本科", "硕士", "博士"],
105
108
  "gender": "女",
106
109
  "recent_not_view": "近14天没有",
107
110
  "criteria": "候选人需要有 AI Agent 或 MCP 工具开发经验",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -50,6 +50,7 @@ description: "Use when users ask to run Boss recommend-page filtering and screen
50
50
  - `max_greet_count_value` (integer)
51
51
  - `overrides`
52
52
  - `school_tag`
53
+ - `degree`(可传单值或数组;如“本科及以上”应展开为 `["本科","硕士","博士"]`)
53
54
  - `gender`
54
55
  - `recent_not_view`
55
56
  - `criteria`
package/src/adapters.js CHANGED
@@ -36,6 +36,15 @@ function parsePositiveInteger(raw) {
36
36
  return Number.isFinite(value) && value > 0 ? value : null;
37
37
  }
38
38
 
39
+ function serializeDegreeSelection(value) {
40
+ if (Array.isArray(value)) {
41
+ const normalized = value.map((item) => String(item || "").trim()).filter(Boolean);
42
+ return normalized.length ? normalized.join(",") : "不限";
43
+ }
44
+ const normalized = String(value || "").trim();
45
+ return normalized || "不限";
46
+ }
47
+
39
48
  function resolveScreenConfigPath(workspaceRoot) {
40
49
  const envConfigPath = process.env.BOSS_RECOMMEND_SCREEN_CONFIG
41
50
  ? path.resolve(process.env.BOSS_RECOMMEND_SCREEN_CONFIG)
@@ -506,6 +515,8 @@ export async function runRecommendSearchCli({ workspaceRoot, searchParams }) {
506
515
  cliPath,
507
516
  "--school-tag",
508
517
  searchParams.school_tag,
518
+ "--degree",
519
+ serializeDegreeSelection(searchParams.degree),
509
520
  "--gender",
510
521
  searchParams.gender,
511
522
  "--recent-not-view",
package/src/index.js CHANGED
@@ -64,6 +64,23 @@ function createToolSchema() {
64
64
  type: "string",
65
65
  enum: ["不限", "985", "211", "双一流院校", "留学", "国内外名校", "公办本科"]
66
66
  },
67
+ degree: {
68
+ oneOf: [
69
+ {
70
+ type: "string",
71
+ enum: ["不限", "初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"]
72
+ },
73
+ {
74
+ type: "array",
75
+ items: {
76
+ type: "string",
77
+ enum: ["不限", "初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"]
78
+ },
79
+ minItems: 1,
80
+ uniqueItems: true
81
+ }
82
+ ]
83
+ },
67
84
  gender: {
68
85
  type: "string",
69
86
  enum: ["不限", "男", "女"]
package/src/parser.js CHANGED
@@ -7,6 +7,25 @@ const SCHOOL_TAG_OPTIONS = [
7
7
  "国内外名校",
8
8
  "公办本科"
9
9
  ];
10
+ const DEGREE_OPTIONS = [
11
+ "不限",
12
+ "初中及以下",
13
+ "中专/中技",
14
+ "高中",
15
+ "大专",
16
+ "本科",
17
+ "硕士",
18
+ "博士"
19
+ ];
20
+ const DEGREE_ORDER = [
21
+ "初中及以下",
22
+ "中专/中技",
23
+ "高中",
24
+ "大专",
25
+ "本科",
26
+ "硕士",
27
+ "博士"
28
+ ];
10
29
  const GENDER_OPTIONS = ["不限", "男", "女"];
11
30
  const RECENT_NOT_VIEW_OPTIONS = ["不限", "近14天没有"];
12
31
  const POST_ACTION_OPTIONS = ["favorite", "greet"];
@@ -30,6 +49,15 @@ const SCHOOL_TAG_PATTERNS = [
30
49
  { label: "国内外名校", pattern: /国内外名校|名校/i },
31
50
  { label: "公办本科", pattern: /公办本科/i }
32
51
  ];
52
+ const DEGREE_PATTERNS = [
53
+ { label: "初中及以下", pattern: /初中及以下|初中以下/i },
54
+ { label: "中专/中技", pattern: /中专\s*\/\s*中技|中专中技|中专|中技/i },
55
+ { label: "高中", pattern: /(?:学历|教育|要求)?[^。;;\n]{0,8}高中/i },
56
+ { label: "大专", pattern: /(?:学历|教育|要求)?[^。;;\n]{0,8}(?:大专|专科)/i },
57
+ { label: "本科", pattern: /(?:学历|教育|要求)?[^。;;\n]{0,8}(?:本科|学士)/i },
58
+ { label: "硕士", pattern: /(?:学历|教育|要求)?[^。;;\n]{0,8}(?:硕士|研究生)/i },
59
+ { label: "博士", pattern: /(?:学历|教育|要求)?[^。;;\n]{0,8}博士/i }
60
+ ];
33
61
  const GENDER_PATTERNS = [
34
62
  { label: "男", pattern: /(?:性别|候选人|人选)?[^。;;\n]{0,8}(?:男生|男性|男)/i },
35
63
  { label: "女", pattern: /(?:性别|候选人|人选)?[^。;;\n]{0,8}(?:女生|女性|女)/i }
@@ -57,6 +85,7 @@ const MAX_GREET_COUNT_PATTERNS = [
57
85
  ];
58
86
  const FILTER_CLAUSE_PATTERNS = [
59
87
  /学校标签|院校标签|985|211|双一流|留学|国内外名校|公办本科/i,
88
+ /学历|学位|教育|初中及以下|中专|中技|高中|大专|专科|本科|硕士|研究生|博士/i,
60
89
  /性别|男生|女生|男性|女性|男\b|女\b/i,
61
90
  /近?14天(?:内)?没有|近?14天(?:内)?没看过|近?14天(?:内)?未查看|过滤[^。;;\n]{0,12}14天|排除[^。;;\n]{0,12}14天/i,
62
91
  /目标(?:处理|筛选)?(?:人数|数量)?|至少(?:处理|筛选)|(?:处理|筛选)\s*\d+\s*(?:位|人)/i,
@@ -89,6 +118,75 @@ function normalizeSchoolTag(value) {
89
118
  return null;
90
119
  }
91
120
 
121
+ function normalizeDegree(value) {
122
+ const normalized = normalizeText(value);
123
+ if (!normalized) return null;
124
+ if (normalized === "专科") return "大专";
125
+ if (normalized === "研究生") return "硕士";
126
+ if (normalized === "中专" || normalized === "中技" || normalized === "中专中技") return "中专/中技";
127
+ return DEGREE_OPTIONS.includes(normalized) ? normalized : null;
128
+ }
129
+
130
+ function sortDegreeSelections(values) {
131
+ return uniqueList(values).sort((left, right) => {
132
+ const leftIndex = DEGREE_ORDER.indexOf(left);
133
+ const rightIndex = DEGREE_ORDER.indexOf(right);
134
+ return leftIndex - rightIndex;
135
+ });
136
+ }
137
+
138
+ function expandDegreeAtOrAbove(value) {
139
+ const normalized = normalizeDegree(value);
140
+ if (!normalized || normalized === "不限") return [];
141
+ const startIndex = DEGREE_ORDER.indexOf(normalized);
142
+ if (startIndex === -1) return [];
143
+ return DEGREE_ORDER.slice(startIndex);
144
+ }
145
+
146
+ function parseDegreeSelectionsFromText(text) {
147
+ const normalizedText = normalizeText(text);
148
+ if (!normalizedText) return [];
149
+ if (/(?:学历|学位|教育)[^。;;\n]{0,6}不限|不限[^。;;\n]{0,6}(?:学历|学位|教育)/i.test(normalizedText)) {
150
+ return ["不限"];
151
+ }
152
+
153
+ const selected = [];
154
+ const atOrAbovePattern = /(初中及以下|中专\/中技|中专中技|中专|中技|高中|大专|专科|本科|硕士|研究生|博士)\s*(?:及|或)?以上/g;
155
+ let match;
156
+ while ((match = atOrAbovePattern.exec(normalizedText)) !== null) {
157
+ selected.push(...expandDegreeAtOrAbove(match[1]));
158
+ }
159
+
160
+ for (const { label, pattern } of DEGREE_PATTERNS) {
161
+ if (pattern.test(normalizedText)) {
162
+ selected.push(label);
163
+ }
164
+ }
165
+ return sortDegreeSelections(selected);
166
+ }
167
+
168
+ function normalizeDegreeSelections(input) {
169
+ if (Array.isArray(input)) {
170
+ const normalized = sortDegreeSelections(input.map((item) => normalizeDegree(item)).filter(Boolean));
171
+ if (!normalized.length) return null;
172
+ return normalized.includes("不限") ? ["不限"] : normalized;
173
+ }
174
+
175
+ const text = normalizeText(input);
176
+ if (!text) return null;
177
+ if (text.includes("以上")) {
178
+ const fromText = parseDegreeSelectionsFromText(text);
179
+ if (fromText.length) return fromText;
180
+ }
181
+ const parts = text.split(/[,,、/|]/).map((item) => normalizeDegree(item)).filter(Boolean);
182
+ if (parts.length) {
183
+ const normalized = sortDegreeSelections(parts);
184
+ return normalized.includes("不限") ? ["不限"] : normalized;
185
+ }
186
+ const single = normalizeDegree(text);
187
+ return single ? [single] : null;
188
+ }
189
+
92
190
  function normalizeGender(value) {
93
191
  const normalized = normalizeText(value);
94
192
  if (!normalized) return null;
@@ -142,6 +240,10 @@ function extractGender(text) {
142
240
  return null;
143
241
  }
144
242
 
243
+ function extractDegrees(text) {
244
+ return parseDegreeSelectionsFromText(text);
245
+ }
246
+
145
247
  function extractRecentNotView(text) {
146
248
  for (const pattern of RECENT_NOT_VIEW_NEGATIVE_PATTERNS) {
147
249
  if (pattern.test(text)) {
@@ -298,13 +400,22 @@ function collectSuspiciousFields({ detectedSchoolTags }) {
298
400
  export function parseRecommendInstruction({ instruction, confirmation, overrides }) {
299
401
  const text = normalizeText(instruction);
300
402
  const detectedSchoolTags = extractSchoolTags(text);
403
+ const detectedDegrees = extractDegrees(text);
301
404
  const overrideSchoolTag = normalizeSchoolTag(overrides?.school_tag);
405
+ const overrideDegrees = normalizeDegreeSelections(overrides?.degree);
302
406
  const overrideGender = normalizeGender(overrides?.gender);
303
407
  const overrideRecentNotView = normalizeRecentNotView(overrides?.recent_not_view);
304
408
  const overrideCriteria = overrides?.criteria;
305
409
 
306
410
  const searchParams = {
307
411
  school_tag: overrideSchoolTag || detectedSchoolTags[0] || "不限",
412
+ degree: (
413
+ (Array.isArray(overrideDegrees) && overrideDegrees.length > 0
414
+ ? overrideDegrees
415
+ : Array.isArray(detectedDegrees) && detectedDegrees.length > 0
416
+ ? detectedDegrees
417
+ : ["不限"])
418
+ ),
308
419
  gender: overrideGender || extractGender(text) || "不限",
309
420
  recent_not_view: overrideRecentNotView || extractRecentNotView(text) || "不限"
310
421
  };
@@ -415,6 +526,7 @@ export function parseRecommendInstruction({ instruction, confirmation, overrides
415
526
  }
416
527
 
417
528
  export {
529
+ DEGREE_OPTIONS,
418
530
  GENDER_OPTIONS,
419
531
  POST_ACTION_LABELS,
420
532
  POST_ACTION_OPTIONS,
@@ -9,6 +9,7 @@ function testNeedConfirmationIncludesPostAction() {
9
9
  });
10
10
 
11
11
  assert.equal(result.searchParams.school_tag, "985");
12
+ assert.deepEqual(result.searchParams.degree, ["不限"]);
12
13
  assert.equal(result.searchParams.gender, "男");
13
14
  assert.equal(result.searchParams.recent_not_view, "近14天没有");
14
15
  assert.equal(result.screenParams.criteria, "有大模型平台经验");
@@ -33,12 +34,14 @@ function testConfirmedPostActionAndOverrides() {
33
34
  },
34
35
  overrides: {
35
36
  school_tag: "211",
37
+ degree: "本科",
36
38
  recent_not_view: "近14天没有",
37
39
  target_count: 12
38
40
  }
39
41
  });
40
42
 
41
43
  assert.equal(result.searchParams.school_tag, "211");
44
+ assert.deepEqual(result.searchParams.degree, ["本科"]);
42
45
  assert.equal(result.searchParams.gender, "女");
43
46
  assert.equal(result.searchParams.recent_not_view, "近14天没有");
44
47
  assert.equal(result.screenParams.criteria, "有多模态经历");
@@ -65,10 +68,53 @@ function testMultipleSchoolTagsMarkedSuspicious() {
65
68
  });
66
69
 
67
70
  assert.equal(result.searchParams.school_tag, "985");
71
+ assert.deepEqual(result.searchParams.degree, ["不限"]);
68
72
  assert.equal(result.suspicious_fields.length, 1);
69
73
  assert.equal(result.suspicious_fields[0].field, "school_tag");
70
74
  }
71
75
 
76
+ function testDegreeCanBeExtracted() {
77
+ const result = parseRecommendInstruction({
78
+ instruction: "推荐页筛选本科女生,近14天没有,有大模型项目经验",
79
+ confirmation: null,
80
+ overrides: null
81
+ });
82
+
83
+ assert.deepEqual(result.searchParams.degree, ["本科"]);
84
+ }
85
+
86
+ function testDegreeAtOrAboveExpansion() {
87
+ const result = parseRecommendInstruction({
88
+ instruction: "推荐页筛选大专及以上,近14天没有,有Agent经验",
89
+ confirmation: null,
90
+ overrides: null
91
+ });
92
+
93
+ assert.deepEqual(result.searchParams.degree, ["大专", "本科", "硕士", "博士"]);
94
+ }
95
+
96
+ function testDegreeExplicitListOnly() {
97
+ const result = parseRecommendInstruction({
98
+ instruction: "推荐页筛选大专、本科,近14天没有,有Agent经验",
99
+ confirmation: null,
100
+ overrides: null
101
+ });
102
+
103
+ assert.deepEqual(result.searchParams.degree, ["大专", "本科"]);
104
+ }
105
+
106
+ function testDegreeOverrideCanBeArray() {
107
+ const result = parseRecommendInstruction({
108
+ instruction: "推荐页筛选本科,近14天没有,有Agent经验",
109
+ confirmation: null,
110
+ overrides: {
111
+ degree: ["大专", "本科"]
112
+ }
113
+ });
114
+
115
+ assert.deepEqual(result.searchParams.degree, ["大专", "本科"]);
116
+ }
117
+
72
118
  function testCriteriaCanBeProvidedViaOverrides() {
73
119
  const result = parseRecommendInstruction({
74
120
  instruction: "推荐页筛选211女生",
@@ -190,6 +236,10 @@ function main() {
190
236
  testNeedConfirmationIncludesPostAction();
191
237
  testConfirmedPostActionAndOverrides();
192
238
  testMultipleSchoolTagsMarkedSuspicious();
239
+ testDegreeCanBeExtracted();
240
+ testDegreeAtOrAboveExpansion();
241
+ testDegreeExplicitListOnly();
242
+ testDegreeOverrideCanBeArray();
193
243
  testCriteriaCanBeProvidedViaOverrides();
194
244
  testMissingCriteriaTriggersNeedInput();
195
245
  testMcpMentionShouldStayInCriteria();
@@ -5,6 +5,7 @@ function createParsed(overrides = {}) {
5
5
  return {
6
6
  searchParams: {
7
7
  school_tag: "985",
8
+ degree: ["本科"],
8
9
  gender: "男",
9
10
  recent_not_view: "近14天没有"
10
11
  },
@@ -180,6 +181,7 @@ async function testCompletedPipeline() {
180
181
  assert.equal(result.result.processed_count, 10);
181
182
  assert.equal(result.result.passed_count, 3);
182
183
  assert.equal(result.result.post_action, "favorite");
184
+ assert.deepEqual(result.result.applied_filters.degree, ["本科"]);
183
185
  assert.equal(calls[0].type, "search");
184
186
  assert.equal(calls[1].type, "screen");
185
187
  }
@@ -53,7 +53,18 @@ function parseArgs(argv) {
53
53
  output: path.resolve(process.cwd(), `筛选结果_${Date.now()}.csv`),
54
54
  postAction: null,
55
55
  postActionConfirmed: null,
56
- help: false
56
+ help: false,
57
+ __provided: {
58
+ baseUrl: false,
59
+ apiKey: false,
60
+ model: false,
61
+ criteria: false,
62
+ targetCount: false,
63
+ maxGreetCount: false,
64
+ port: false,
65
+ postAction: false,
66
+ postActionConfirmed: false
67
+ }
57
68
  };
58
69
 
59
70
  for (let index = 0; index < argv.length; index += 1) {
@@ -61,12 +72,15 @@ function parseArgs(argv) {
61
72
  const next = argv[index + 1];
62
73
  if (token === "--baseurl" && next) {
63
74
  parsed.baseUrl = next;
75
+ parsed.__provided.baseUrl = true;
64
76
  index += 1;
65
77
  } else if (token === "--apikey" && next) {
66
78
  parsed.apiKey = next;
79
+ parsed.__provided.apiKey = true;
67
80
  index += 1;
68
81
  } else if (token === "--model" && next) {
69
82
  parsed.model = next;
83
+ parsed.__provided.model = true;
70
84
  index += 1;
71
85
  } else if (token === "--openai-organization" && next) {
72
86
  parsed.openaiOrganization = next;
@@ -76,24 +90,30 @@ function parseArgs(argv) {
76
90
  index += 1;
77
91
  } else if (token === "--criteria" && next) {
78
92
  parsed.criteria = next;
93
+ parsed.__provided.criteria = true;
79
94
  index += 1;
80
95
  } else if (token === "--targetCount" && next) {
81
96
  parsed.targetCount = parsePositiveInteger(next);
97
+ parsed.__provided.targetCount = true;
82
98
  index += 1;
83
99
  } else if (token === "--max-greet-count" && next) {
84
100
  parsed.maxGreetCount = parsePositiveInteger(next);
101
+ parsed.__provided.maxGreetCount = true;
85
102
  index += 1;
86
103
  } else if (token === "--port" && next) {
87
104
  parsed.port = parsePositiveInteger(next) || DEFAULT_PORT;
105
+ parsed.__provided.port = true;
88
106
  index += 1;
89
107
  } else if (token === "--output" && next) {
90
108
  parsed.output = path.resolve(next);
91
109
  index += 1;
92
110
  } else if (token === "--post-action" && next) {
93
111
  parsed.postAction = normalizePostAction(next);
112
+ parsed.__provided.postAction = true;
94
113
  index += 1;
95
114
  } else if (token === "--post-action-confirmed" && next) {
96
115
  parsed.postActionConfirmed = parseBoolean(next);
116
+ parsed.__provided.postActionConfirmed = true;
97
117
  index += 1;
98
118
  } else if (token === "--help" || token === "-h") {
99
119
  parsed.help = true;
@@ -103,6 +123,109 @@ function parseArgs(argv) {
103
123
  return parsed;
104
124
  }
105
125
 
126
+ function isInteractiveTTY() {
127
+ return Boolean(process.stdin?.isTTY && process.stdout?.isTTY);
128
+ }
129
+
130
+ async function askWithValidation(ask, question, validate, options = {}) {
131
+ const { allowEmpty = false, defaultValue = undefined } = options;
132
+ while (true) {
133
+ const answer = normalizeText(await ask(question));
134
+ if (!answer) {
135
+ if (defaultValue !== undefined) return defaultValue;
136
+ if (allowEmpty) return null;
137
+ }
138
+ const validated = validate(answer);
139
+ if (validated !== null && validated !== undefined) return validated;
140
+ console.error("输入无效,请重试。");
141
+ }
142
+ }
143
+
144
+ async function promptMissingInputs(args) {
145
+ if (!isInteractiveTTY() || args.help) return args;
146
+
147
+ if (args.__provided.postAction && args.postAction && args.postActionConfirmed === null) {
148
+ args.postActionConfirmed = true;
149
+ }
150
+
151
+ const rl = readline.createInterface({
152
+ input: process.stdin,
153
+ output: process.stdout
154
+ });
155
+ const ask = (question) => new Promise((resolve) => rl.question(question, resolve));
156
+ try {
157
+ if (!normalizeText(args.criteria)) {
158
+ args.criteria = await askWithValidation(
159
+ ask,
160
+ "请输入筛选标准(--criteria): ",
161
+ (value) => normalizeText(value) || null
162
+ );
163
+ }
164
+ if (!normalizeText(args.baseUrl)) {
165
+ args.baseUrl = await askWithValidation(
166
+ ask,
167
+ "请输入模型接口 baseUrl(--baseurl,例如 https://api.openai.com/v1): ",
168
+ (value) => normalizeText(value) || null
169
+ );
170
+ }
171
+ if (!normalizeText(args.apiKey)) {
172
+ args.apiKey = await askWithValidation(
173
+ ask,
174
+ "请输入模型接口 apiKey(--apikey): ",
175
+ (value) => normalizeText(value) || null
176
+ );
177
+ }
178
+ if (!normalizeText(args.model)) {
179
+ args.model = await askWithValidation(
180
+ ask,
181
+ "请输入模型名(--model): ",
182
+ (value) => normalizeText(value) || null
183
+ );
184
+ }
185
+ if (args.targetCount === null) {
186
+ const targetCount = await askWithValidation(
187
+ ask,
188
+ "请输入目标筛选人数(--targetCount,可留空表示不设上限): ",
189
+ (value) => parsePositiveInteger(value),
190
+ { allowEmpty: true }
191
+ );
192
+ if (Number.isInteger(targetCount) && targetCount > 0) {
193
+ args.targetCount = targetCount;
194
+ }
195
+ }
196
+ if (!(args.postActionConfirmed === true && args.postAction)) {
197
+ args.postAction = await askWithValidation(
198
+ ask,
199
+ "本次通过人选统一执行什么动作?请输入 1(收藏) 或 2(直接沟通): ",
200
+ (value) => {
201
+ if (value === "1") return "favorite";
202
+ if (value === "2") return "greet";
203
+ return null;
204
+ }
205
+ );
206
+ args.postActionConfirmed = true;
207
+ }
208
+ if (args.postAction === "greet" && !(Number.isInteger(args.maxGreetCount) && args.maxGreetCount > 0)) {
209
+ args.maxGreetCount = await askWithValidation(
210
+ ask,
211
+ "本次最多打招呼多少位候选人?请输入正整数(--max-greet-count): ",
212
+ (value) => parsePositiveInteger(value)
213
+ );
214
+ }
215
+ if (!args.__provided.port) {
216
+ args.port = await askWithValidation(
217
+ ask,
218
+ `Chrome 调试端口(--port,默认: ${args.port}): `,
219
+ (value) => parsePositiveInteger(value),
220
+ { defaultValue: args.port }
221
+ );
222
+ }
223
+ return args;
224
+ } finally {
225
+ rl.close();
226
+ }
227
+ }
228
+
106
229
  function sleep(ms) {
107
230
  return new Promise((resolve) => setTimeout(resolve, ms));
108
231
  }
@@ -1605,7 +1728,8 @@ async function main() {
1605
1728
  return;
1606
1729
  }
1607
1730
 
1608
- const cli = new RecommendScreenCli(args);
1731
+ const finalArgs = await promptMissingInputs(args);
1732
+ const cli = new RecommendScreenCli(finalArgs);
1609
1733
  const result = await cli.run();
1610
1734
  console.log(JSON.stringify(result));
1611
1735
  }
@@ -1,22 +1,90 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
+ import readline from "node:readline";
3
4
  import CDP from "chrome-remote-interface";
4
5
 
5
6
  const DEFAULT_PORT = 9222;
6
7
  const RECOMMEND_URL_FRAGMENT = "/web/chat/recommend";
8
+ const SCHOOL_TAG_OPTIONS = ["不限", "985", "211", "双一流院校", "留学", "国内外名校", "公办本科"];
9
+ const DEGREE_OPTIONS = ["不限", "初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"];
10
+ const DEGREE_ORDER = ["初中及以下", "中专/中技", "高中", "大专", "本科", "硕士", "博士"];
11
+ const GENDER_OPTIONS = ["不限", "男", "女"];
12
+ const RECENT_NOT_VIEW_OPTIONS = ["不限", "近14天没有"];
13
+
14
+ function normalizeText(value) {
15
+ return String(value || "").replace(/\s+/g, " ").trim();
16
+ }
7
17
 
8
18
  function parsePositiveInteger(raw) {
9
19
  const value = Number.parseInt(String(raw || ""), 10);
10
20
  return Number.isFinite(value) && value > 0 ? value : null;
11
21
  }
12
22
 
23
+ function normalizeDegree(value) {
24
+ const normalized = normalizeText(value);
25
+ if (!normalized) return null;
26
+ if (normalized === "专科") return "大专";
27
+ if (normalized === "研究生") return "硕士";
28
+ if (normalized === "中专" || normalized === "中技" || normalized === "中专中技") return "中专/中技";
29
+ return DEGREE_OPTIONS.includes(normalized) ? normalized : null;
30
+ }
31
+
32
+ function sortDegreeSelection(values) {
33
+ return Array.from(new Set(values.filter(Boolean))).sort((left, right) => DEGREE_ORDER.indexOf(left) - DEGREE_ORDER.indexOf(right));
34
+ }
35
+
36
+ function expandDegreeAtOrAbove(value) {
37
+ const normalized = normalizeDegree(value);
38
+ if (!normalized || normalized === "不限") return [];
39
+ const index = DEGREE_ORDER.indexOf(normalized);
40
+ if (index === -1) return [];
41
+ return DEGREE_ORDER.slice(index);
42
+ }
43
+
44
+ function parseDegreeSelection(raw) {
45
+ const text = normalizeText(raw);
46
+ if (!text) return null;
47
+ if (text === "不限") return ["不限"];
48
+ if (/不限/.test(text) && !/(初中|中专|中技|高中|大专|专科|本科|硕士|研究生|博士)/.test(text)) {
49
+ return ["不限"];
50
+ }
51
+
52
+ const selected = [];
53
+ const atOrAbovePattern = /(初中及以下|中专\/中技|中专中技|中专|中技|高中|大专|专科|本科|硕士|研究生|博士)\s*(?:及|或)?以上/g;
54
+ let match;
55
+ while ((match = atOrAbovePattern.exec(text)) !== null) {
56
+ selected.push(...expandDegreeAtOrAbove(match[1]));
57
+ }
58
+
59
+ const chunks = text.split(/[,,、/|]/).map((item) => normalizeDegree(item)).filter(Boolean);
60
+ selected.push(...chunks);
61
+
62
+ for (const label of DEGREE_OPTIONS) {
63
+ if (label === "不限") continue;
64
+ if (text.includes(label)) {
65
+ selected.push(label);
66
+ }
67
+ }
68
+
69
+ const normalized = sortDegreeSelection(selected);
70
+ return normalized.length ? normalized : null;
71
+ }
72
+
13
73
  function parseArgs(argv) {
14
74
  const args = {
15
75
  schoolTag: "不限",
76
+ degree: ["不限"],
16
77
  gender: "不限",
17
78
  recentNotView: "不限",
18
79
  port: DEFAULT_PORT,
19
- help: false
80
+ help: false,
81
+ __provided: {
82
+ schoolTag: false,
83
+ degree: false,
84
+ gender: false,
85
+ recentNotView: false,
86
+ port: false
87
+ }
20
88
  };
21
89
 
22
90
  for (let index = 0; index < argv.length; index += 1) {
@@ -24,15 +92,23 @@ function parseArgs(argv) {
24
92
  const next = argv[index + 1];
25
93
  if (token === "--school-tag" && next) {
26
94
  args.schoolTag = next;
95
+ args.__provided.schoolTag = true;
96
+ index += 1;
97
+ } else if (token === "--degree" && next) {
98
+ args.degree = parseDegreeSelection(next);
99
+ args.__provided.degree = true;
27
100
  index += 1;
28
101
  } else if (token === "--gender" && next) {
29
102
  args.gender = next;
103
+ args.__provided.gender = true;
30
104
  index += 1;
31
105
  } else if (token === "--recent-not-view" && next) {
32
106
  args.recentNotView = next;
107
+ args.__provided.recentNotView = true;
33
108
  index += 1;
34
109
  } else if (token === "--port" && next) {
35
110
  args.port = parsePositiveInteger(next) || DEFAULT_PORT;
111
+ args.__provided.port = true;
36
112
  index += 1;
37
113
  } else if (token === "--help" || token === "-h") {
38
114
  args.help = true;
@@ -42,6 +118,78 @@ function parseArgs(argv) {
42
118
  return args;
43
119
  }
44
120
 
121
+ function isInteractiveTTY() {
122
+ return Boolean(process.stdin?.isTTY && process.stdout?.isTTY);
123
+ }
124
+
125
+ async function promptValue(ask, question, validate, defaultValue) {
126
+ while (true) {
127
+ const answer = normalizeText(await ask(question));
128
+ if (!answer && defaultValue !== undefined) return defaultValue;
129
+ const validated = validate(answer);
130
+ if (validated !== null && validated !== undefined) return validated;
131
+ console.error("输入无效,请重试。");
132
+ }
133
+ }
134
+
135
+ async function enrichArgsFromPrompt(args) {
136
+ 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;
138
+ if (!askTargets) return args;
139
+
140
+ const rl = readline.createInterface({
141
+ input: process.stdin,
142
+ output: process.stdout
143
+ });
144
+ const ask = (question) => new Promise((resolve) => rl.question(question, resolve));
145
+ try {
146
+ if (!args.__provided.schoolTag) {
147
+ args.schoolTag = await promptValue(
148
+ ask,
149
+ `学校标签(${SCHOOL_TAG_OPTIONS.join("/")},默认: ${args.schoolTag}): `,
150
+ (value) => SCHOOL_TAG_OPTIONS.includes(value) ? value : null,
151
+ args.schoolTag
152
+ );
153
+ }
154
+ if (!args.__provided.gender) {
155
+ args.gender = await promptValue(
156
+ ask,
157
+ `性别(${GENDER_OPTIONS.join("/")},默认: ${args.gender}): `,
158
+ (value) => GENDER_OPTIONS.includes(value) ? value : null,
159
+ args.gender
160
+ );
161
+ }
162
+ if (!args.__provided.degree || !Array.isArray(args.degree) || args.degree.length === 0) {
163
+ const current = Array.isArray(args.degree) && args.degree.length > 0 ? args.degree.join(",") : "不限";
164
+ args.degree = await promptValue(
165
+ ask,
166
+ `学历(可多选逗号分隔,支持“本科及以上”;默认: ${current}): `,
167
+ (value) => parseDegreeSelection(value),
168
+ Array.isArray(args.degree) && args.degree.length > 0 ? args.degree : ["不限"]
169
+ );
170
+ }
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
+ if (!args.__provided.port) {
180
+ args.port = await promptValue(
181
+ ask,
182
+ `Chrome 调试端口(默认: ${args.port}): `,
183
+ (value) => parsePositiveInteger(value),
184
+ args.port
185
+ );
186
+ }
187
+ return args;
188
+ } finally {
189
+ rl.close();
190
+ }
191
+ }
192
+
45
193
  function sleep(ms) {
46
194
  return new Promise((resolve) => setTimeout(resolve, ms));
47
195
  }
@@ -66,10 +214,6 @@ function generateBezierPath(start, end, steps = 18) {
66
214
  return path;
67
215
  }
68
216
 
69
- function normalizeText(value) {
70
- return String(value || "").replace(/\s+/g, " ").trim();
71
- }
72
-
73
217
  class RecommendSearchCli {
74
218
  constructor(args) {
75
219
  this.args = args;
@@ -218,9 +362,10 @@ class RecommendSearchCli {
218
362
  return rect.width > 2 && rect.height > 2;
219
363
  };
220
364
  const school = doc.querySelector('.check-box.school');
365
+ const degree = doc.querySelector('.check-box.degree');
221
366
  const gender = doc.querySelector('.check-box.gender');
222
367
  const recent = doc.querySelector('.check-box.recentNotView');
223
- return Boolean((school && gender && recent) || isVisible(panel));
368
+ return Boolean((school && degree && gender && recent) || isVisible(panel));
224
369
  })()`);
225
370
  return result === true;
226
371
  }
@@ -399,10 +544,11 @@ class RecommendSearchCli {
399
544
  if (!target) {
400
545
  return { ok: false, error: 'OPTION_NOT_FOUND', activeText };
401
546
  }
547
+ const targetActive = target.classList.contains('active');
402
548
  return {
403
549
  ok: true,
404
550
  activeText,
405
- alreadySelected: activeText === normalize(label),
551
+ alreadySelected: targetActive || activeText === normalize(label),
406
552
  x: getPoint(target).x,
407
553
  y: getPoint(target).y
408
554
  };
@@ -421,6 +567,60 @@ class RecommendSearchCli {
421
567
  await sleep(humanDelay(300, 80));
422
568
  }
423
569
 
570
+ async getDegreeFilterState() {
571
+ return this.evaluate(`(() => {
572
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
573
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
574
+ || document.querySelector('iframe');
575
+ if (!frame || !frame.contentDocument) {
576
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
577
+ }
578
+ const doc = frame.contentDocument;
579
+ const group = doc.querySelector('.check-box.degree');
580
+ if (!group) {
581
+ return { ok: false, error: 'GROUP_NOT_FOUND' };
582
+ }
583
+ const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
584
+ const labels = ${JSON.stringify(DEGREE_OPTIONS)};
585
+ const activeLabels = labels.filter((label) => {
586
+ const node = Array.from(group.querySelectorAll('.options .option'))
587
+ .find((item) => normalize(item.textContent) === normalize(label));
588
+ return Boolean(node && node.classList.contains('active'));
589
+ });
590
+ const defaultOption = group.querySelector('.default.option');
591
+ return {
592
+ ok: true,
593
+ defaultActive: Boolean(defaultOption && defaultOption.classList.contains('active')),
594
+ activeLabels
595
+ };
596
+ })()`);
597
+ }
598
+
599
+ async selectDegreeFilter(labels) {
600
+ const targetLabels = Array.isArray(labels) && labels.length > 0 ? labels : ["不限"];
601
+ if (targetLabels.includes("不限")) {
602
+ await this.selectOption("degree", "不限");
603
+ return;
604
+ }
605
+
606
+ const currentState = await this.getDegreeFilterState();
607
+ if (!currentState?.ok) {
608
+ throw new Error(currentState?.error || "DEGREE_FILTER_STATE_FAILED");
609
+ }
610
+ const current = sortDegreeSelection(currentState.activeLabels || []);
611
+ const desired = sortDegreeSelection(targetLabels);
612
+ const same =
613
+ !currentState.defaultActive
614
+ && current.length === desired.length
615
+ && current.every((value, index) => value === desired[index]);
616
+ if (same) return;
617
+
618
+ await this.selectOption("degree", "不限");
619
+ for (const label of desired) {
620
+ await this.selectOption("degree", label);
621
+ }
622
+ }
623
+
424
624
  async countCandidates() {
425
625
  return this.evaluate(`(() => {
426
626
  const frame = document.querySelector('iframe[name="recommendFrame"]')
@@ -470,11 +670,14 @@ class RecommendSearchCli {
470
670
  console.log(JSON.stringify({
471
671
  status: "COMPLETED",
472
672
  result: {
473
- usage: "node src/cli.js --school-tag 985 --gender 男 --recent-not-view 近14天没有 --port 9222"
673
+ usage: "node src/cli.js --school-tag 985 --degree 本科及以上 --gender 男 --recent-not-view 近14天没有 --port 9222"
474
674
  }
475
675
  }));
476
676
  return;
477
677
  }
678
+ if (!Array.isArray(this.args.degree) || this.args.degree.length === 0) {
679
+ throw new Error("INVALID_DEGREE_INPUT");
680
+ }
478
681
 
479
682
  await this.connect();
480
683
  try {
@@ -485,6 +688,7 @@ class RecommendSearchCli {
485
688
 
486
689
  await this.openFilterPanel();
487
690
  await this.selectOption("school", this.args.schoolTag);
691
+ await this.selectDegreeFilter(this.args.degree);
488
692
  await this.selectOption("gender", this.args.gender);
489
693
  await this.selectOption("recentNotView", this.args.recentNotView);
490
694
  await this.closeFilterPanel();
@@ -495,6 +699,7 @@ class RecommendSearchCli {
495
699
  result: {
496
700
  applied_filters: {
497
701
  school_tag: this.args.schoolTag,
702
+ degree: this.args.degree,
498
703
  gender: this.args.gender,
499
704
  recent_not_view: this.args.recentNotView
500
705
  },
@@ -511,9 +716,14 @@ class RecommendSearchCli {
511
716
  }
512
717
  }
513
718
 
514
- const args = parseArgs(process.argv.slice(2));
515
- const cli = new RecommendSearchCli(args);
516
- cli.run().catch((error) => {
719
+ async function main() {
720
+ const args = parseArgs(process.argv.slice(2));
721
+ const finalArgs = await enrichArgsFromPrompt(args);
722
+ const cli = new RecommendSearchCli(finalArgs);
723
+ await cli.run();
724
+ }
725
+
726
+ main().catch((error) => {
517
727
  console.log(JSON.stringify({
518
728
  status: "FAILED",
519
729
  error: {
@@ -523,4 +733,4 @@ cli.run().catch((error) => {
523
733
  }
524
734
  }));
525
735
  process.exitCode = 1;
526
- });
736
+ });