From d8f38fe7660d69219312d2b752a117a029060ae8 Mon Sep 17 00:00:00 2001 From: AKHIL TRIPATHI Date: Mon, 29 Jun 2026 23:56:19 +0530 Subject: [PATCH] Add country-agnostic linkedin-search skill (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Opus 4.8 --- .agents/skills/linkedin-search/SKILL.md | 98 ++++++++ .agents/skills/linkedin-search/cli/README.md | 61 +++++ .../skills/linkedin-search/cli/package.json | 19 ++ .agents/skills/linkedin-search/cli/src/cli.ts | 116 +++++++++ .../cli/src/commands/detail.ts | 57 +++++ .../cli/src/commands/search.ts | 85 +++++++ .../skills/linkedin-search/cli/src/helpers.ts | 229 ++++++++++++++++++ .../skills/linkedin-search/cli/tsconfig.json | 14 ++ .../skills/linkedin-search/url-reference.md | 42 ++++ README.md | 4 +- 10 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/linkedin-search/SKILL.md create mode 100644 .agents/skills/linkedin-search/cli/README.md create mode 100644 .agents/skills/linkedin-search/cli/package.json create mode 100644 .agents/skills/linkedin-search/cli/src/cli.ts create mode 100644 .agents/skills/linkedin-search/cli/src/commands/detail.ts create mode 100644 .agents/skills/linkedin-search/cli/src/commands/search.ts create mode 100644 .agents/skills/linkedin-search/cli/src/helpers.ts create mode 100644 .agents/skills/linkedin-search/cli/tsconfig.json create mode 100644 .agents/skills/linkedin-search/url-reference.md diff --git a/.agents/skills/linkedin-search/SKILL.md b/.agents/skills/linkedin-search/SKILL.md new file mode 100644 index 0000000..31e45ab --- /dev/null +++ b/.agents/skills/linkedin-search/SKILL.md @@ -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 ", 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 "" [flags] +``` + +Key flags: +- `--location ` / `-l ` — **required.** A LinkedIn place string, e.g. `"Mumbai, Maharashtra, India"`, `"Berlin, Germany"`, `"London, United Kingdom"`, or `"Remote"`. +- `--query ` / `-q ` — keyword search (title, skill, role). Recommended. +- `--jobage ` — posted within N days: `1`, `7`, `14`, `30`. Omit for all postings. +- `--remote ` — `remote`, `hybrid`, or `onsite` (workplace-type filter). +- `--page ` — page number (1-indexed, 10 results per page). +- `--limit ` / `-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 [--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`. diff --git a/.agents/skills/linkedin-search/cli/README.md b/.agents/skills/linkedin-search/cli/README.md new file mode 100644 index 0000000..319eb33 --- /dev/null +++ b/.agents/skills/linkedin-search/cli/README.md @@ -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/`). +**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`. | diff --git a/.agents/skills/linkedin-search/cli/package.json b/.agents/skills/linkedin-search/cli/package.json new file mode 100644 index 0000000..bf548f4 --- /dev/null +++ b/.agents/skills/linkedin-search/cli/package.json @@ -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" + } +} diff --git a/.agents/skills/linkedin-search/cli/src/cli.ts b/.agents/skills/linkedin-search/cli/src/cli.ts new file mode 100644 index 0000000..15e0156 --- /dev/null +++ b/.agents/skills/linkedin-search/cli/src/cli.ts @@ -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 = { 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 "" [flags] + bun run src/cli.ts detail [--format json|plain] + +SEARCH FLAGS + --location, -l Location to search. REQUIRED. e.g. "Mumbai, Maharashtra, India", + "Berlin, Germany", "London, United Kingdom", or "Remote". + --query, -q Keywords (job title, skill, or role). Recommended. + --jobage Posted within N days: 1, 7, 14, 30. Default: all. + --remote remote | hybrid | onsite. Filter by workplace type. + --page 1-indexed page (10 results/page). Default 1. + --limit, -n Cap results emitted (client-side). + --format 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 { + 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 ", 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)) diff --git a/.agents/skills/linkedin-search/cli/src/commands/detail.ts b/.agents/skills/linkedin-search/cli/src/commands/detail.ts new file mode 100644 index 0000000..c07a8ed --- /dev/null +++ b/.agents/skills/linkedin-search/cli/src/commands/detail.ts @@ -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 { + 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 + } +} diff --git a/.agents/skills/linkedin-search/cli/src/commands/search.ts b/.agents/skills/linkedin-search/cli/src/commands/search.ts new file mode 100644 index 0000000..228f955 --- /dev/null +++ b/.agents/skills/linkedin-search/cli/src/commands/search.ts @@ -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 { + 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 + } +} diff --git a/.agents/skills/linkedin-search/cli/src/helpers.ts b/.agents/skills/linkedin-search/cli/src/helpers.ts new file mode 100644 index 0000000..56308b3 --- /dev/null +++ b/.agents/skills/linkedin-search/cli/src/helpers.ts @@ -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 { + 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
  • 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

    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

    with optional inner ). + 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 = {} + 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 + } +} diff --git a/.agents/skills/linkedin-search/cli/tsconfig.json b/.agents/skills/linkedin-search/cli/tsconfig.json new file mode 100644 index 0000000..9e46917 --- /dev/null +++ b/.agents/skills/linkedin-search/cli/tsconfig.json @@ -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"] +} diff --git a/.agents/skills/linkedin-search/url-reference.md b/.agents/skills/linkedin-search/url-reference.md new file mode 100644 index 0000000..bf368de --- /dev/null +++ b/.agents/skills/linkedin-search/url-reference.md @@ -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 `
  • ` per posting). The CLI parses each card by +its `data-entity-urn="urn:li:jobPosting:"` and extracts title, company, location, date, URL. + +## Detail + +``` +GET https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/ +``` + +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"). diff --git a/README.md b/README.md index 4cdf2ba..dfd0d10 100644 --- a/README.md +++ b/README.md @@ -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