feat: add skills

This commit is contained in:
Prad Nukala committed 2026-08-26 10:35:34 -04:00
1 parent 6984212b17
commit c50aa92862
150 files changed
+12702

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
# linkedin-cli
CLI for searching jobs on LinkedIn's public job listings, for **any country/region**
(and remote), across any sector.
**Data source**: LinkedIn `jobs-guest` endpoints (`seeMoreJobPostings/search` and `jobPosting/<id>`).
**Authentication**: None required.
**Dependencies**: None (plain `bun` + `fetch`). `bun install` is optional and only pulls dev type defs.
> **Personal use only.** This uses LinkedIn's public job pages; automated access is against
> LinkedIn's Terms of Service. Keep volume low, don't use it commercially or for bulk data
> collection, and run it on your own responsibility.
## Installation
```bash
cd .agents/skills/linkedin-search/cli
bun install # optional — only installs TypeScript dev types
```
The CLI runs without any install because it has zero runtime dependencies.
## Commands
| Command | Description |
|---------|-------------|
| `search` | Search for job listings (`--location` required) |
| `detail` | Fetch full detail for a single job listing |
`search` accepts `--format json|table|plain` (default `json`); `detail` accepts `--format json|plain`.
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` with exit code `1`.
## Quick examples
```bash
# Software roles in Hyderabad, last 7 days
bun run src/cli.ts search -q "backend engineer" -l "Hyderabad, Telangana, India" --jobage 7 --format table
# Design roles in London
bun run src/cli.ts search -q "product designer" -l "London, United Kingdom" --format table
# Fully remote
bun run src/cli.ts search -q "technical writer" -l "Remote" --remote remote --format table
# Full detail for one job
bun run src/cli.ts detail 4426311357 --format plain
```
See `../SKILL.md` for the full flag reference and the Terms-of-Service note.
## Search flags
| Flag | Alias | Description |
|------|-------|-------------|
| `--location` | `-l` | **Required.** Place string, e.g. `"Mumbai, Maharashtra, India"`, `"Berlin, Germany"`, `"Remote"`. |
| `--query` | `-q` | Keywords (title / skill / role). Recommended. |
| `--jobage` | | Posted within N days: `1`, `7`, `14`, `30`. |
| `--remote` | | `remote` \| `hybrid` \| `onsite`. |
| `--page` | | 1-indexed page (10 results/page). |
| `--limit` | `-n` | Cap results emitted. |
| `--format` | | `json` \| `table` \| `plain`. |
+20
View File
@@ -0,0 +1,20 @@
{
"name": "linkedin-cli",
"version": "1.0.0",
"description": "CLI for searching jobs on LinkedIn's public job listings, for any country/region (and remote) — no authentication, zero runtime dependencies. Personal use only (LinkedIn ToS).",
"type": "module",
"main": "src/cli.ts",
"bin": {
"linkedin-search": "src/cli.ts"
},
"scripts": {
"start": "bun run src/cli.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsc --noEmit"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.4.0",
"@types/bun": "1.3.14"
}
}
+206
View File
@@ -0,0 +1,206 @@
#!/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.
--jobage-minutes <n> Posted within N minutes (sub-day precision). Conflicts with --jobage.
--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 search -q "engineer" -l "Remote" --jobage-minutes 30 --format table
bun run src/cli.ts detail 4300011451 --format plain
Personal use only — uses LinkedIn's public pages; keep volume low (LinkedIn ToS).
`
// Long-form flag names each command accepts (parseFlags resolves the short
// aliases q/l/n to these before validation). "help"/"h" pass so `search --help`
// still prints usage.
const KNOWN_FLAGS: Record<string, Set<string>> = {
search: new Set([
"location", "query", "jobage", "jobage-minutes", "remote", "page", "limit", "format", "help", "h",
]),
detail: new Set(["format", "help", "h"]),
}
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
}
// Reject unknown flags instead of silently discarding them: a discarded
// filter changes what the search returns with no error (a wrong flag name
// once returned an entire portal's database as if it matched the query).
// add-portal.md's contract requires a bogus flag to exit 1 with a JSON
// error on stderr.
const knownFlags = KNOWN_FLAGS[cmd]
if (knownFlags) {
for (const key of Object.keys(flags)) {
if (key === "_" || knownFlags.has(key)) continue
process.stderr.write(
JSON.stringify({
error: `unknown flag --${key} for '${cmd}' - flags are never silently ignored, because a discarded filter changes what the search returns; see --help for the supported flags`,
code: "UNKNOWN_FLAG",
}) + "\n",
)
return 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"
if (flags.jobage !== undefined && flags["jobage-minutes"] !== undefined) {
process.stderr.write(
JSON.stringify({
error: "--jobage and --jobage-minutes both set a freshness window; pass only one",
code: "CONFLICTING_AGE_FLAGS",
}) + "\n",
)
return 1
}
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["jobage-minutes"] !== undefined) {
const raw = flags["jobage-minutes"]
const v = parseIntFlag("jobage-minutes", raw)
if (v === null) return 1
if (v <= 0) {
process.stderr.write(
JSON.stringify({ error: `--jobage-minutes must be a positive number, got "${raw}"`, code: "BAD_ARG" }) + "\n",
)
return 1
}
flags["jobage-minutes"] = 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,
jobageMinutes: flags["jobage-minutes"] ? parseInt(flags["jobage-minutes"] as string, 10) : undefined,
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))
.catch((e) => {
process.stderr.write(
JSON.stringify({
error: e instanceof Error ? e.message : String(e),
code: "INTERNAL_ERROR",
}) + "\n",
)
process.exit(1)
})
+56
View File
@@ -0,0 +1,56 @@
import { DETAIL_URL, htmlFetch, parseJobDetail, writeError } from "../helpers.js"
export interface DetailOpts {
id: string
format: "json" | "plain"
}
/** Accept a raw job ID, a job-view URL, or a job URN. */
function normalizeId(input: string): string | null {
const urn = input.match(/urn:li:jobPosting:(\d+)/)
if (urn) return urn[1]
const url = input.match(/-(\d{6,})(?:\?|$)/) || input.match(/\/(\d{6,})(?:\?|$)/)
if (url) return url[1]
const bare = input.match(/^\d{6,}$/)
if (bare) return input
return null
}
export async function runDetail(opts: DetailOpts): Promise<number> {
const id = normalizeId(opts.id)
if (!id) {
writeError(`Could not parse a job ID from "${opts.id}"`, "BAD_ID")
return 1
}
try {
const html = await htmlFetch(`${DETAIL_URL}/${id}`)
if (!html) {
writeError("Job not found", "NOT_FOUND")
return 1
}
const job = parseJobDetail(html, id)
if (opts.format === "plain") {
const lines = [
job.title,
`${job.company || "—"} · ${job.location || "—"}`,
"",
job.seniority ? `Seniority: ${job.seniority}` : "",
job.employmentType ? `Employment: ${job.employmentType}` : "",
job.jobFunction ? `Function: ${job.jobFunction}` : "",
job.industries ? `Industries: ${job.industries}` : "",
"",
job.description || "(no description)",
"",
`URL: ${job.url}`,
].filter((l) => l !== "")
process.stdout.write(lines.join("\n") + "\n")
} else {
process.stdout.write(JSON.stringify(job, null, 2) + "\n")
}
return 0
} catch (e) {
writeError(e instanceof Error ? e.message : String(e), "DETAIL_FAILED")
return 1
}
}
+87
View File
@@ -0,0 +1,87 @@
import {
SEARCH_URL,
htmlFetch,
parseJobCards,
jobageToTPR,
minutesToTPR,
workTypeFlag,
writeError,
type JobCard,
} from "../helpers.js"
export interface SearchOpts {
query?: string
location: string
jobage: number
jobageMinutes?: number
remote?: string // "remote" | "hybrid" | "onsite"
page: number
limit?: number
format: "json" | "table" | "plain"
}
function buildUrl(opts: SearchOpts): string {
const params = new URLSearchParams()
if (opts.query) params.set("keywords", opts.query)
if (opts.location) params.set("location", opts.location)
const tpr = opts.jobageMinutes !== undefined ? minutesToTPR(opts.jobageMinutes) : jobageToTPR(opts.jobage)
if (tpr) params.set("f_TPR", tpr)
const wt = workTypeFlag(opts.remote)
if (wt) params.set("f_WT", wt)
params.set("start", String((opts.page - 1) * 10))
return `${SEARCH_URL}?${params.toString()}`
}
function renderTable(cards: JobCard[]): string {
if (cards.length === 0) return "No results."
const rows = cards.map((c) => {
const title = (c.title || "").slice(0, 42).padEnd(42)
const company = (c.company || "—").slice(0, 26).padEnd(26)
const loc = (c.location || "—").slice(0, 24).padEnd(24)
const date = c.date || "—"
return `${c.id.padEnd(11)} ${title} ${company} ${loc} ${date}`
})
const header =
"ID".padEnd(11) +
" " +
"TITLE".padEnd(42) +
" " +
"COMPANY".padEnd(26) +
" " +
"LOCATION".padEnd(24) +
" DATE"
return [header, "-".repeat(header.length), ...rows].join("\n")
}
export async function runSearch(opts: SearchOpts): Promise<number> {
try {
const html = await htmlFetch(buildUrl(opts))
let cards = parseJobCards(html)
if (opts.limit !== undefined && opts.limit >= 0) cards = cards.slice(0, opts.limit)
if (opts.format === "table") {
process.stdout.write(renderTable(cards) + "\n")
} else if (opts.format === "plain") {
process.stdout.write(
cards
.map(
(c) =>
`${c.title}\n ${c.company || "—"} · ${c.location || "—"} · ${c.date || "—"}\n id: ${c.id}\n ${c.url}`,
)
.join("\n\n") + "\n",
)
} else {
process.stdout.write(
JSON.stringify(
{ meta: { count: cards.length, page: opts.page }, results: cards },
null,
2,
) + "\n",
)
}
return 0
} catch (e) {
writeError(e instanceof Error ? e.message : String(e), "SEARCH_FAILED")
return 1
}
}
+271
View File
@@ -0,0 +1,271 @@
// Data source: LinkedIn public "jobs-guest" endpoints. No authentication required.
// Search returns an HTML list of job cards; detail returns a single job's HTML.
// We parse both with regex (the markup is shallow and stable; a full DOM parser
// is unnecessary and node-html-parser has known nesting bugs on LinkedIn cards).
export const SEARCH_URL =
"https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search"
export const DETAIL_URL =
"https://www.linkedin.com/jobs-guest/jobs/api/jobPosting"
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
const UA = "Mozilla/5.0 (compatible; linkedin-search-cli/1.0)"
/** Fetch HTML with exponential backoff on 429/5xx. Returns "" on a 404. */
export async function htmlFetch(url: string): Promise<string> {
const maxRetries = 6
let delay = 500
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, {
headers: {
"User-Agent": UA,
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"X-Requested-With": "XMLHttpRequest",
},
redirect: "follow",
signal: AbortSignal.timeout(15000),
})
if (response.status === 429 || response.status >= 500) {
if (attempt === maxRetries) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`)
}
const jitter = Math.floor(Math.random() * 500)
await new Promise((r) => setTimeout(r, delay + jitter))
delay = Math.min(delay * 2, 8000)
continue
}
if (response.status === 404) return ""
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`)
}
return response.text()
}
throw new Error("Request failed after max retries")
}
export interface JobCard {
id: string
title: string
company: string | null
companyUrl: string | null
location: string | null
date: string | null
url: string
}
export interface JobDetail extends JobCard {
description: string | null
seniority: string | null
employmentType: string | null
jobFunction: string | null
industries: string | null
}
/**
* Extract the inner HTML of a <div> identified by a CSS class name, correctly
* handling nested <div> elements by tracking tag depth.
*/
export function extractDivContent(html: string, className: string): string | null {
const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const openRe = new RegExp(`<div[^>]*class="[^"]*${escaped}[^"]*"[^>]*>`, 'i')
const open = openRe.exec(html)
if (!open) return null
let i = open.index + open[0].length
let depth = 1
while (depth > 0 && i < html.length) {
const nextOpen = html.indexOf('<div', i)
const nextClose = html.indexOf('</div>', i)
if (nextClose === -1) return null
if (nextOpen !== -1 && nextOpen < nextClose) {
depth++
i = nextOpen + 4
} else {
depth--
i = nextClose + 6
}
}
return html.slice(open.index + open[0].length, i - 6)
}
/**
* Convert a Unicode code point to a string. Uses `fromCodePoint` (not
* `fromCharCode`) so supplementary-plane code points (e.g. emoji, U+1F600)
* decode correctly, and drops out-of-range values instead of throwing.
*/
function numericEntity(cp: number): string {
return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : ""
}
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
// Numeric character references: decimal (&#233;) and hexadecimal (&#xE9;).
.replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10)))
.replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16)))
.replace(/&nbsp;/g, " ")
}
function stripTags(html: string): string {
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
}
function clean(html: string): string {
return decodeHtmlEntities(stripTags(html))
}
/** Parse the job ID out of a LinkedIn job-view URL or URN. */
function idFromUrl(url: string): string | null {
const m = url.match(/-(\d{6,})(?:\?|$)/) || url.match(/(\d{6,})/)
return m ? m[1] : null
}
/**
* Parse the search response: a flat list of <li> job cards. We split on the
* job-posting URN and parse each chunk independently so one malformed card
* cannot break the rest.
*/
export function parseJobCards(html: string): JobCard[] {
const results: JobCard[] = []
const chunks = html.split(/data-entity-urn="urn:li:jobPosting:/).slice(1)
for (const chunk of chunks) {
const idMatch = chunk.match(/^(\d+)/)
if (!idMatch) continue
const id = idMatch[1]
// Full link + title (title lives in the sr-only span or the <h3> title).
const linkMatch = chunk.match(/class="base-card__full-link[^"]*"[^>]*href="([^"]+)"/i)
const url = linkMatch ? decodeHtmlEntities(linkMatch[1]).split("?")[0] : ""
let title: string | null = null
const h3 = chunk.match(/class="base-search-card__title"[^>]*>([\s\S]*?)<\/h3>/i)
if (h3) title = clean(h3[1])
if (!title) {
const sr = chunk.match(/class="sr-only"[^>]*>([\s\S]*?)<\/span>/i)
if (sr) title = clean(sr[1])
}
if (!title) continue
// Company (subtitle <h4> with optional inner <a>).
let company: string | null = null
let companyUrl: string | null = null
const sub = chunk.match(/class="base-search-card__subtitle"[^>]*>([\s\S]*?)<\/h4>/i)
if (sub) {
const a = sub[1].match(/href="([^"]+)"/i)
if (a) companyUrl = decodeHtmlEntities(a[1]).split("?")[0]
company = clean(sub[1]) || null
}
// Location + date.
const loc = chunk.match(/class="job-search-card__location"[^>]*>([\s\S]*?)<\/span>/i)
const location = loc ? clean(loc[1]) || null : null
const dt = chunk.match(/class="job-search-card__listdate[^"]*"[^>]*datetime="([^"]+)"/i)
const date = dt ? dt[1] : null
results.push({
id,
title,
company,
companyUrl,
location,
date,
url: url || `https://www.linkedin.com/jobs/view/${id}`,
})
}
return results
}
/** Parse the single-job detail page. */
export function parseJobDetail(html: string, id: string): JobDetail {
const title = html.match(
/class="(?:top-card-layout__title|topcard__title)[^"]*"[^>]*>([\s\S]*?)<\/h[12]>/i,
)?.[1]
const orgMatch = html.match(
/class="topcard__org-name-link[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i,
)
const company = orgMatch ? clean(orgMatch[2]) || null : null
const companyUrl = orgMatch ? decodeHtmlEntities(orgMatch[1]).split("?")[0] : null
const locMatch = html.match(
/class="topcard__flavor topcard__flavor--bullet"[^>]*>([\s\S]*?)<\/span>/i,
)
const location = locMatch ? clean(locMatch[1]) || null : null
// Rich description block. Keep paragraph/line breaks as newlines.
let description: string | null = null
const descHtml =
extractDivContent(html, "show-more-less-html__markup") ??
extractDivContent(html, "description__text")
if (descHtml) {
const withBreaks = descHtml
.replace(/<\s*br\s*\/?>/gi, "\n")
.replace(/<\/(p|li|ul|ol|div|h\d)>/gi, "\n")
description = decodeHtmlEntities(stripTags(withBreaks)).replace(/\n{3,}/g, "\n\n").trim() || null
}
// Job-criteria items: subheader label -> text value.
const criteria: Record<string, string> = {}
const itemRe =
/class="description__job-criteria-subheader"[^>]*>([\s\S]*?)<\/h3>[\s\S]*?class="description__job-criteria-text[^"]*"[^>]*>([\s\S]*?)<\/span>/gi
let cm: RegExpExecArray | null
while ((cm = itemRe.exec(html)) !== null) {
criteria[clean(cm[1]).toLowerCase()] = clean(cm[2])
}
return {
id,
title: title ? clean(title) : "(untitled)",
company,
companyUrl,
location,
date: null,
url: `https://www.linkedin.com/jobs/view/${id}`,
description,
seniority: criteria["seniority level"] ?? null,
employmentType: criteria["employment type"] ?? null,
jobFunction: criteria["job function"] ?? null,
industries: criteria["industries"] ?? null,
}
}
/** Convert a job-age in days to LinkedIn's f_TPR seconds value. */
export function jobageToTPR(days: number): string | null {
if (!days || days <= 0 || days >= 9999) return null
return `r${days * 86400}`
}
/** Convert a job-age in minutes to LinkedIn's f_TPR seconds value (sub-day precision). */
export function minutesToTPR(minutes: number): string | null {
if (!minutes || minutes <= 0) return null
return `r${minutes * 60}`
}
/** Workplace-type flag: on-site=1, remote=2, hybrid=3. */
export function workTypeFlag(mode: string | undefined): string | null {
switch ((mode || "").toLowerCase()) {
case "remote":
return "2"
case "hybrid":
return "3"
case "onsite":
case "on-site":
return "1"
default:
return null
}
}
@@ -0,0 +1,146 @@
import { describe, test, expect } from "bun:test";
import { runCLI } from "./helpers";
const LOCATION = "Copenhagen, Denmark";
function parsedStderr(stderr: string): { error?: string; code?: string } {
try {
return JSON.parse(stderr);
} catch {
return {};
}
}
describe("LinkedIn CLI flag validation", () => {
describe("--jobage NaN validation", () => {
test("non-numeric string exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--jobage", "foo"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(/jobage/);
});
test("boolean flag (no value) exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--jobage"]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toBeTruthy();
});
test("valid integer passes validation", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--jobage", "7", "--limit", "1"]);
const err = parsedStderr(result.stderr);
expect(err.code).not.toBe("BAD_ARG");
});
test("float string truncated to integer, no error", async () => {
// parseInt("7.5") = 7, which is valid
const result = await runCLI(["search", "-l", LOCATION, "--jobage", "7.5", "--limit", "1"]);
const err = parsedStderr(result.stderr);
expect(err.code).not.toBe("BAD_ARG");
});
test("zero is accepted (falsy int should not be treated as missing)", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--jobage", "0", "--limit", "1"]);
const err = parsedStderr(result.stderr);
expect(err.code).not.toBe("BAD_ARG");
});
});
describe("--jobage-minutes validation", () => {
test("non-numeric string exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "foo"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(/jobage-minutes/);
});
test("zero exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "0"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(/jobage-minutes/);
});
test("negative value is parsed as a missing value and exits 1 with BAD_ARG", async () => {
// parseFlags in cli.ts treats a next-token starting with "-" as absent
// (`next.startsWith("-")` → flag becomes boolean `true`), and there is no
// `--flag=value` syntax. So "-5" never reaches --jobage-minutes as a value;
// it parses as a stray flag named "5", which the unknown-flag guard now
// rejects before the NaN branch can. Either way the invariant holds: a
// negative value fails loudly with exit 1 and a JSON error, never a
// silent unfiltered search.
const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "-5"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("UNKNOWN_FLAG");
});
});
describe("--jobage / --jobage-minutes conflict", () => {
test("both set exits 1 with CONFLICTING_AGE_FLAGS", async () => {
const result = await runCLI([
"search", "-l", LOCATION, "--jobage", "7", "--jobage-minutes", "30",
]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("CONFLICTING_AGE_FLAGS");
});
});
describe("--page NaN validation", () => {
test("non-numeric string exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--page", "abc"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(/page/);
});
});
describe("--limit NaN validation", () => {
test("non-numeric string exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--limit", "xyz"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(/limit/);
});
});
describe("existing validations (regression)", () => {
test("missing --location exits 1 with NO_LOCATION", async () => {
const result = await runCLI(["search"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("NO_LOCATION");
});
test("all valid flags produce no BAD_ARG", async () => {
const result = await runCLI([
"search", "-l", LOCATION, "--jobage", "7", "--page", "1", "--limit", "5",
]);
const err = parsedStderr(result.stderr);
expect(err.code).not.toBe("BAD_ARG");
});
});
});
describe("unknown flag rejection", () => {
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
// with a JSON error on stderr". A silently discarded flag is worse than an
// error: on jobdanmark a wrong flag name returned the entire database
// (13,862 results) as if it matched the query (review finding F13,
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
const result = await runCLI(["search", "-l", "Denmark", "-q", "test", "--bogus-flag", "xyz"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
const error = JSON.parse(result.stderr);
expect(error.code).toBe("UNKNOWN_FLAG");
expect(error.error).toContain("--bogus-flag");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { join } from "path";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
export interface CLIResult {
stdout: string;
stderr: string;
exitCode: number;
}
export async function runCLI(args: string[]): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
}
export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) {
throw new Error(
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
);
}
try {
return JSON.parse(result.stdout) as T;
} catch {
throw new Error(
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import { describe, test, expect } from "bun:test";
import { parseJobCards, parseJobDetail, extractDivContent, minutesToTPR } from "../src/helpers";
// Minimal search-card markup: parseJobCards splits on the job-posting URN and
// needs an id, a base-search-card__title, and a full-link. Everything else is
// optional. We inject HTML entities into the title/company to exercise decoding.
function searchCard(id: string, title: string, company = "Acme"): string {
return `<li>
<div data-entity-urn="urn:li:jobPosting:${id}">
<a class="base-card__full-link" href="https://www.linkedin.com/jobs/view/${id}"></a>
<h3 class="base-search-card__title">${title}</h3>
<h4 class="base-search-card__subtitle"><a href="https://www.linkedin.com/company/acme">${company}</a></h4>
</div>
</li>`;
}
// The /scrape contract fields beyond title/company. The original fixture had
// no <time> or location element at all, so deleting the date extraction from
// parseJobCards left every test green (review finding F35, 2026-08-19).
function searchCardWithMeta(id: string, datetimeAttr: string, listdateClass = "job-search-card__listdate"): string {
return `<li>
<div data-entity-urn="urn:li:jobPosting:${id}">
<a class="base-card__full-link" href="https://www.linkedin.com/jobs/view/${id}"></a>
<h3 class="base-search-card__title">Data Engineer</h3>
<h4 class="base-search-card__subtitle"><a href="https://www.linkedin.com/company/acme">Acme</a></h4>
<span class="job-search-card__location">Copenhagen, Denmark</span>
<time class="${listdateClass}" datetime="${datetimeAttr}">3 days ago</time>
</div>
</li>`;
}
describe("parseJobCards contract fields", () => {
test("extracts date from the listdate <time> element", () => {
const [card] = parseJobCards(searchCardWithMeta("200", "2026-08-10"));
expect(card.date).toBe("2026-08-10");
});
test("extracts date from the listdate--new variant class", () => {
const [card] = parseJobCards(
searchCardWithMeta("201", "2026-08-15", "job-search-card__listdate--new"),
);
expect(card.date).toBe("2026-08-15");
});
test("extracts location from the location span", () => {
const [card] = parseJobCards(searchCardWithMeta("202", "2026-08-10"));
expect(card.location).toBe("Copenhagen, Denmark");
});
test("date and location are null when the elements are absent", () => {
const [card] = parseJobCards(searchCard("203", "Bare Card"));
expect(card.date).toBeNull();
expect(card.location).toBeNull();
});
});
describe("decodeHtmlEntities (via parseJobCards)", () => {
test("decodes hexadecimal numeric entities (&#xE9;)", () => {
const [card] = parseJobCards(searchCard("123", "Caf&#xE9; Manager"));
expect(card.title).toBe("Café Manager");
});
test("decodes uppercase-X hexadecimal entities (&#X...;)", () => {
const [card] = parseJobCards(searchCard("124", "Deb&#XFC;t Role")); // &#XFC; = ü
expect(card.title).toBe("Debüt Role");
});
test("still decodes decimal numeric entities (&#233;) — regression", () => {
const [card] = parseJobCards(searchCard("125", "Caf&#233; Lead"));
expect(card.title).toBe("Café Lead");
});
test("decodes supplementary-plane code points with fromCodePoint (&#128512;)", () => {
const [card] = parseJobCards(searchCard("126", "Growth &#128512;"));
expect(card.title).toBe("Growth 😀");
});
test("decodes hex supplementary-plane code points (&#x1F600;)", () => {
const [card] = parseJobCards(searchCard("127", "Growth &#x1F600;"));
expect(card.title).toBe("Growth 😀");
});
test("decodes hex entities in the company subtitle too", () => {
const [card] = parseJobCards(searchCard("128", "Engineer", "N&#xF8;rrebro ApS"));
expect(card.company).toBe("Nørrebro ApS");
});
});
describe("parseJobDetail dropped fields", () => {
test("emits no applyUrl field", () => {
// The extraction regex assumed class-before-href and never matched
// LinkedIn's real markup (null on every live posting), and a fixed
// version would only capture the job-view URL - a duplicate of `url`.
// The field is dropped rather than fixed (review finding F19,
// 2026-08-19). This test pins the removal so it does not quietly
// return as a broken or redundant field.
const job = parseJobDetail("<html></html>", "1");
expect("applyUrl" in job).toBe(false);
});
});
describe("decodeHtmlEntities (via parseJobDetail)", () => {
test("decodes hex entities inside the job title", () => {
const html = `<h1 class="topcard__title">Se&#xF1;or Engineer</h1>`;
const job = parseJobDetail(html, "999");
expect(job.title).toBe("Señor Engineer");
});
});
describe("extractDivContent", () => {
test("extracts content from simple div", () => {
const html = '<div class="description__text">Simple text</div>';
expect(extractDivContent(html, "description__text")).toBe("Simple text");
});
test("extracts content with nested divs — the regression case", () => {
const html = `<div class="description__text">
<div>Requirements:</div>
<ul><li>Skill A</li></ul>
<div>About Us:</div>
<p>We are...</p>
</div>`;
expect(extractDivContent(html, "description__text")).toBe(
'\n <div>Requirements:</div>\n <ul><li>Skill A</li></ul>\n <div>About Us:</div>\n <p>We are...</p>\n ',
);
});
test("returns null when class not found", () => {
expect(extractDivContent("<div>no class</div>", "nonexistent")).toBeNull();
});
test("works with show-more-less-html__markup class", () => {
const html = '<div class="show-more-less-html__markup">LinkedIn content</div>';
expect(extractDivContent(html, "show-more-less-html__markup")).toBe("LinkedIn content");
});
test("handles deeply nested divs (3 levels)", () => {
const html = `<div class="description__text">
<div>
<div>Deep content</div>
</div>
</div>`;
expect(extractDivContent(html, "description__text")).toBe(
'\n <div>\n <div>Deep content</div>\n </div>\n ',
);
});
test("handles empty content", () => {
const html = '<div class="description__text"></div>';
expect(extractDivContent(html, "description__text")).toBe("");
});
test("parseJobDetail uses extractDivContent and preserves full description", () => {
const html = `<div class="description__text">
<div>Requirements:</div>
<ul><li>5 years Python</li></ul>
<div>About Us:</div>
<p>We are hiring!</p>
</div>`;
const job = parseJobDetail(html, "999");
expect(job.description).toContain("Requirements:");
expect(job.description).toContain("5 years Python");
expect(job.description).toContain("About Us:");
expect(job.description).toContain("We are hiring!");
});
});
describe("minutesToTPR", () => {
test("converts minutes to an f_TPR seconds window", () => {
expect(minutesToTPR(30)).toBe("r1800");
expect(minutesToTPR(1)).toBe("r60");
expect(minutesToTPR(1440)).toBe("r86400"); // matches jobageToTPR(1)
});
test("returns null for non-positive input", () => {
expect(minutesToTPR(0)).toBeNull();
expect(minutesToTPR(-5)).toBeNull();
});
});
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, test } from "bun:test";
import { htmlFetch } from "../src/helpers";
// A stalled upstream connection (accepted socket, no response) would otherwise
// hang the CLI forever - fetch has no default timeout. Assert the request
// wrapper carries an AbortSignal timeout. Fails on the pre-fix code (no signal).
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("htmlFetch request timeout", () => {
test("passes an AbortSignal timeout to fetch", async () => {
let init: RequestInit | undefined;
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
init = i;
return new Response("<html></html>", { status: 200 });
}) as unknown as typeof fetch;
await htmlFetch("https://www.linkedin.com/jobs-guest/jobs/api/x");
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
});
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, test } from "bun:test";
import { htmlFetch } from "../src/helpers";
// The portal contract requires backoff on 429/5xx. These tests pin the retry
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
// fires immediately so the exhaustion case does not sleep through the real
// 500ms -> 8s backoff schedule.
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
afterEach(() => {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
});
function instantTimers() {
globalThis.setTimeout = ((fn: () => void) =>
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
}
function stubFetch(responses: Array<() => Response>): { calls: number } {
const state = { calls: 0 };
globalThis.fetch = (async () => {
const i = Math.min(state.calls, responses.length - 1);
state.calls++;
return responses[i]();
}) as unknown as typeof fetch;
return state;
}
describe("htmlFetch retry/backoff", () => {
test("retries a 429 and succeeds on the next attempt", async () => {
instantTimers();
const state = stubFetch([
() => new Response("", { status: 429 }),
() => new Response("<html>ok</html>", { status: 200 }),
]);
const html = await htmlFetch("https://www.linkedin.com/x");
expect(html).toContain("ok");
expect(state.calls).toBe(2);
});
test("returns the documented empty string on 404 without retrying", async () => {
const state = stubFetch([() => new Response("", { status: 404 })]);
const html = await htmlFetch("https://www.linkedin.com/x");
expect(html).toBe("");
expect(state.calls).toBe(1);
});
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
instantTimers();
const state = stubFetch([() => new Response("", { status: 500 })]);
await expect(htmlFetch("https://www.linkedin.com/x")).rejects.toThrow(/500/);
expect(state.calls).toBe(7);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, test } from "bun:test";
import { runSearch } from "../src/commands/search";
const originalFetch = globalThis.fetch;
const originalStdoutWrite = process.stdout.write;
function searchCard(id: string, title: string): string {
return `<li>
<div data-entity-urn="urn:li:jobPosting:${id}">
<a class="base-card__full-link" href="https://www.linkedin.com/jobs/view/${id}"></a>
<h3 class="base-search-card__title">${title}</h3>
</div>
</li>`;
}
afterEach(() => {
globalThis.fetch = originalFetch;
process.stdout.write = originalStdoutWrite;
});
describe("runSearch", () => {
test("--limit 0 emits zero results", async () => {
globalThis.fetch = (async () => new Response(searchCard("123456", "Engineer"))) as typeof fetch;
let stdout = "";
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout += chunk.toString();
return true;
}) as typeof process.stdout.write;
const code = await runSearch({
location: "Copenhagen, Denmark",
jobage: 9999,
page: 1,
limit: 0,
format: "json",
});
expect(code).toBe(0);
expect(JSON.parse(stdout).results).toHaveLength(0);
});
test("--jobage-minutes 30 constructs f_TPR=r1800 in the request URL", async () => {
let capturedUrl = "";
globalThis.fetch = (async (input: RequestInfo | URL) => {
capturedUrl = typeof input === "string" ? input : input.toString();
return new Response("");
}) as typeof fetch;
const code = await runSearch({
location: "Remote",
jobage: 9999,
jobageMinutes: 30,
page: 1,
format: "json",
});
expect(code).toBe(0);
expect(capturedUrl).toContain("f_TPR=r1800");
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}