mirror of
https://github.com/prdlk/cv.git
synced 2026-08-02 17:31:41 +00:00
105 lines
4.0 KiB
TypeScript
105 lines
4.0 KiB
TypeScript
import type { MatchResult, MatchedSkill, RankedItem } from "../shared/types";
|
|
import { formatRange, type Corpus } from "./db";
|
|
|
|
/** Skill names that collide with common English words — match these case-sensitively. */
|
|
const CASE_SENSITIVE = new Set(["Go", "R", "C", "Swift", "Dart", "Unity", "Agile", "Matrix"]);
|
|
|
|
/** Hand-curated aliases beyond the automatic parenthetical/slash splits. */
|
|
const EXTRA_ALIASES: Record<string, string[]> = {
|
|
Go: ["Golang"],
|
|
Kubernetes: ["K8s"],
|
|
"Google Cloud": ["GCP"],
|
|
"Test-Driven Development": ["TDD"],
|
|
"Continuous Integration": ["CI/CD", "CI"],
|
|
"React.js": ["ReactJS"],
|
|
"Protocol Buffers": ["Protobufs", "Protobuf"],
|
|
Protobufs: ["Protocol Buffers", "Protobuf"],
|
|
"Matrix Protocol": ["Matrix"],
|
|
"Decentralized Identifiers (DIDs)": ["DID"],
|
|
"Cloudflare Workers": ["Cloudflare"],
|
|
PostgreSQL: ["Postgres"],
|
|
};
|
|
|
|
/** "WebAssembly (WASM)" -> ["WebAssembly (WASM)", "WebAssembly", "WASM"]; "HTML/CSS" -> [..., "HTML", "CSS"]. */
|
|
function aliasesFor(name: string): string[] {
|
|
const out = [name];
|
|
const paren = /^(.*?)\s*\((.*?)\)$/.exec(name);
|
|
if (paren) {
|
|
out.push(paren[1].trim());
|
|
for (const part of paren[2].split(/\s*(?:[&,/·]|and)\s*/)) if (part.trim()) out.push(part.trim());
|
|
} else if (name.includes("/") && name !== "CI/CD") {
|
|
for (const part of name.split("/")) if (part.trim()) out.push(part.trim());
|
|
}
|
|
for (const extra of EXTRA_ALIASES[name] ?? []) out.push(extra);
|
|
return [...new Set(out)];
|
|
}
|
|
|
|
function countHits(text: string, name: string): number {
|
|
let hits = 0;
|
|
for (const alias of aliasesFor(name)) {
|
|
if (alias.length < 2 && !CASE_SENSITIVE.has(alias)) continue;
|
|
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const flags = CASE_SENSITIVE.has(alias) ? "g" : "gi";
|
|
const re = new RegExp(`(?<![A-Za-z0-9#+])${escaped}(?![A-Za-z0-9#+])`, flags);
|
|
hits += text.match(re)?.length ?? 0;
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
function rankItem(
|
|
type: RankedItem["type"],
|
|
slug: string,
|
|
title: string,
|
|
company: string | undefined,
|
|
dates: string,
|
|
bullets: string[],
|
|
itemSkills: string[],
|
|
hitsByName: Map<string, number>,
|
|
): RankedItem {
|
|
const matchedSkills = itemSkills.filter((s) => hitsByName.has(s));
|
|
let bulletHits = 0;
|
|
for (const bullet of bullets) {
|
|
const lower = bullet.toLowerCase();
|
|
for (const s of matchedSkills) {
|
|
const stem = s.replace(/\s*\(.*\)$/, "").toLowerCase();
|
|
if (lower.includes(stem)) bulletHits++;
|
|
}
|
|
}
|
|
const score = matchedSkills.length * 3 + Math.min(bulletHits, 6);
|
|
return { type, slug, title, company, dates, score, matchedSkills, bullets };
|
|
}
|
|
|
|
/** Deterministic skill-keyword match of the corpus against extracted job text. */
|
|
export function matchCorpus(corpus: Corpus, jdText: string): MatchResult {
|
|
const categories = new Map<string, MatchedSkill["category"]>();
|
|
for (const s of corpus.skills) categories.set(s.name, s.category);
|
|
|
|
const universe = new Set<string>(corpus.skills.map((s) => s.name));
|
|
for (const item of corpus.experience) for (const s of item.skills) universe.add(s);
|
|
for (const item of corpus.projects) for (const s of item.skills) universe.add(s);
|
|
|
|
const hitsByName = new Map<string, number>();
|
|
for (const name of universe) {
|
|
const hits = countHits(jdText, name);
|
|
if (hits > 0) hitsByName.set(name, hits);
|
|
}
|
|
|
|
const skills: MatchedSkill[] = [...hitsByName.entries()]
|
|
.map(([name, hits]) => ({ name, hits, category: categories.get(name) ?? ("domain" as const) }))
|
|
.sort((a, b) => b.hits - a.hits);
|
|
|
|
const items: RankedItem[] = [];
|
|
for (const e of corpus.experience) {
|
|
items.push(
|
|
rankItem("experience", e.slug, e.title, e.company, formatRange(e.start_date, e.end_date, "month"), e.bullets, e.skills, hitsByName),
|
|
);
|
|
}
|
|
for (const p of corpus.projects) {
|
|
items.push(
|
|
rankItem("project", p.slug, p.title, undefined, formatRange(p.start_date, p.end_date, "year"), p.bullets, p.skills, hitsByName),
|
|
);
|
|
}
|
|
items.sort((a, b) => b.score - a.score);
|
|
return { skills, items };
|
|
}
|