@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 +1 -0
- package/package.json +1 -1
- package/src/adapters.js +45 -2
- package/vendor/boss-recommend-search-cli/src/cli.js +237 -78
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
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 (
|
|
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
|
|
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
|
|
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
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
.find((
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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.
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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
|
|
1354
|
-
|
|
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:
|
|
1369
|
-
degree:
|
|
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,
|