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