Skip to content

Commit 8e3f678

Browse files
committed
feat: add professor-search CLI tool to find US professors by topic
Uses the OpenAlex open academic graph API (no key required) to search for US-based researchers and professors by any topic keyword. Supports pagination, configurable result limits, and an interactive prompt mode. https://claude.ai/code/session_01KiLngaGGuqii3KzBU57JVB
1 parent d0304b5 commit 8e3f678

1 file changed

Lines changed: 355 additions & 0 deletions

File tree

β€Žprofessor-search.jsβ€Ž

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Professor Search
5+
*
6+
* Searches for US-based professors/researchers by topic using the OpenAlex API.
7+
* No API key required.
8+
*
9+
* Usage:
10+
* node professor-search.js <topic>
11+
* node professor-search.js <topic> --page <number>
12+
* node professor-search.js <topic> --limit <number>
13+
*
14+
* Examples:
15+
* node professor-search.js landscaping
16+
* node professor-search.js "machine learning" --limit 10
17+
* node professor-search.js "urban planning" --page 2
18+
*/
19+
20+
const https = require('https');
21+
const readline = require('readline');
22+
23+
// ─── Config ──────────────────────────────────────────────────────────────────
24+
25+
const OPENALEX_BASE = 'https://api.openalex.org';
26+
const DEFAULT_LIMIT = 15;
27+
const MAX_LIMIT = 50;
28+
const EMAIL_POLITE = 'professor-search@example.com'; // OpenAlex "polite pool" – faster responses
29+
30+
// ─── HTTP helper ─────────────────────────────────────────────────────────────
31+
32+
function fetchJSON(url) {
33+
return new Promise((resolve, reject) => {
34+
const options = new URL(url);
35+
const req = https.get(
36+
{
37+
hostname: options.hostname,
38+
path: options.pathname + options.search,
39+
headers: {
40+
'User-Agent': `professor-search/1.0 (mailto:${EMAIL_POLITE})`,
41+
Accept: 'application/json',
42+
},
43+
},
44+
(res) => {
45+
if (res.statusCode !== 200) {
46+
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
47+
res.resume();
48+
return;
49+
}
50+
let raw = '';
51+
res.on('data', (chunk) => (raw += chunk));
52+
res.on('end', () => {
53+
try {
54+
resolve(JSON.parse(raw));
55+
} catch (e) {
56+
reject(new Error('Failed to parse response JSON'));
57+
}
58+
});
59+
}
60+
);
61+
req.on('error', reject);
62+
req.setTimeout(15000, () => {
63+
req.destroy(new Error('Request timed out after 15s'));
64+
});
65+
});
66+
}
67+
68+
// ─── OpenAlex helpers ─────────────────────────────────────────────────────────
69+
70+
/**
71+
* Resolve a topic string to an OpenAlex concept ID.
72+
* Returns the best-matching concept or null if none found.
73+
*/
74+
async function resolveConcept(topic) {
75+
const url =
76+
`${OPENALEX_BASE}/concepts?search=${encodeURIComponent(topic)}` +
77+
`&per-page=5&select=id,display_name,description,level,works_count`;
78+
const data = await fetchJSON(url);
79+
if (!data.results || data.results.length === 0) return null;
80+
81+
// Prefer an exact (case-insensitive) match; otherwise take the top result
82+
const exact = data.results.find(
83+
(c) => c.display_name.toLowerCase() === topic.toLowerCase()
84+
);
85+
return exact || data.results[0];
86+
}
87+
88+
/**
89+
* Search OpenAlex authors who:
90+
* - Are at a US institution
91+
* - Have the given concept in their expertise
92+
*/
93+
async function searchAuthors({ conceptId, topicQuery, page, limit }) {
94+
const filters = ['last_known_institution.country_code:US'];
95+
if (conceptId) filters.push(`x_concepts.id:${conceptId}`);
96+
97+
const params = new URLSearchParams({
98+
filter: filters.join(','),
99+
search: topicQuery,
100+
'per-page': limit,
101+
page,
102+
select: [
103+
'id',
104+
'display_name',
105+
'last_known_institution',
106+
'x_concepts',
107+
'works_count',
108+
'cited_by_count',
109+
'orcid',
110+
'ids',
111+
].join(','),
112+
sort: 'cited_by_count:desc',
113+
});
114+
115+
const url = `${OPENALEX_BASE}/authors?${params}`;
116+
return fetchJSON(url);
117+
}
118+
119+
// ─── Formatting ───────────────────────────────────────────────────────────────
120+
121+
const RESET = '\x1b[0m';
122+
const BOLD = '\x1b[1m';
123+
const DIM = '\x1b[2m';
124+
const CYAN = '\x1b[36m';
125+
const GREEN = '\x1b[32m';
126+
const YELLOW = '\x1b[33m';
127+
const BLUE = '\x1b[34m';
128+
const MAGENTA = '\x1b[35m';
129+
130+
function hr(char = '─', width = 80) {
131+
return char.repeat(width);
132+
}
133+
134+
function formatCount(n) {
135+
if (n === null || n === undefined) return 'N/A';
136+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
137+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
138+
return String(n);
139+
}
140+
141+
function printProfessor(prof, index) {
142+
const name = prof.display_name || 'Unknown';
143+
const inst = prof.last_known_institution;
144+
const instName = inst ? inst.display_name : 'Unknown institution';
145+
const instCity = inst && inst.city ? inst.city : null;
146+
const instState = inst && inst.country_code ? inst.country_code : null;
147+
const works = formatCount(prof.works_count);
148+
const citations = formatCount(prof.cited_by_count);
149+
150+
// Top 6 concepts, skip generic ones
151+
const skipConcepts = new Set(['computer science', 'biology', 'chemistry', 'physics', 'medicine', 'mathematics']);
152+
const concepts = (prof.x_concepts || [])
153+
.filter((c) => c.score > 0.3 && !skipConcepts.has(c.display_name.toLowerCase()))
154+
.slice(0, 6)
155+
.map((c) => c.display_name);
156+
157+
const openAlexId = prof.id ? prof.id.replace('https://openalex.org/', '') : null;
158+
const scholarUrl =
159+
prof.ids && prof.ids.google_scholar
160+
? `https://scholar.google.com${prof.ids.google_scholar}`
161+
: null;
162+
163+
console.log(`\n${BOLD}${CYAN}${index}. ${name}${RESET}`);
164+
console.log(
165+
` ${BOLD}Institution:${RESET} ${instName}` +
166+
(instCity ? ` Β· ${instCity}` : '') +
167+
(instState ? `, ${instState}` : '')
168+
);
169+
console.log(
170+
` ${BOLD}Works:${RESET} ${GREEN}${works}${RESET} ` +
171+
`${BOLD}Citations:${RESET} ${YELLOW}${citations}${RESET}`
172+
);
173+
174+
if (concepts.length > 0) {
175+
console.log(` ${BOLD}Expertise:${RESET} ${MAGENTA}${concepts.join(' Β· ')}${RESET}`);
176+
}
177+
178+
if (prof.orcid) {
179+
console.log(` ${BOLD}ORCID:${RESET} ${DIM}${prof.orcid}${RESET}`);
180+
}
181+
if (scholarUrl) {
182+
console.log(` ${BOLD}Google Scholar:${RESET} ${BLUE}${scholarUrl}${RESET}`);
183+
}
184+
if (openAlexId) {
185+
console.log(` ${BOLD}OpenAlex:${RESET} ${DIM}https://openalex.org/${openAlexId}${RESET}`);
186+
}
187+
188+
console.log(` ${DIM}${hr('Β·', 76)}${RESET}`);
189+
}
190+
191+
function printHeader(topic, total, page, limit) {
192+
const from = (page - 1) * limit + 1;
193+
const to = Math.min(page * limit, total);
194+
const totalPages = Math.ceil(total / limit);
195+
196+
console.log('\n' + hr('═'));
197+
console.log(
198+
`${BOLD} Professor Search${RESET} Β· Topic: ${CYAN}${BOLD}"${topic}"${RESET}`
199+
);
200+
console.log(
201+
` Found ${YELLOW}${BOLD}${total.toLocaleString()}${RESET} US-based researchers` +
202+
` Β· Showing ${from}–${to} Β· Page ${page}/${totalPages}`
203+
);
204+
console.log(hr('═'));
205+
}
206+
207+
function printFooter(topic, page, total, limit) {
208+
const totalPages = Math.ceil(total / limit);
209+
console.log('\n' + hr());
210+
if (page < totalPages) {
211+
console.log(
212+
`${DIM} Next page: node professor-search.js "${topic}" --page ${page + 1} --limit ${limit}${RESET}`
213+
);
214+
}
215+
console.log(`${DIM} Data source: OpenAlex (https://openalex.org)${RESET}`);
216+
console.log(hr() + '\n');
217+
}
218+
219+
// ─── Interactive mode ─────────────────────────────────────────────────────────
220+
221+
async function interactiveMode() {
222+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
223+
const ask = (q) => new Promise((res) => rl.question(q, res));
224+
225+
console.log('\n' + hr('═'));
226+
console.log(`${BOLD} Professor Search – Interactive Mode${RESET}`);
227+
console.log(hr('═') + '\n');
228+
229+
while (true) {
230+
const topic = (await ask(`${CYAN}Enter topic (or "quit" to exit):${RESET} `)).trim();
231+
if (!topic || topic.toLowerCase() === 'quit' || topic.toLowerCase() === 'q') break;
232+
233+
const limitInput = (await ask(`${DIM} Results per page [${DEFAULT_LIMIT}]:${RESET} `)).trim();
234+
const limit = Math.min(parseInt(limitInput) || DEFAULT_LIMIT, MAX_LIMIT);
235+
236+
rl.close();
237+
await runSearch({ topic, page: 1, limit });
238+
return;
239+
}
240+
241+
rl.close();
242+
}
243+
244+
// ─── Core search flow ─────────────────────────────────────────────────────────
245+
246+
async function runSearch({ topic, page, limit }) {
247+
process.stdout.write(`\n Searching for "${topic}" ...\r`);
248+
249+
let conceptId = null;
250+
try {
251+
const concept = await resolveConcept(topic);
252+
if (concept) {
253+
conceptId = concept.id;
254+
process.stdout.write(
255+
` Mapped to concept: ${CYAN}${concept.display_name}${RESET} (${formatCount(concept.works_count)} works)\n`
256+
);
257+
}
258+
} catch {
259+
// concept resolution is best-effort
260+
}
261+
262+
let data;
263+
try {
264+
data = await searchAuthors({ conceptId, topicQuery: topic, page, limit });
265+
} catch (err) {
266+
console.error(`\n ${BOLD}Error:${RESET} ${err.message}`);
267+
process.exit(1);
268+
}
269+
270+
const results = data.results || [];
271+
const total = data.meta ? data.meta.count : results.length;
272+
273+
if (results.length === 0) {
274+
console.log(`\n No US professors found for topic: "${topic}"\n`);
275+
console.log(' Tips:');
276+
console.log(' Β· Try broader keywords (e.g. "landscape architecture" instead of "landscaping")');
277+
console.log(' Β· Try synonyms or related fields');
278+
process.exit(0);
279+
}
280+
281+
printHeader(topic, total, page, limit);
282+
results.forEach((prof, i) => printProfessor(prof, (page - 1) * limit + i + 1));
283+
printFooter(topic, page, total, limit);
284+
}
285+
286+
// ─── CLI argument parsing ─────────────────────────────────────────────────────
287+
288+
function parseArgs(argv) {
289+
const args = argv.slice(2);
290+
const opts = { topic: null, page: 1, limit: DEFAULT_LIMIT, interactive: false };
291+
292+
if (args.length === 0) {
293+
opts.interactive = true;
294+
return opts;
295+
}
296+
297+
// First positional arg = topic (unless it starts with --)
298+
if (args[0] && !args[0].startsWith('--')) {
299+
opts.topic = args[0];
300+
}
301+
302+
for (let i = 0; i < args.length; i++) {
303+
if (args[i] === '--page' && args[i + 1]) {
304+
opts.page = Math.max(1, parseInt(args[++i]) || 1);
305+
} else if (args[i] === '--limit' && args[i + 1]) {
306+
opts.limit = Math.min(Math.max(1, parseInt(args[++i]) || DEFAULT_LIMIT), MAX_LIMIT);
307+
} else if (args[i] === '--interactive' || args[i] === '-i') {
308+
opts.interactive = true;
309+
} else if (args[i] === '--help' || args[i] === '-h') {
310+
printHelp();
311+
process.exit(0);
312+
}
313+
}
314+
315+
return opts;
316+
}
317+
318+
function printHelp() {
319+
console.log(`
320+
${BOLD}professor-search${RESET} β€” Find US professors by research topic
321+
322+
${BOLD}USAGE${RESET}
323+
node professor-search.js <topic> [options]
324+
node professor-search.js # interactive mode
325+
326+
${BOLD}OPTIONS${RESET}
327+
--page <n> Page number (default: 1)
328+
--limit <n> Results per page, max 50 (default: ${DEFAULT_LIMIT})
329+
--interactive Launch interactive prompt
330+
--help Show this help
331+
332+
${BOLD}EXAMPLES${RESET}
333+
node professor-search.js landscaping
334+
node professor-search.js "urban planning" --limit 20
335+
node professor-search.js "machine learning" --page 3
336+
node professor-search.js "renewable energy" --limit 25 --page 2
337+
338+
${BOLD}DATA SOURCE${RESET}
339+
OpenAlex (https://openalex.org) β€” free, open academic graph
340+
No API key required.
341+
`);
342+
}
343+
344+
// ─── Entry point ──────────────────────────────────────────────────────────────
345+
346+
(async () => {
347+
const opts = parseArgs(process.argv);
348+
349+
if (opts.interactive || !opts.topic) {
350+
await interactiveMode();
351+
return;
352+
}
353+
354+
await runSearch({ topic: opts.topic, page: opts.page, limit: opts.limit });
355+
})();

0 commit comments

Comments
Β (0)