mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-18 09:06:24 +00:00
Add country-agnostic linkedin-search skill (#20)
A general-purpose, field-agnostic job-search skill built on LinkedIn's public jobs-guest endpoints. Works for any market out of the box — location is an explicit required flag (no country default). Zero runtime dependencies (bun only); search + detail commands. Includes a personal-use / Terms-of-Service note (automated access is against LinkedIn's ToS — keep volume low, non-commercial). (Pairs with the .gitignore fix in #21, which lets skills under .agents/ be tracked.) Co-authored-by: Akhil Tripathi <kodabear@Akhils-MacBook-Pro.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Akhil Tripathi
Claude Opus 4.8
parent
46a9fdc66c
commit
d8f38fe766
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user