Files
ai-job-search/.agents/skills/linkedin-search/cli/src/cli.ts
T
AshutoshandClaude Sonnet 4.6 e595663dc1 fix: NaN filter bypass in LinkedIn CLI --jobage/--page/--limit flags (#35)
* fix: silent zero output in salary converter and NaN filter bypass in LinkedIn CLI

Bug #10 (convert_salary_excel.py):
openpyxl ws[row_index] random access fails silently under read_only=True,
leaving headers empty and producing no output. Fix: save the header row
values during the existing iter_rows scan so ws[header_row] is never called.

Bug #5 (.agents/skills/linkedin-search/cli/src/cli.ts):
parseInt on --jobage/--page/--limit flags returns NaN on non-numeric input.
NaN propagates silently — the jobage filter is dropped, page/limit are broken.
Fix: validate each parsed int, exit 1 with a structured BAD_ARG error on NaN.

Also adds:
- tests/test_bug10_salary_converter.py: 10 scenarios, 31 assertions (all green)
- tests/test_bug5_linkedin_cli.sh: 8 scenarios, 16 assertions (all green)
- docs/bugfixes.md: root cause, impact, and fix explanation for both bugs,
  plus a note on the pre-existing detect_column_type "n" pattern issue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address PR review — drop salary converter change, move test to bun

- Drop tools/convert_salary_excel.py change (bug not reproducible on
  modern openpyxl; reviewer confirmed master works correctly)
- Drop tests/test_bug10_salary_converter.py and top-level tests/ dir
- Drop docs/bugfixes.md (analysis belongs in PR description, not repo)
- Replace tests/test_bug5_linkedin_cli.sh with a proper bun test file
  at .agents/skills/linkedin-search/cli/tests/cli-flag-validation.test.ts
  following the jobindex-search/cli/tests/ convention (runCLI/parseJSON
  helpers, describe/test/expect, descriptive names)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 21:23:29 +02:00

143 lines
5.1 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 parseIntFlag = (name: string, raw: string | boolean | string[]): number | null => {
const val = parseInt(raw as string, 10)
if (isNaN(val)) {
process.stderr.write(JSON.stringify({ error: `--${name} must be a number, got "${raw}"`, code: "BAD_ARG" }) + "\n")
return null
}
return val
}
if (flags.jobage !== undefined) {
const v = parseIntFlag("jobage", flags.jobage)
if (v === null) return 1
flags.jobage = String(v)
}
if (flags.page !== undefined) {
const v = parseIntFlag("page", flags.page)
if (v === null) return 1
flags.page = String(v)
}
if (flags.limit !== undefined) {
const v = parseIntFlag("limit", flags.limit)
if (v === null) return 1
flags.limit = String(v)
}
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))