mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
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:
co-authored by
Akhil Tripathi
Claude Opus 4.8
parent
46a9fdc66c
commit
d8f38fe766
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: linkedin-search
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Use this skill whenever the user wants to search for jobs in any location or
|
||||
market, find job listings, or look up a specific job posting — in any country,
|
||||
city, or remotely. Invoke for open positions, vacancies, and hiring across any
|
||||
sector or role (software, data, design, marketing, finance, legal, operations,
|
||||
etc.). The location is always supplied explicitly by the user. Trigger phrases:
|
||||
find a job, job search, search for jobs, job openings, vacancies, hiring,
|
||||
positions open, remote jobs, "are there any X jobs in <place>", look up this
|
||||
job posting.
|
||||
context: fork
|
||||
allowed-tools: Bash(bun run skills/linkedin-search/cli/src/cli.ts *)
|
||||
---
|
||||
|
||||
# LinkedIn Search Skill
|
||||
|
||||
Search live job listings from LinkedIn's public job board for **any country/region**
|
||||
(and remote). No authentication, no API key, and **zero runtime dependencies** — it runs
|
||||
with just `bun`. The location is always passed explicitly, so the same skill works for a
|
||||
forker in any market out of the box.
|
||||
|
||||
> This is a country-agnostic worked example of the repo's job-portal-skill pattern.
|
||||
> LinkedIn's `jobs-guest` endpoints are global and the HTML parsing is country-independent;
|
||||
> only the `--location` you pass changes per market.
|
||||
|
||||
## ⚠️ Personal use only
|
||||
|
||||
This uses LinkedIn's public job pages; automated access is against LinkedIn's Terms of
|
||||
Service, so **keep volume low and don't use it commercially or for bulk data collection.**
|
||||
Run it on your own responsibility.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- Search for job openings in a given location (any country/city) or remotely
|
||||
- Filter by recency (posted today / last 7 / 14 / 30 days) or workplace type (remote/hybrid/onsite)
|
||||
- Get the full description of a specific job listing
|
||||
|
||||
## Commands
|
||||
|
||||
### Search job listings
|
||||
|
||||
```bash
|
||||
bun run skills/linkedin-search/cli/src/cli.ts search --location "<place>" [flags]
|
||||
```
|
||||
|
||||
Key flags:
|
||||
- `--location <text>` / `-l <text>` — **required.** A LinkedIn place string, e.g. `"Mumbai, Maharashtra, India"`, `"Berlin, Germany"`, `"London, United Kingdom"`, or `"Remote"`.
|
||||
- `--query <text>` / `-q <text>` — keyword search (title, skill, role). Recommended.
|
||||
- `--jobage <days>` — posted within N days: `1`, `7`, `14`, `30`. Omit for all postings.
|
||||
- `--remote <mode>` — `remote`, `hybrid`, or `onsite` (workplace-type filter).
|
||||
- `--page <n>` — page number (1-indexed, 10 results per page).
|
||||
- `--limit <n>` / `-n <n>` — cap total results emitted (client-side).
|
||||
- `--format json|table|plain` — default `json`.
|
||||
|
||||
### Fetch full job detail
|
||||
|
||||
```bash
|
||||
bun run skills/linkedin-search/cli/src/cli.ts detail <id|url> [--format json|plain]
|
||||
```
|
||||
|
||||
`id` is the job ID from `search` results (e.g. `4426311357`). You may also pass a full
|
||||
LinkedIn `jobs/view/...` URL or a `urn:li:jobPosting:...` URN. Returns the full description,
|
||||
seniority, employment type, job function, industries, and apply link.
|
||||
|
||||
## Usage examples
|
||||
|
||||
```bash
|
||||
# Data engineer roles in Bengaluru, last 30 days
|
||||
bun run skills/linkedin-search/cli/src/cli.ts search -q "data engineer" -l "Bengaluru, Karnataka, India" --jobage 30 --format table
|
||||
|
||||
# Product manager roles in Berlin, remote
|
||||
bun run skills/linkedin-search/cli/src/cli.ts search -q "product manager" -l "Berlin, Germany" --remote remote --format table
|
||||
|
||||
# Any role, fully remote
|
||||
bun run skills/linkedin-search/cli/src/cli.ts search -q "paralegal" -l "Remote" --format table
|
||||
|
||||
# Full details for a specific job
|
||||
bun run skills/linkedin-search/cli/src/cli.ts detail 4426311357 --format plain
|
||||
```
|
||||
|
||||
## Output formats
|
||||
|
||||
| Format | Best for |
|
||||
|--------|----------|
|
||||
| `json` | Default — programmatic use, passing IDs to `detail` |
|
||||
| `table` | Quick human-readable scanning |
|
||||
| `plain` | Reading a single job's full detail (`detail` command) |
|
||||
|
||||
All errors are written to **stderr** as `{ "error": "...", "code": "..." }` and the process exits with code `1`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Data is from LinkedIn's public `jobs-guest` endpoints — no credentials required.
|
||||
- Page size is fixed at 10 results per page.
|
||||
- LinkedIn may rate-limit; the CLI retries 429/5xx with exponential backoff. Keep volume low (see ToS note above).
|
||||
- Job IDs are numeric (e.g. `4426311357`) — pass them as-is to `detail`.
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
|
||||
.replace(/ /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"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# LinkedIn Jobs URL Reference
|
||||
|
||||
Public, unauthenticated `jobs-guest` endpoints used by this skill. Global — the same
|
||||
endpoints serve every market; only the `location` value changes.
|
||||
|
||||
> Personal use only — automated access is against LinkedIn's Terms of Service; keep volume low.
|
||||
|
||||
## Search
|
||||
|
||||
```
|
||||
GET https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search
|
||||
```
|
||||
|
||||
Query params:
|
||||
|
||||
| Param | Meaning | Example |
|
||||
|-------|---------|---------|
|
||||
| `keywords` | Free-text query | `data engineer` |
|
||||
| `location` | Place string | `Mumbai, Maharashtra, India` · `Berlin, Germany` · `Remote` |
|
||||
| `f_TPR` | Posted-within window (seconds) | `r604800` (7d), `r2592000` (30d) |
|
||||
| `f_WT` | Workplace type | `1` on-site · `2` remote · `3` hybrid |
|
||||
| `start` | Pagination offset (10/page) | `0`, `10`, `20`, … |
|
||||
|
||||
Returns an HTML list of job cards (one `<li>` per posting). The CLI parses each card by
|
||||
its `data-entity-urn="urn:li:jobPosting:<id>"` and extracts title, company, location, date, URL.
|
||||
|
||||
## Detail
|
||||
|
||||
```
|
||||
GET https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/<jobId>
|
||||
```
|
||||
|
||||
Returns a single job's HTML: title (`top-card-layout__title`), company
|
||||
(`topcard__org-name-link`), location (`topcard__flavor--bullet`), the rich description
|
||||
(`show-more-less-html__markup` / `description__text`), and job-criteria items
|
||||
(seniority, employment type, job function, industries).
|
||||
|
||||
## Notes
|
||||
|
||||
- No authentication required.
|
||||
- Respect rate limits — the CLI backs off on 429/5xx.
|
||||
- Country-agnostic: pass any `--location` (city, region, country, or "Remote").
|
||||
@@ -198,7 +198,9 @@ The CV uses [moderncv](https://ctan.org/pkg/moderncv) (banking style). The cover
|
||||
|
||||
### Job search tools
|
||||
|
||||
The four CLI tools in `.agents/skills/` are specific to the **Danish job market** (Jobbank, Jobdanmark, Jobindex, Jobnet). They demonstrate the pattern for building job portal integrations. If you're in a different country, you can build equivalent tools for your local job portals using the same structure.
|
||||
The four Danish CLI tools in `.agents/skills/` (Jobbank, Jobdanmark, Jobindex, Jobnet) demonstrate the pattern for building a job-portal integration for a specific market. If you're in a different country, you can build equivalent tools for your local job portals using the same structure.
|
||||
|
||||
For a **country-agnostic** starting point, the repo also includes **`linkedin-search`** — a job-search skill built on LinkedIn's public, unauthenticated `jobs-guest` endpoints. It is field-agnostic, has **zero runtime dependencies** (runs with just `bun`), and takes the search location as an explicit flag, so it works for any market out of the box (`-l "Berlin, Germany"`, `-l "Mumbai, Maharashtra, India"`, `-l "Remote"`, …). It is intended for **personal use only** — automated access is against LinkedIn's Terms of Service, so keep volume low. See `.agents/skills/linkedin-search/SKILL.md`.
|
||||
|
||||
### Salary benchmarking
|
||||
|
||||
|
||||
Reference in New Issue
Block a user