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
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "jobsdb-cli",
|
||||
"version": "1.0.0",
|
||||
"description": "CLI for searching a local SQLite snapshot of job postings (documents/postings/jobs.db). Zero runtime dependencies: bun:sqlite only.",
|
||||
"type": "module",
|
||||
"main": "src/cli.ts",
|
||||
"bin": { "jobsdb-search": "src/cli.ts" },
|
||||
"scripts": { "start": "bun run src/cli.ts", "typecheck": "tsc --noEmit" },
|
||||
"dependencies": {},
|
||||
"devDependencies": { "typescript": "^5.4.0", "@types/bun": "1.3.14" }
|
||||
}
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bun
|
||||
// jobsdb-cli: search a local SQLite snapshot of job postings. Zero dependencies (bun:sqlite).
|
||||
import { runSearch, type SearchOpts } from "./commands/search.js"
|
||||
import { runDetail } from "./commands/detail.js"
|
||||
import { writeError } from "./helpers.js"
|
||||
|
||||
const HELP = `jobsdb-cli - search the local postings snapshot (documents/postings/jobs.db)
|
||||
|
||||
Usage:
|
||||
cli.ts search [flags]
|
||||
cli.ts detail <posting_id|url> [--format json|plain]
|
||||
|
||||
Search flags:
|
||||
-q, --query <text> Full-text match on title, company, location (FTS5)
|
||||
--company <text> Company name contains <text>
|
||||
--location <text> Location text contains <text> (e.g. "New York", "San Francisco")
|
||||
--country <code> location_country equals <code> (e.g. US)
|
||||
--remote <list> Comma list of: remote, remote-regional, hybrid, onsite
|
||||
--seniority <list> Comma list of: senior, staff_principal, manager, director, head_of, vp, c_level_founder, mid_unclassified, junior_associate, intern, unclassified
|
||||
--sector <list> Comma list of company target_sector values (crypto, fintech-nc, ai-ml, devtools, enterprise-saas, hardware, other)
|
||||
--crypto-native Only companies flagged crypto-native
|
||||
--tier <n> Company priority_tier <= n (1 is best)
|
||||
--jobage <days> Posted within N days of the row's scrape snapshot (not today)
|
||||
--page <n> 1-indexed page. Default 1
|
||||
-n, --limit <n> Results per page. Default 20
|
||||
--format json|table Default json
|
||||
|
||||
Env:
|
||||
JOBSDB_PATH Override the database path
|
||||
`
|
||||
|
||||
type Flags = Record<string, string | boolean>
|
||||
|
||||
function parse(argv: string[]): { cmd: string; pos: string[]; flags: Flags } {
|
||||
const [cmd = "", ...rest] = argv
|
||||
const pos: string[] = []
|
||||
const flags: Flags = {}
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const a = rest[i]
|
||||
if (a === "-q") { flags.query = rest[++i] ?? ""; continue }
|
||||
if (a === "-n") { flags.limit = rest[++i] ?? ""; continue }
|
||||
if (a.startsWith("--")) {
|
||||
const [k, inline] = a.slice(2).split("=", 2)
|
||||
if (inline !== undefined) { flags[k] = inline; continue }
|
||||
const next = rest[i + 1]
|
||||
if (next === undefined || next.startsWith("-")) flags[k] = true
|
||||
else flags[k] = rest[++i]
|
||||
continue
|
||||
}
|
||||
pos.push(a)
|
||||
}
|
||||
return { cmd, pos, flags }
|
||||
}
|
||||
|
||||
function num(flags: Flags, name: string, dflt?: number): number | undefined {
|
||||
const raw = flags[name]
|
||||
if (raw === undefined) return dflt
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || n < 0) { writeError(`--${name} must be a non-negative number, got "${raw}"`, "BAD_ARG"); process.exit(1) }
|
||||
return n
|
||||
}
|
||||
const list = (v: string | boolean | undefined) => (typeof v === "string" && v ? v.split(",").map((s) => s.trim()).filter(Boolean) : undefined)
|
||||
const str = (v: string | boolean | undefined) => (typeof v === "string" && v ? v : undefined)
|
||||
|
||||
const { cmd, pos, flags } = parse(process.argv.slice(2))
|
||||
const KNOWN = new Set(["query", "company", "location", "country", "remote", "seniority", "sector", "crypto-native", "tier", "jobage", "page", "limit", "format"])
|
||||
for (const k of Object.keys(flags)) if (!KNOWN.has(k)) { writeError(`Unknown flag --${k}`, "BAD_ARG"); process.exit(1) }
|
||||
|
||||
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { process.stdout.write(HELP); process.exit(0) }
|
||||
|
||||
if (cmd === "search") {
|
||||
const format = (str(flags.format) ?? "json") as SearchOpts["format"]
|
||||
if (format !== "json" && format !== "table") { writeError(`--format must be json or table`, "BAD_ARG"); process.exit(1) }
|
||||
runSearch({
|
||||
query: str(flags.query), company: str(flags.company), location: str(flags.location), country: str(flags.country),
|
||||
remote: list(flags.remote), seniority: list(flags.seniority), sector: list(flags.sector),
|
||||
cryptoNative: flags["crypto-native"] === true, tier: num(flags, "tier"), jobage: num(flags, "jobage"),
|
||||
page: Math.max(1, num(flags, "page", 1)!), limit: Math.max(1, num(flags, "limit", 20)!), format,
|
||||
})
|
||||
} else if (cmd === "detail") {
|
||||
if (!pos[0]) { writeError("detail requires a <posting_id|url>", "NO_ID"); process.exit(1) }
|
||||
const format = (str(flags.format) ?? "json") as "json" | "plain"
|
||||
runDetail(pos[0], format)
|
||||
} else {
|
||||
writeError(`Unknown command "${cmd}"`, "BAD_CMD"); process.exit(1)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { openDb, toJob, writeError, type Row } from "../helpers.js"
|
||||
|
||||
export function runDetail(idOrUrl: string, format: "json" | "plain"): void {
|
||||
const db = openDb()
|
||||
const row = db
|
||||
.query("SELECT p.*, c.target_sector, c.crypto_native, c.priority_tier FROM postings p LEFT JOIN companies c ON c.company_id = p.company_id WHERE p.posting_id = ? OR p.url = ? LIMIT 1")
|
||||
.get(idOrUrl, idOrUrl) as Row | null
|
||||
if (!row) { writeError(`no posting with id or url ${idOrUrl}`, "NOT_FOUND"); process.exit(1) }
|
||||
const job = toJob(row)
|
||||
if (format === "plain") {
|
||||
process.stdout.write(
|
||||
[`${job.title} - ${job.company}`, `${job.location} | ${job.remote ?? "?"} | ${job.seniority ?? "?"}`, `Posted ${job.date ?? "?"}${job.date_approximate ? " (approx.)" : ""}, snapshot ${job.snapshot}`, job.salary ? `Salary ${job.salary}` : "", `URL: ${job.url}`, "", "The snapshot stores no description. Fetch the URL above for the full posting text."].filter(Boolean).join("\n") + "\n",
|
||||
)
|
||||
return
|
||||
}
|
||||
process.stdout.write(JSON.stringify(job, null, 2) + "\n")
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { openDb, toJob, renderTable, writeError, REMOTE_CLASS, type Row } from "../helpers.js"
|
||||
|
||||
export interface SearchOpts {
|
||||
query?: string
|
||||
company?: string
|
||||
location?: string
|
||||
country?: string
|
||||
remote?: string[]
|
||||
seniority?: string[]
|
||||
sector?: string[]
|
||||
cryptoNative?: boolean
|
||||
tier?: number
|
||||
jobage?: number
|
||||
page: number
|
||||
limit: number
|
||||
format: "json" | "table"
|
||||
}
|
||||
|
||||
function ftsMatch(q: string): string {
|
||||
// Quote every token so user text can never be parsed as FTS5 syntax.
|
||||
return q.split(/\s+/).filter(Boolean).map((t) => `"${t.replace(/"/g, '""')}"`).join(" ")
|
||||
}
|
||||
|
||||
export function runSearch(opts: SearchOpts): void {
|
||||
const db = openDb()
|
||||
const where: string[] = []
|
||||
const params: (string | number)[] = []
|
||||
let from = "postings p LEFT JOIN companies c ON c.company_id = p.company_id"
|
||||
if (opts.query) {
|
||||
from = "postings_fts f JOIN postings p ON p.rowid = f.rowid LEFT JOIN companies c ON c.company_id = p.company_id"
|
||||
where.push("postings_fts MATCH ?")
|
||||
params.push(ftsMatch(opts.query))
|
||||
}
|
||||
if (opts.company) { where.push("p.company_name LIKE ?"); params.push(`%${opts.company}%`) }
|
||||
if (opts.location) { where.push("p.location_text LIKE ?"); params.push(`%${opts.location}%`) }
|
||||
if (opts.country) { where.push("p.location_country = ?"); params.push(opts.country) }
|
||||
if (opts.remote?.length) {
|
||||
const classes = opts.remote.map((r) => REMOTE_CLASS[r.toLowerCase()]).filter(Boolean)
|
||||
if (classes.length !== opts.remote.length) {
|
||||
writeError(`--remote accepts ${Object.keys(REMOTE_CLASS).join(", ")}`, "BAD_ARG"); process.exit(1)
|
||||
}
|
||||
where.push(`p.location_class IN (${classes.map(() => "?").join(",")})`); params.push(...classes)
|
||||
}
|
||||
if (opts.seniority?.length) { where.push(`p.seniority IN (${opts.seniority.map(() => "?").join(",")})`); params.push(...opts.seniority) }
|
||||
if (opts.sector?.length) { where.push(`c.target_sector IN (${opts.sector.map(() => "?").join(",")})`); params.push(...opts.sector) }
|
||||
if (opts.cryptoNative) where.push("c.crypto_native = 'True'")
|
||||
if (opts.tier !== undefined) { where.push("c.priority_tier <= ?"); params.push(opts.tier) }
|
||||
|
||||
const sql = `SELECT p.*, c.target_sector, c.crypto_native, c.priority_tier FROM ${from}
|
||||
${where.length ? "WHERE " + where.join(" AND ") : ""}
|
||||
ORDER BY COALESCE(c.priority_tier, 9), p.scrape_timestamp DESC`
|
||||
let rows: Row[]
|
||||
try { rows = db.query(sql).all(...params) as Row[] } catch (e) {
|
||||
writeError(`query failed: ${(e as Error).message}`, "QUERY_FAILED"); process.exit(1)
|
||||
}
|
||||
let jobs = rows.map(toJob)
|
||||
if (opts.jobage !== undefined) {
|
||||
// Recency relative to each row's own scrape snapshot, not today's calendar: the DB is a static export.
|
||||
jobs = jobs.filter((j) => {
|
||||
if (!j.date || !j.snapshot) return false
|
||||
const age = (Date.parse(j.snapshot) - Date.parse(j.date)) / 86_400_000
|
||||
return age <= opts.jobage!
|
||||
})
|
||||
}
|
||||
const total = jobs.length
|
||||
const start = (opts.page - 1) * opts.limit
|
||||
const pageJobs = jobs.slice(start, start + opts.limit)
|
||||
const snapshot = pageJobs.reduce<string | null>((m, j) => (j.snapshot && (!m || j.snapshot > m) ? j.snapshot : m), null)
|
||||
|
||||
if (opts.format === "table") { process.stdout.write(renderTable(pageJobs) + "\n"); return }
|
||||
process.stdout.write(JSON.stringify({ meta: { count: pageJobs.length, page: opts.page, total, snapshot, db: process.env.JOBSDB_PATH ?? "documents/postings/jobs.db" }, results: pageJobs }, null, 2) + "\n")
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { existsSync } from "node:fs"
|
||||
|
||||
export const DB_PATH = process.env.JOBSDB_PATH ?? "documents/postings/jobs.db"
|
||||
|
||||
export const REMOTE_CLASS: Record<string, string> = { remote: "R1", "remote-global": "R1", "remote-regional": "R2", hybrid: "H", onsite: "O" }
|
||||
export const CLASS_LABEL: Record<string, string> = { R1: "Remote (global)", R2: "Remote (regional)", H: "Hybrid", O: "Onsite" }
|
||||
|
||||
export interface Row {
|
||||
posting_id: string
|
||||
company_id: string | null
|
||||
company_name: string | null
|
||||
title: string | null
|
||||
seniority: string | null
|
||||
posted_date: string | null
|
||||
location_text: string | null
|
||||
location_class: string | null
|
||||
location_country: string | null
|
||||
salary_min: string | null
|
||||
salary_max: string | null
|
||||
salary_currency: string | null
|
||||
url: string | null
|
||||
source_ats: string | null
|
||||
scrape_timestamp: string | null
|
||||
target_sector: string | null
|
||||
crypto_native: string | null
|
||||
priority_tier: number | null
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: string
|
||||
title: string | null
|
||||
company: string | null
|
||||
location: string | null
|
||||
date: string | null
|
||||
date_approximate: boolean
|
||||
url: string | null
|
||||
description: null
|
||||
remote: string | null
|
||||
seniority: string | null
|
||||
salary: string | null
|
||||
sector: string | null
|
||||
crypto_native: boolean
|
||||
priority_tier: number | null
|
||||
ats: string | null
|
||||
snapshot: string | null
|
||||
}
|
||||
|
||||
export function openDb(): Database {
|
||||
if (!existsSync(DB_PATH)) {
|
||||
writeError(`database not found at ${DB_PATH} (set JOBSDB_PATH)`, "NO_DB")
|
||||
process.exit(1)
|
||||
}
|
||||
return new Database(DB_PATH, { readonly: true })
|
||||
}
|
||||
|
||||
/** "Posted 8 Days Ago" + scrape_timestamp -> ISO date. "30+" is a floor, flagged approximate. */
|
||||
export function resolveDate(posted: string | null, scraped: string | null): { date: string | null; approximate: boolean } {
|
||||
if (!posted) return { date: null, approximate: false }
|
||||
// greenhouse/ashby/lever store ISO timestamps; workday stores relative text.
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(posted)) return { date: posted.slice(0, 10), approximate: false }
|
||||
if (!scraped) return { date: null, approximate: false }
|
||||
const base = new Date(scraped)
|
||||
if (isNaN(base.getTime())) return { date: null, approximate: false }
|
||||
const m = /(\d+)(\+?)\s*Days?/i.exec(posted)
|
||||
let days: number
|
||||
let approximate = false
|
||||
if (/Today/i.test(posted)) days = 0
|
||||
else if (/Yesterday/i.test(posted)) days = 1
|
||||
else if (m) { days = Number(m[1]); approximate = m[2] === "+" }
|
||||
else return { date: null, approximate: false }
|
||||
base.setUTCDate(base.getUTCDate() - days)
|
||||
return { date: base.toISOString().slice(0, 10), approximate }
|
||||
}
|
||||
|
||||
export function toJob(r: Row): Job {
|
||||
const { date, approximate } = resolveDate(r.posted_date, r.scrape_timestamp)
|
||||
const salary = r.salary_min && r.salary_max ? `${r.salary_min}-${r.salary_max} ${r.salary_currency ?? ""}`.trim() : null
|
||||
return {
|
||||
id: r.posting_id,
|
||||
title: r.title || null,
|
||||
company: r.company_name || null,
|
||||
location: r.location_text || null,
|
||||
date,
|
||||
date_approximate: approximate,
|
||||
url: r.url || null,
|
||||
description: null,
|
||||
remote: r.location_class ? CLASS_LABEL[r.location_class] ?? null : null,
|
||||
seniority: r.seniority || null,
|
||||
salary,
|
||||
sector: r.target_sector || null,
|
||||
crypto_native: r.crypto_native === "True",
|
||||
priority_tier: r.priority_tier ?? null,
|
||||
ats: r.source_ats || null,
|
||||
snapshot: r.scrape_timestamp ? r.scrape_timestamp.slice(0, 10) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function writeError(error: string, code: string): void {
|
||||
process.stderr.write(JSON.stringify({ error, code }) + "\n")
|
||||
}
|
||||
|
||||
export function renderTable(jobs: Job[]): string {
|
||||
if (jobs.length === 0) return "No results."
|
||||
const rows = jobs.map((j) =>
|
||||
[
|
||||
(j.title ?? "").slice(0, 44).padEnd(44),
|
||||
(j.company ?? "-").slice(0, 22).padEnd(22),
|
||||
(j.location ?? "-").slice(0, 22).padEnd(22),
|
||||
(j.remote ?? "-").slice(0, 16).padEnd(16),
|
||||
j.date ? j.date + (j.date_approximate ? "~" : " ") : "-".padEnd(11),
|
||||
j.url ?? "",
|
||||
].join(" "),
|
||||
)
|
||||
return rows.join("\n")
|
||||
}
|
||||
+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