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
+98
View File
@@ -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(/&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"]
}
@@ -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").