← Back to Archive
Browser Extension / Productivity AI Case Study

ZenApply

A browser extension that ingests job listings at scale and auto-fills complex applications across 30+ ATS platforms, writing the free-text answers in the applicant's own voice.

Only engineer on a three-person team. I built and run the whole stack solo: the browser extension, the NestJS backend and ingestion pipeline, the generation layer and the production infrastructure.

NestJS BullMQ React / Zustand PostgreSQL Chrome Extension (Manifest V3) OpenAI GPT-4o Google Gemini Server-Sent Events (SSE)

Architecture Overview

Two halves that meet in one database. A NestJS service runs the ingestion side: BullMQ queues fan out to 40+ external sources, each with its own rate limit, dedupe key and enrichment step, normalizing 25k+ job listings a day without letting one flaky upstream stall the rest. The client side is a React browser extension on Manifest V3 service workers, scraping form schemas through Strategy Pattern adapters and driving 2k+ applications a day across 30+ ATS platforms. The same NestJS backend proxies model calls to sanitize prompts, protect API keys, and stream open-ended answers back over SSE.

The Challenges

Ingestion From Sources That Change Underneath You

Problem

Job listings come from 40+ external sources, none of which owe you a stable schema, a stable rate limit or a stable uptime. Pull them inline and a single slow or reshaped source stalls the whole refresh, and the same listing arrives three times under three different ids.

Solution

Every source runs as its own BullMQ job behind its own rate limiter, so a source that degrades slows only itself. Each record passes a dedupe key before enrichment, and parsing is written against what the source actually returns rather than what it documented, so a reshaped payload drops that record instead of failing the batch. That is what holds 25k+ normalized listings a day steady.

Polymorphic DOM Scraping

Problem

Every ATS renders forms differently. Some use standard `<input>`, others use `<div role='combobox'>` or shadow DOMs. Hardcoding selectors for every site is unscalable.

Solution

Implemented a 'Heuristic Detection Engine.' Instead of relying solely on CSS selectors, the extension analyzes the semantic structure (labels, ARIA roles, proximity). I used a Strategy Pattern where specific `PlatformAdapters` (e.g., `GreenhouseAdapter`) inject custom logic only when strict detection criteria are met, which let me cover the large majority of forms with generic logic and handle the rest with specialized overrides.

detectors/orchestrator.js

Trusted Event Simulation

Problem

Modern frameworks (React/Angular) ignore simple value updates (`input.value = 'text'`). If the user doesn't physically type, the internal state (Virtual DOM) doesn't update, and the form submits as empty.

Solution

Built a 'Human Simulator' module. Instead of setting values, it dispatches a precise sequence of synthetic events (`mousedown` → `focus` → `input` → `change` → `blur`) that mimics a real user. For React-controlled components, I reach into the React Fiber node instances to force state updates explicitly when synthetic events fail.

fillers/react-select.js

Schema-Constrained Generation

Problem

Mapping a Resume (Unstructured Text) to a Form (Strict Schema) is error-prone. The AI might hallucinate a 'Yes' for a 'Years of Experience' integer field.

Solution

Designed a two-stage Prompt Engineering pipeline. First, the extension scrapes the form schema (including dropdown options and validation regex). The prompt then injects the User Profile + Job Description + Form Schema. I enforce 'Strict JSON Output' mode so the model selects *only* valid options from the provided dropdown lists, preventing validation errors. Deterministic fields never reach the model at all: they fill instantly from the stored profile, and only open-ended answers are generated, streaming back over SSE with a batch fallback. Constraining the model to the form's own vocabulary is what gets completion to ~100% on officially supported platforms and 80-90% on unsupported ones, across 2k+ applications a day.

ai.service.ts

Cross-Frame Injection

Problem

Many career sites embed the actual application form inside an `iframe` (cross-origin), blocking the main extension script from accessing the fields due to browser security policies.

Solution

Built on the Chrome `webNavigation` API and Background Service Workers. The background worker maintains a registry of active frames. When the popup is clicked, it identifies the correct `frameId` containing the form and uses `chrome.tabs.sendMessage` to inject the content script directly into that specific child frame context.

background.js

Separating Voice from Format

Problem

Users pick a writing style: upload writing samples that get analysed to extract their voice, write direct prompt instructions, or fall back to a default style. The naive implementation treats a style as one opaque blob of instructions and injects it into every generation call. That breaks immediately, because a style actually contains two different kinds of rule. Voice, meaning vocabulary, sentence rhythm and how formal the register is, should apply everywhere. Format, meaning greeting, sign-off, target word count and paragraph structure, belongs to cover letters only. Ship them as one blob and the format rules leak into short free-text answers, so 'Why do you want to work here?' comes back as a letter that opens with 'Hi team,' and signs off with the applicant's name.

Solution

Split the extracted style into two independently addressable parts and let each generation surface request only what it needs. Sample analysis writes voice attributes and format attributes into separate fields rather than one instruction string, and the free-text answer path composes a prompt from voice alone while the cover letter path composes from both. Generation runs at temperature 0, so the same job posting plus the same style produces the same output, which is what makes the two style modes (learned-from-samples versus direct instructions) comparable against each other instead of a matter of taste.