
I'm a backend engineer, and a while ago I was job-hunting while working full time. The painful part wasn't emotional — it was engineering-inefficient. hh.ru mixes senior roles with junior noise, the same job gets reposted under new URLs, salaries are hidden or in random currencies, and half the interesting roles live in Western ATS (Greenhouse, Ashby, Lever, Workday) that hh.ru never sees. Decomposed honestly, it's a classic ETL problem over messy sources — so I built a tool for it.

career-ops-ui is a free, open-source, local-first job-search command center. It aggregates 78 sources into one de-duplicated feed, AI-scores every posting against your CV on a six-dimension 0 to 5 rubric, tracks each application through a CRM-style funnel, and generates ATS-optimized PDF resumes. It runs entirely on 127.0.0.1 — no cloud, no telemetry, no auto-submit, no account. Your CV and applications never leave your machine.

Under the hood, the frontend is vanilla ES-module JavaScript with no build step and no virtual DOM — chosen for auditability (it handles PII), a tiny supply chain, and a strict script-src 'self' CSP with no unsafe-inline. It's a companion over the terminal engine santifer/career-ops and reads the same flat-file source of truth (cv.md, config, data) read-only. The scanner is a two-phase SSE stream with deterministic termination, and all egress goes through an SSRF envelope with DNS-pinning: resolve once, pin the IP, re-validate on every redirect — so a job URL you paste can't reach your cloud metadata endpoint.
It ships in 17 languages including right-to-left Arabic, is fully keyboard-accessible, and is locked down by around 2,300 tests plus an agentic end-to-end pass that walks every route in every locale. You bring your own LLM key (Anthropic, Gemini, OpenAI, Qwen or OpenRouter) and pay your provider directly. It's MIT-licensed, and every application still stays a manual, deliberate decision — the tool gives you precision, not spam.
Let me go a level deeper on the parts I'm most proud of. This is a companion app, not a fork: it sits on top of the terminal engine santifer/career-ops (around sixty thousand GitHub stars) and treats it as a read-only dependency. Both processes share one flat-file state — your resume is cv.md, your target roles and deal-breakers live in config/profile.yml, and the application tracker is data/applications.md. There is no database as the source of truth, and that is deliberate: the state is git-diffable, portable by a plain copy, and readable by a human without a special client. Any index is just a derived cache you can always rebuild from the files.
The scanner is where most of the messy-source pain gets solved. Every source runs through one pipeline — normalize, filter by your title keywords, de-duplicate against three files at once (scan history, pipeline, and applications), then append. De-duplicating against all three is what kills the reposted-job problem: a role you already applied to a month ago never resurfaces as new, no matter which fresh URL a company re-publishes it under. Sources live in two registries — an auto-discovery one that drives the dropdown, and a hand-maintained ALL_ADAPTERS list the fetcher actually walks — and a test locks the two together, so registering a board in one but forgetting the other turns CI red instead of silently returning zero results.
Scanning streams its progress over Server-Sent Events, in two phases (ATS boards, then regional portals) on a single connection, with a final flag on the terminal event so the browser never closes early and drops the second half. A failed source degrades softly: one board returning HTML or a 429 doesn't abort the sweep over the other seventy-plus. It's the kind of determinism that's boring when it works and infuriating when it doesn't — so it is pinned by tests rather than left to luck.
Security got more attention than a personal tool probably deserves, because the server fetches arbitrary job URLs that you paste — a textbook SSRF surface. Every outbound request goes through one guarded path: a single DNS lookup, reject anything resolving to a private, loopback, or cloud-metadata address, pin the exact IP so there is no rebind between the check and the connect, re-validate on every redirect with a hop cap, and truncate the response body with a streaming byte cap. Without pinning the IP, a domain could resolve to a public address during validation and to 169.254.169.254 during the actual connect — DNS-pinning closes that window.
Quality is enforced by process, not vibes. Under roughly 2,300 node:test cases sits the interesting part: an agentic end-to-end pass, where a spec-regression document is handed to an AI agent that drives a real browser — walking every route across all 17 locales, exercising every filter, and probing the SSRF gate with real requests to loopback and the metadata IP — then writing a GO or NO-GO verdict. It's the second pair of eyes that solo development usually lacks. The method behind it is Spec-Driven Development: the spec, not the code, is the source of truth; each release fixes observable invariants, every invariant gets a test-lock, and one fix ships per release so the diff stays reviewable. Across twenty-plus releases, no regression has reached a user.
A couple of things I'd genuinely like other engineers' opinions on. First: a build-less, virtual-DOM-less frontend at this size — reasonable trade for auditability and a tiny attack surface, or tech debt I'll regret? Second: does Spec-Driven Development plus one-fix-per-release actually scale for a solo project, or is it over-engineering dressed up as discipline? The project started as pure utility — I needed a job and the existing tools solved the problem badly — but it turned into a nice sandbox for local-first architecture, and I'm still not sure which of those two framings will age better.
Repo: https://github.com/Fighter90/career-ops-ui — Site: https://cvstart.org . Feedback and contributions are very welcome. Connect with me on LinkedIn: https://www.linkedin.com/in/sergei-emelianov-in-job/ — thanks for reading.
Comments (0)
Login to post a comment.