Add country-agnostic linkedin-search skill (#20)

A general-purpose, field-agnostic job-search skill built on LinkedIn's public
jobs-guest endpoints. Works for any market out of the box — location is an
explicit required flag (no country default). Zero runtime dependencies (bun only);
search + detail commands.

Includes a personal-use / Terms-of-Service note (automated access is against
LinkedIn's ToS — keep volume low, non-commercial).

(Pairs with the .gitignore fix in #21, which lets skills under .agents/ be tracked.)

Co-authored-by: Akhil Tripathi <kodabear@Akhils-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AKHIL TRIPATHI
2026-06-29 20:26:19 +02:00
committed by GitHub
co-authored by Akhil Tripathi Claude Opus 4.8
parent 46a9fdc66c
commit d8f38fe766
10 changed files with 724 additions and 1 deletions
@@ -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`. |
@@ -0,0 +1,19 @@
{
"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",
"typecheck": "tsc --noEmit"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.4.0",
"@types/bun": "latest"
}
}
@@ -0,0 +1,116 @@
#!/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))
@@ -0,0 +1,57 @@
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}`,
job.applyUrl ? `Apply: ${job.applyUrl}` : "",
].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
}
}
@@ -0,0 +1,85 @@
import {
SEARCH_URL,
htmlFetch,
parseJobCards,
jobageToTPR,
workTypeFlag,
writeError,
type JobCard,
} from "../helpers.js"
export interface SearchOpts {
query?: string
location: string
jobage: 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 = 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 && 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
}
}
@@ -0,0 +1,229 @@
// 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 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
/** 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",
})
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
applyUrl: string | null
}
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, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
.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 desc = html.match(
/class="(?:show-more-less-html__markup|description__text[^"]*)"[^>]*>([\s\S]*?)<\/div>/i,
)
if (desc) {
const withBreaks = desc[1]
.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])
}
const applyMatch = html.match(/class="topcard__link[^"]*"[^>]*href="([^"]+)"/i)
const applyUrl = applyMatch ? decodeHtmlEntities(applyMatch[1]).split("?")[0] : null
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,
applyUrl,
}
}
/** 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}`
}
/** 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,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}