@reconcrap/boss-recommend-mcp 1.0.2 → 1.0.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/README.md CHANGED
@@ -90,6 +90,7 @@ npx -y @reconcrap/boss-recommend-mcp@latest run --instruction "推荐页筛选98
90
90
  注意:
91
91
 
92
92
  - `install` / `postinstall` 会自动创建 `screening-config.json` 模板(若目标路径可写)
93
+ - 当当前目录是系统目录(例如 `C:\\Windows\\System32`)、用户主目录根(例如 `C:\\Users\\<name>`)或磁盘根目录时,不会再写入 `<cwd>/config`,而是回退到 `~/.boss-recommend-mcp/screening-config.json`
93
94
  - 首次运行时,若仍检测到默认占位词(如 `replace-with-openai-api-key`),pipeline 会返回配置目录并要求用户修改后确认“已修改完成”再继续
94
95
  - 在 `npx` 临时目录(如 `AppData\\Local\\npm-cache\\_npx\\...`)执行时,不会再把该临时目录当作 `screening-config.json` 目标路径
95
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/adapters.js CHANGED
@@ -58,9 +58,50 @@ function parsePositiveInteger(raw) {
58
58
  return Number.isFinite(value) && value > 0 ? value : null;
59
59
  }
60
60
 
61
+ function isRootDirectory(targetPath) {
62
+ const resolved = path.resolve(String(targetPath || ""));
63
+ const parsed = path.parse(resolved);
64
+ return resolved.toLowerCase() === String(parsed.root || "").toLowerCase();
65
+ }
66
+
67
+ function isSystemDirectoryWorkspaceRoot(workspaceRoot) {
68
+ const root = path.resolve(String(workspaceRoot || ""));
69
+ const normalized = root.replace(/\\/g, "/").toLowerCase();
70
+ if (process.platform === "win32") {
71
+ return (
72
+ normalized.endsWith("/windows")
73
+ || normalized.endsWith("/windows/system32")
74
+ || normalized.endsWith("/windows/syswow64")
75
+ || normalized.endsWith("/program files")
76
+ || normalized.endsWith("/program files (x86)")
77
+ );
78
+ }
79
+ return (
80
+ normalized === "/system"
81
+ || normalized.startsWith("/system/")
82
+ || normalized === "/usr"
83
+ || normalized.startsWith("/usr/")
84
+ || normalized === "/bin"
85
+ || normalized.startsWith("/bin/")
86
+ || normalized === "/sbin"
87
+ || normalized.startsWith("/sbin/")
88
+ );
89
+ }
90
+
91
+ function shouldIgnoreWorkspaceConfigRoot(workspaceRoot) {
92
+ const root = path.resolve(String(workspaceRoot || process.cwd()));
93
+ const home = path.resolve(os.homedir());
94
+ return (
95
+ isEphemeralNpxWorkspaceRoot(root)
96
+ || isRootDirectory(root)
97
+ || root.toLowerCase() === home.toLowerCase()
98
+ || isSystemDirectoryWorkspaceRoot(root)
99
+ );
100
+ }
101
+
61
102
  function resolveWorkspaceConfigCandidates(workspaceRoot) {
62
103
  const root = path.resolve(String(workspaceRoot || process.cwd()));
63
- if (isEphemeralNpxWorkspaceRoot(root)) {
104
+ if (shouldIgnoreWorkspaceConfigRoot(root)) {
64
105
  return [];
65
106
  }
66
107
  const directPath = path.join(root, "config", "screening-config.json");
@@ -174,11 +215,13 @@ export function getScreenConfigResolution(workspaceRoot) {
174
215
  const candidateMap = buildScreenConfigCandidateMap(workspaceRoot);
175
216
  const candidate_paths = resolveScreenConfigCandidates(workspaceRoot);
176
217
  const resolved_path = resolveScreenConfigPath(workspaceRoot) || null;
218
+ const workspace_root = path.resolve(String(workspaceRoot || process.cwd()));
177
219
  return {
178
220
  resolved_path,
179
221
  candidate_paths,
180
- workspace_root: path.resolve(String(workspaceRoot || process.cwd())),
222
+ workspace_root,
181
223
  workspace_ephemeral: isEphemeralNpxWorkspaceRoot(workspaceRoot),
224
+ workspace_ignored_for_config: shouldIgnoreWorkspaceConfigRoot(workspace_root),
182
225
  writable_path: resolveWritableScreenConfigPath(workspaceRoot),
183
226
  legacy_path: candidateMap.legacy_path
184
227
  };
@@ -85,6 +85,16 @@ function selectionEquals(left, right) {
85
85
  if (left.length !== right.length) return false;
86
86
  return left.every((value, index) => value === right[index]);
87
87
  }
88
+
89
+ function uniqueNormalizedLabels(values) {
90
+ return Array.from(
91
+ new Set(
92
+ (values || [])
93
+ .map((item) => normalizeText(item))
94
+ .filter(Boolean)
95
+ )
96
+ );
97
+ }
88
98
 
89
99
  function expandDegreeAtOrAbove(value) {
90
100
  const normalized = normalizeDegree(value);
@@ -1083,43 +1093,99 @@ class RecommendSearchCli {
1083
1093
  return false;
1084
1094
  }
1085
1095
 
1086
- async getSchoolFilterState() {
1087
- return this.evaluate(`(() => {
1088
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1089
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1090
- || document.querySelector('iframe');
1091
- if (!frame || !frame.contentDocument) {
1092
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1093
- }
1094
- const doc = frame.contentDocument;
1095
- const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
1096
- const groups = Array.from(doc.querySelectorAll('.check-box'));
1097
- const group = doc.querySelector('.check-box.school')
1098
- || groups.find((item) => {
1099
- const set = new Set(
1100
- Array.from(item.querySelectorAll('.default.option, .options .option, .option'))
1101
- .map((node) => normalize(node.textContent))
1102
- .filter(Boolean)
1103
- );
1104
- return set.has('985') || set.has('211') || set.has('双一流院校');
1105
- });
1106
- if (!group) {
1107
- return { ok: false, error: 'GROUP_NOT_FOUND' };
1108
- }
1109
- const labels = ${JSON.stringify(SCHOOL_TAG_OPTIONS)};
1110
- const activeLabels = labels.filter((label) => {
1111
- const node = Array.from(group.querySelectorAll('.options .option, .option'))
1112
- .find((item) => normalize(item.textContent) === normalize(label));
1113
- return Boolean(node && node.classList.contains('active'));
1114
- });
1115
- const defaultOption = group.querySelector('.default.option');
1116
- return {
1117
- ok: true,
1118
- defaultActive: Boolean(defaultOption && defaultOption.classList.contains('active')),
1119
- activeLabels
1120
- };
1121
- })()`);
1122
- }
1096
+ async getFilterGroupState(groupClass) {
1097
+ return this.evaluate(`((groupClass) => {
1098
+ const frame = document.querySelector('iframe[name="recommendFrame"]')
1099
+ || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1100
+ || document.querySelector('iframe');
1101
+ if (!frame || !frame.contentDocument) {
1102
+ return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1103
+ }
1104
+ const doc = frame.contentDocument;
1105
+ const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
1106
+ const groupCandidates = Array.from(doc.querySelectorAll('.check-box'));
1107
+ const getOptionSet = (group) => new Set(
1108
+ Array.from(group.querySelectorAll('.default.option, .options .option, .option'))
1109
+ .map((item) => normalize(item.textContent))
1110
+ .filter(Boolean)
1111
+ );
1112
+ const findGroup = () => {
1113
+ const direct = doc.querySelector('.check-box.' + groupClass);
1114
+ if (direct) return direct;
1115
+ if (groupClass === 'school') {
1116
+ return groupCandidates.find((group) => {
1117
+ const set = getOptionSet(group);
1118
+ return set.has('985') || set.has('211') || set.has('双一流院校');
1119
+ }) || null;
1120
+ }
1121
+ if (groupClass === 'degree') {
1122
+ return groupCandidates.find((group) => {
1123
+ const set = getOptionSet(group);
1124
+ return set.has('大专') || set.has('本科') || set.has('硕士') || set.has('博士');
1125
+ }) || null;
1126
+ }
1127
+ if (groupClass === 'gender') {
1128
+ return groupCandidates.find((group) => {
1129
+ const set = getOptionSet(group);
1130
+ return set.has('男') || set.has('女');
1131
+ }) || null;
1132
+ }
1133
+ if (groupClass === 'recentNotView') {
1134
+ return groupCandidates.find((group) => {
1135
+ const set = getOptionSet(group);
1136
+ return set.has('近14天没有');
1137
+ }) || null;
1138
+ }
1139
+ return null;
1140
+ };
1141
+
1142
+ const group = findGroup();
1143
+ if (!group) {
1144
+ return { ok: false, error: 'GROUP_NOT_FOUND' };
1145
+ }
1146
+
1147
+ const defaultOption = group.querySelector('.default.option');
1148
+ const options = Array.from(group.querySelectorAll('.default.option, .options .option, .option'));
1149
+ const byLabel = new Map();
1150
+ for (const node of options) {
1151
+ const label = normalize(node.textContent);
1152
+ if (!label) continue;
1153
+ const className = String(node.className || '').trim();
1154
+ const active = node.classList.contains('active');
1155
+ const existing = byLabel.get(label);
1156
+ if (existing) {
1157
+ existing.active = existing.active || active;
1158
+ if (className && !existing.classNames.includes(className)) {
1159
+ existing.classNames.push(className);
1160
+ }
1161
+ } else {
1162
+ byLabel.set(label, {
1163
+ label,
1164
+ active,
1165
+ classNames: className ? [className] : []
1166
+ });
1167
+ }
1168
+ }
1169
+
1170
+ const normalizedOptions = Array.from(byLabel.values()).map((item) => ({
1171
+ label: item.label,
1172
+ active: item.active,
1173
+ class_name: item.classNames.join(' | ')
1174
+ }));
1175
+ return {
1176
+ ok: true,
1177
+ group_class: groupClass,
1178
+ defaultActive: Boolean(defaultOption && defaultOption.classList.contains('active')),
1179
+ defaultClassName: defaultOption ? String(defaultOption.className || '').trim() : '',
1180
+ options: normalizedOptions,
1181
+ activeLabels: normalizedOptions.filter((item) => item.active).map((item) => item.label)
1182
+ };
1183
+ })(${JSON.stringify(groupClass)})`);
1184
+ }
1185
+
1186
+ async getSchoolFilterState() {
1187
+ return this.getFilterGroupState("school");
1188
+ }
1123
1189
 
1124
1190
  async selectSchoolFilter(labels) {
1125
1191
  const ensure = await this.ensureGroupReady("school");
@@ -1169,43 +1235,17 @@ class RecommendSearchCli {
1169
1235
  throw new Error(`SCHOOL_FILTER_VERIFY_FAILED:${JSON.stringify(lastState || {})}`);
1170
1236
  }
1171
1237
 
1172
- async getDegreeFilterState() {
1173
- return this.evaluate(`(() => {
1174
- const frame = document.querySelector('iframe[name="recommendFrame"]')
1175
- || document.querySelector('iframe[src*="/web/frame/recommend/"]')
1176
- || document.querySelector('iframe');
1177
- if (!frame || !frame.contentDocument) {
1178
- return { ok: false, error: 'NO_RECOMMEND_IFRAME' };
1179
- }
1180
- const doc = frame.contentDocument;
1181
- const normalize = (value) => String(value || '').replace(/\\s+/g, '').trim();
1182
- const groups = Array.from(doc.querySelectorAll('.check-box'));
1183
- const group = doc.querySelector('.check-box.degree')
1184
- || groups.find((item) => {
1185
- const set = new Set(
1186
- Array.from(item.querySelectorAll('.default.option, .options .option, .option'))
1187
- .map((node) => normalize(node.textContent))
1188
- .filter(Boolean)
1189
- );
1190
- return set.has('大专') || set.has('本科') || set.has('硕士') || set.has('博士');
1191
- });
1192
- if (!group) {
1193
- return { ok: false, error: 'GROUP_NOT_FOUND' };
1194
- }
1195
- const labels = ${JSON.stringify(DEGREE_OPTIONS)};
1196
- const activeLabels = labels.filter((label) => {
1197
- const node = Array.from(group.querySelectorAll('.options .option'))
1198
- .find((item) => normalize(item.textContent) === normalize(label));
1199
- return Boolean(node && node.classList.contains('active'));
1200
- });
1201
- const defaultOption = group.querySelector('.default.option');
1202
- return {
1203
- ok: true,
1204
- defaultActive: Boolean(defaultOption && defaultOption.classList.contains('active')),
1205
- activeLabels
1206
- };
1207
- })()`);
1208
- }
1238
+ async getDegreeFilterState() {
1239
+ return this.getFilterGroupState("degree");
1240
+ }
1241
+
1242
+ async getGenderFilterState() {
1243
+ return this.getFilterGroupState("gender");
1244
+ }
1245
+
1246
+ async getRecentNotViewFilterState() {
1247
+ return this.getFilterGroupState("recentNotView");
1248
+ }
1209
1249
 
1210
1250
  async selectDegreeFilter(labels) {
1211
1251
  const ensure = await this.ensureGroupReady("degree");
@@ -1254,6 +1294,112 @@ class RecommendSearchCli {
1254
1294
 
1255
1295
  throw new Error(`DEGREE_FILTER_VERIFY_FAILED:${JSON.stringify(lastState || {})}`);
1256
1296
  }
1297
+
1298
+ buildGroupClassVerification(groupName, state, expectedLabels, availableOptions, sortFn) {
1299
+ if (!state?.ok) {
1300
+ return {
1301
+ group: groupName,
1302
+ ok: false,
1303
+ reason: state?.error || "GROUP_STATE_UNAVAILABLE",
1304
+ expected_labels: expectedLabels,
1305
+ state: state || null
1306
+ };
1307
+ }
1308
+
1309
+ const expectedSorted = sortFn(uniqueNormalizedLabels(expectedLabels));
1310
+ const expectedSet = new Set(expectedSorted);
1311
+ const allowedSet = new Set(uniqueNormalizedLabels(availableOptions));
1312
+ const optionMap = new Map();
1313
+ for (const option of state.options || []) {
1314
+ optionMap.set(normalizeText(option.label), option);
1315
+ }
1316
+
1317
+ const selectedNotActive = [];
1318
+ const unselectedButActive = [];
1319
+ for (const label of expectedSorted) {
1320
+ const option = optionMap.get(label);
1321
+ if (!option || option.active !== true) {
1322
+ selectedNotActive.push(label);
1323
+ }
1324
+ }
1325
+ for (const label of allowedSet) {
1326
+ if (expectedSet.has(label)) continue;
1327
+ const option = optionMap.get(label);
1328
+ if (option?.active === true) {
1329
+ unselectedButActive.push(label);
1330
+ }
1331
+ }
1332
+
1333
+ const expectDefault = expectedSet.has("不限");
1334
+ const defaultMismatch = expectDefault ? !state.defaultActive : Boolean(state.defaultActive);
1335
+ const ok = (
1336
+ selectedNotActive.length === 0
1337
+ && unselectedButActive.length === 0
1338
+ && !defaultMismatch
1339
+ );
1340
+
1341
+ return {
1342
+ group: groupName,
1343
+ ok,
1344
+ expected_labels: expectedSorted,
1345
+ actual_active_labels: sortFn(uniqueNormalizedLabels(state.activeLabels || [])),
1346
+ default_active: Boolean(state.defaultActive),
1347
+ selected_not_active: selectedNotActive,
1348
+ unselected_but_active: unselectedButActive,
1349
+ default_mismatch: defaultMismatch,
1350
+ options: state.options || []
1351
+ };
1352
+ }
1353
+
1354
+ async verifyFilterDomClassStates(expected) {
1355
+ const schoolState = await this.getSchoolFilterState();
1356
+ const degreeState = await this.getDegreeFilterState();
1357
+ const genderState = await this.getGenderFilterState();
1358
+ const recentState = await this.getRecentNotViewFilterState();
1359
+
1360
+ const checks = [
1361
+ this.buildGroupClassVerification(
1362
+ "school",
1363
+ schoolState,
1364
+ Array.isArray(expected?.schoolTag) && expected.schoolTag.length > 0 ? expected.schoolTag : ["不限"],
1365
+ SCHOOL_TAG_OPTIONS,
1366
+ sortSchoolSelection
1367
+ ),
1368
+ this.buildGroupClassVerification(
1369
+ "degree",
1370
+ degreeState,
1371
+ Array.isArray(expected?.degree) && expected.degree.length > 0 ? expected.degree : ["不限"],
1372
+ DEGREE_OPTIONS,
1373
+ sortDegreeSelection
1374
+ ),
1375
+ this.buildGroupClassVerification(
1376
+ "gender",
1377
+ genderState,
1378
+ [normalizeText(expected?.gender || "不限")],
1379
+ GENDER_OPTIONS,
1380
+ uniqueNormalizedLabels
1381
+ ),
1382
+ this.buildGroupClassVerification(
1383
+ "recent_not_view",
1384
+ recentState,
1385
+ [normalizeText(expected?.recentNotView || "不限")],
1386
+ RECENT_NOT_VIEW_OPTIONS,
1387
+ uniqueNormalizedLabels
1388
+ )
1389
+ ];
1390
+ const failures = checks.filter((item) => item.ok === false);
1391
+ return {
1392
+ ok: failures.length === 0,
1393
+ checks,
1394
+ failures,
1395
+ states: {
1396
+ school: schoolState,
1397
+ degree: degreeState,
1398
+ gender: genderState,
1399
+ recent_not_view: recentState
1400
+ }
1401
+ };
1402
+ }
1257
1403
 
1258
1404
  async countCandidates() {
1259
1405
  return this.evaluate(`(() => {
@@ -1350,8 +1496,15 @@ class RecommendSearchCli {
1350
1496
  await this.selectOption("gender", this.args.gender);
1351
1497
  await this.selectOption("recentNotView", this.args.recentNotView);
1352
1498
  await this.selectDegreeFilter(this.args.degree);
1353
- const verifiedSchoolState = await this.getSchoolFilterState();
1354
- const verifiedDegreeState = await this.getDegreeFilterState();
1499
+ const domClassVerification = await this.verifyFilterDomClassStates({
1500
+ schoolTag: this.args.schoolTag,
1501
+ degree: this.args.degree,
1502
+ gender: this.args.gender,
1503
+ recentNotView: this.args.recentNotView
1504
+ });
1505
+ if (!domClassVerification.ok) {
1506
+ throw new Error(`FILTER_DOM_CLASS_VERIFY_FAILED:${JSON.stringify(domClassVerification.failures)}`);
1507
+ }
1355
1508
  await this.closeFilterPanel();
1356
1509
  const candidateInfo = await this.waitForCandidateCountStable();
1357
1510
 
@@ -1365,8 +1518,14 @@ class RecommendSearchCli {
1365
1518
  recent_not_view: this.args.recentNotView
1366
1519
  },
1367
1520
  verified_filters: {
1368
- school: verifiedSchoolState,
1369
- degree: verifiedDegreeState
1521
+ school: domClassVerification.states.school,
1522
+ degree: domClassVerification.states.degree,
1523
+ gender: domClassVerification.states.gender,
1524
+ recent_not_view: domClassVerification.states.recent_not_view,
1525
+ dom_class_check: {
1526
+ ok: domClassVerification.ok,
1527
+ checks: domClassVerification.checks
1528
+ }
1370
1529
  },
1371
1530
  selected_job: selectedJob,
1372
1531
  candidate_count: candidateInfo?.candidateCount ?? null,