mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 16:46:24 +00:00
117 lines
4.3 KiB
TypeScript
117 lines
4.3 KiB
TypeScript
#!/usr/bin/env bun
|
|||
|
|
// Self-contained CLI for searching jobs on LinkedIn's public jobs-guest endpoints,
|
||
|
|
// for any country/region (plus remote). No external CLI framework, so it runs
|
||
|
|
// anywhere `bun` is available with zero install beyond the repo clone.
|
||
|
|
//
|
||
|
|
// Personal use only. This reads LinkedIn's public job pages; automated access is
|
||
|
|
// against LinkedIn's Terms of Service, so keep volume low and do not use it
|
||
|
|
// commercially or for bulk data collection. Run it on your own responsibility.
|
||
|
|
|
||
|
|
import { runSearch, type SearchOpts } from "./commands/search.js"
|
||
|
|
import { runDetail, type DetailOpts } from "./commands/detail.js"
|
||
|
|
|
||
|
|
interface Flags {
|
||
|
|
_: string[]
|
||
|
|
[k: string]: string | boolean | string[]
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseFlags(argv: string[]): Flags {
|
||
|
|
const flags: Flags = { _: [] }
|
||
|
|
const alias: Record<string, string> = { q: "query", l: "location", n: "limit" }
|
||
|
|
for (let i = 0; i < argv.length; i++) {
|
||
|
|
const a = argv[i]
|
||
|
|
if (a.startsWith("--") || a.startsWith("-")) {
|
||
|
|
const key = alias[a.replace(/^-+/, "")] ?? a.replace(/^-+/, "")
|
||
|
|
const next = argv[i + 1]
|
||
|
|
if (next === undefined || next.startsWith("-")) {
|
||
|
|
flags[key] = true
|
||
|
|
} else {
|
||
|
|
flags[key] = next
|
||
|
|
i++
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
;(flags._ as string[]).push(a)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return flags
|
||
|
|
}
|
||
|
|
|
||
|
|
const HELP = `linkedin-cli — search jobs on LinkedIn (any country/region, plus remote)
|
||
|
|
|
||
|
|
USAGE
|
||
|
|
bun run src/cli.ts search --location "<place>" [flags]
|
||
|
|
bun run src/cli.ts detail <id|url> [--format json|plain]
|
||
|
|
|
||
|
|
SEARCH FLAGS
|
||
|
|
--location, -l <text> Location to search. REQUIRED. e.g. "Mumbai, Maharashtra, India",
|
||
|
|
"Berlin, Germany", "London, United Kingdom", or "Remote".
|
||
|
|
--query, -q <text> Keywords (job title, skill, or role). Recommended.
|
||
|
|
--jobage <days> Posted within N days: 1, 7, 14, 30. Default: all.
|
||
|
|
--remote <mode> remote | hybrid | onsite. Filter by workplace type.
|
||
|
|
--page <n> 1-indexed page (10 results/page). Default 1.
|
||
|
|
--limit, -n <n> Cap results emitted (client-side).
|
||
|
|
--format <fmt> json (default) | table | plain.
|
||
|
|
|
||
|
|
EXAMPLES
|
||
|
|
bun run src/cli.ts search -q "data engineer" -l "Bengaluru, Karnataka, India" --jobage 30 --format table
|
||
|
|
bun run src/cli.ts search -q "product manager" -l "Berlin, Germany" --remote remote --format table
|
||
|
|
bun run src/cli.ts search -q "paralegal" -l "Remote" --format table
|
||
|
|
bun run src/cli.ts detail 4300011451 --format plain
|
||
|
|
|
||
|
|
Personal use only — uses LinkedIn's public pages; keep volume low (LinkedIn ToS).
|
||
|
|
`
|
||
|
|
|
||
|
|
async function main(): Promise<number> {
|
||
|
|
const argv = process.argv.slice(2)
|
||
|
|
const flags = parseFlags(argv)
|
||
|
|
const cmd = (flags._ as string[])[0]
|
||
|
|
|
||
|
|
if (!cmd || flags.help || flags.h) {
|
||
|
|
process.stdout.write(HELP)
|
||
|
|
return cmd ? 0 : 1
|
||
|
|
}
|
||
|
|
|
||
|
|
if (cmd === "search") {
|
||
|
|
const location = typeof flags.location === "string" ? flags.location : undefined
|
||
|
|
if (!location) {
|
||
|
|
process.stderr.write(
|
||
|
|
JSON.stringify({
|
||
|
|
error: 'the --location/-l flag is required (e.g. -l "Mumbai, Maharashtra, India", -l "Berlin, Germany", or -l "Remote")',
|
||
|
|
code: "NO_LOCATION",
|
||
|
|
}) + "\n",
|
||
|
|
)
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
const fmt = (flags.format as string) || "json"
|
||
|
|
const opts: SearchOpts = {
|
||
|
|
query: typeof flags.query === "string" ? flags.query : undefined,
|
||
|
|
location,
|
||
|
|
jobage: flags.jobage ? parseInt(flags.jobage as string, 10) : 9999,
|
||
|
|
remote: typeof flags.remote === "string" ? flags.remote : undefined,
|
||
|
|
page: flags.page ? Math.max(1, parseInt(flags.page as string, 10)) : 1,
|
||
|
|
limit: flags.limit ? parseInt(flags.limit as string, 10) : undefined,
|
||
|
|
format: (["json", "table", "plain"].includes(fmt) ? fmt : "json") as SearchOpts["format"],
|
||
|
|
}
|
||
|
|
return runSearch(opts)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (cmd === "detail") {
|
||
|
|
const id = (flags._ as string[])[1]
|
||
|
|
if (!id) {
|
||
|
|
process.stderr.write(JSON.stringify({ error: "detail requires an <id|url>", code: "NO_ID" }) + "\n")
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
const fmt = (flags.format as string) || "json"
|
||
|
|
const opts: DetailOpts = {
|
||
|
|
id,
|
||
|
|
format: (fmt === "plain" ? "plain" : "json") as DetailOpts["format"],
|
||
|
|
}
|
||
|
|
return runDetail(opts)
|
||
|
|
}
|
||
|
|
|
||
|
|
process.stderr.write(JSON.stringify({ error: `Unknown command "${cmd}"`, code: "BAD_CMD" }) + "\n")
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
|
||
|
|
main().then((code) => process.exit(code))
|