All articles
Build Guides

Build a Free Job Autofill Extension with Groq and Playwright

FDE Coach EditorialJuly 31, 202610 min read

What We're Building

We're building a Chrome extension that acts as a universal autofill sniper for job applications. You upload your resume PDF once. The extension then uses the free Groq API (running Llama-3) to read any Greenhouse or Lever form, map its fields to your resume details, and inject context-aware answers directly into the DOM. No more copy-pasting your address into seventeen different text boxes.

Feature list:

  • Client-side PDF parsing with pdf.js (your data never leaves your machine before you hit "Submit")
  • Groq LLM call to map standard resume fields (name, email, work_experience) to arbitrary form field labels ("Tell us about your last role")
  • Smart DOM detection for text inputs, dropdowns, and checkboxes on Greenhouse and Lever
  • A popup to trigger the magic on the current tab

Architecture Overview

The extension has three layers: the popup UI, the content script that lives inside the job application page, and a background service worker that holds the resume data and makes the Groq API call. This separation keeps your resume in memory only while the extension is active and avoids CORS issues with the Groq endpoint.

Prerequisites (All Free Tier)

  • Groq API Key: Sign up at console.groq.com. The free tier gives you enough tokens for hundreds of applications. Create an API key and keep it handy.
  • Node.js and npm: We'll use npm to pull in pdf.js. Grab the LTS from nodejs.org.
  • A Chromium browser: Chrome, Brave, or Edge with developer mode enabled.
  • Your resume as a PDF: Keep it clean—text-based, not a scanned image. The parser needs real text.

Step 1: Scaffold the Chrome Extension

Create a project folder called job-autofill. Inside, create manifest.json:

{
  "manifest_version": 3,
  "name": "Job App Autofill",
  "version": "1.0",
  "permissions": ["activeTab", "storage", "scripting"],
  "host_permissions": ["https://*.greenhouse.io/*", "https://*.lever.co/*"],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html"
  },
  "content_scripts": [
    {
      "matches": ["https://*.greenhouse.io/*", "https://*.lever.co/*"],
      "js": ["content.js"]
    }
  ]
}

Create popup.html with a file input and a "Fill Application" button:

<!DOCTYPE html>
<html>
<head>
  <style>
    body { width: 280px; padding: 16px; font-family: system-ui; }
    button { width: 100%; margin-top: 12px; padding: 8px; }
  </style>
</head>
<body>
  <h3>Job Autofill</h3>
  <input type="file" id="resumeInput" accept=".pdf">
  <button id="parseBtn">Parse Resume</button>
  <button id="fillBtn" disabled>Fill Current Page</button>
  <script src="popup.js"></script>
</body>
</html>

Step 2: Parse the Resume with pdf.js

We'll use pdf.js directly in the extension. Download the prebuilt pdf.min.js and pdf.worker.min.js from mozilla/pdfjs-dist and place them in a lib/ folder. In popup.js, read the file and extract text:

document.getElementById('parseBtn').addEventListener('click', async () => {
  const file = document.getElementById('resumeInput').files[0];
  if (!file) return;

  const arrayBuffer = await file.arrayBuffer();
  const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
  let fullText = '';

  for (let i = 1; i <= pdf.numPages; i++) {
    const page = await pdf.getPage(i);
    const content = await page.getTextContent();
    fullText += content.items.map(item => item.str).join(' ') + '\n';
  }

  // Store the raw text in chrome.storage so the background worker can access it
  chrome.storage.local.set({ resumeText: fullText }, () => {
    document.getElementById('fillBtn').disabled = false;
    console.log('Resume parsed and stored.');
  });
});

Step 3: Connect to Groq's Free API

Create background.js. This service worker listens for a message from the content script that contains the list of form fields. It then crafts a prompt, hits Groq, and returns a mapping.

const GROQ_API_KEY = 'YOUR_GROQ_API_KEY'; // Replace with your key

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'mapFields') {
    chrome.storage.local.get(['resumeText'], async (data) => {
      const resume = data.resumeText;
      if (!resume) {
        sendResponse({ error: 'No resume parsed. Upload first.' });
        return;
      }

      const prompt = `
You are a precise job application autofill assistant. 
Given the resume text below and a list of form field labels from a job application, 
return a JSON object mapping each field label to the most appropriate value from the resume. 
If a value is not found, map it to an empty string. 
For dropdowns, pick the closest option from the provided choices. 
For checkboxes (yes/no), return "true" or "false".

Resume:
${resume}

Form fields (label and type):
${JSON.stringify(request.fields)}

Return ONLY valid JSON like: {"Full Name": "John Doe", "Email": "john@example.com", ...}
`;

      try {
        const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${GROQ_API_KEY}`,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            model: 'llama3-8b-8192',
            messages: [{ role: 'user', content: prompt }],
            temperature: 0.1
          })
        });

        const json = await response.json();
        const mapping = JSON.parse(json.choices[0].message.content);
        sendResponse({ mapping });
      } catch (err) {
        sendResponse({ error: err.message });
      }
    });

    return true; // Keep the message channel open for async sendResponse
  }
});

Step 4: Build the DOM Scraper and Autofill Logic

In content.js, we'll have two main functions: one that scrapes all visible form fields, and one that applies the mapping to the DOM.

function scrapeFields() {
  const fields = [];
  // Greenhouse and Lever use common patterns
  const selectors = 'input[type="text"], input[type="email"], input[type="tel"], textarea, select, input[type="checkbox"]';
  
  document.querySelectorAll(selectors).forEach(el => {
    if (el.offsetParent === null) return; // Ignore hidden fields
    
    const label = findLabel(el);
    if (!label) return;

    const field = { label, type: el.type || el.tagName.toLowerCase() };
    
    if (el.tagName === 'SELECT') {
      field.options = Array.from(el.options).map(o => o.textContent.trim());
    }
    
    // Store a unique path to re-locate the element later
    field.selector = buildUniqueSelector(el);
    fields.push(field);
  });

  return fields;
}

function findLabel(el) {
  // Try explicit <label for="...">
  if (el.id) {
    const labelEl = document.querySelector(`label[for="${el.id}"]`);
    if (labelEl) return labelEl.textContent.trim();
  }
  // Try parent label
  const parentLabel = el.closest('label');
  if (parentLabel) return parentLabel.textContent.trim();
  // Try preceding sibling or parent text
  return '';
}

function buildUniqueSelector(el) {
  if (el.id) return `#${el.id}`;
  const path = [];
  while (el && el.nodeType === Node.ELEMENT_NODE) {
    let selector = el.nodeName.toLowerCase();
    if (el.className && typeof el.className === 'string') {
      selector += '.' + el.className.trim().split(/\s+/).join('.');
    }
    path.unshift(selector);
    el = el.parentNode;
  }
  return path.join(' > ');
}

function autofillFields(mapping) {
  Object.entries(mapping).forEach(([label, value]) => {
    const el = findElementByLabel(label);
    if (!el) return;

    if (el.tagName === 'SELECT') {
      const option = Array.from(el.options).find(o => o.textContent.trim() === value);
      if (option) option.selected = true;
    } else if (el.type === 'checkbox') {
      el.checked = (value === true || value === 'true');
    } else {
      el.value = value;
      el.dispatchEvent(new Event('input', { bubbles: true })); // Trigger React/Angular bindings
    }
  });
}

// Fallback: find element by label text
function findElementByLabel(labelText) {
  const allLabels = document.querySelectorAll('label');
  for (const label of allLabels) {
    if (label.textContent.trim().includes(labelText)) {
      if (label.getAttribute('for')) {
        return document.getElementById(label.getAttribute('for'));
      }
      const input = label.querySelector('input, textarea, select');
      if (input) return input;
    }
  }
  return null;
}

Now, listen for a message from the popup to trigger the flow:

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'fillForm') {
    const fields = scrapeFields();
    chrome.runtime.sendMessage({ action: 'mapFields', fields }, (response) => {
      if (response.error) {
        alert('Error: ' + response.error);
        return;
      }
      autofillFields(response.mapping);
    });
  }
});

Step 5: Wire the Popup and Content Script

Back in popup.js, add the handler for the "Fill Current Page" button:

document.getElementById('fillBtn').addEventListener('click', async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  chrome.tabs.sendMessage(tab.id, { action: 'fillForm' });
});

How to Run It

  1. Clone or create the folder with all files: manifest.json, popup.html, popup.js, background.js, content.js, and lib/ with pdf.js files.
  2. Replace YOUR_GROQ_API_KEY in background.js.
  3. Go to chrome://extensions, enable "Developer mode", click "Load unpacked", and select the project folder.
  4. Navigate to a Greenhouse or Lever application page (e.g., a public test posting).
  5. Click the extension icon, upload your resume PDF, hit "Parse Resume", then "Fill Current Page".

Extensions and Next Steps

  • Workday and other ATS: The DOM scraping logic in content.js is modular. Add a matches pattern in the manifest and tweak the selectors for Workday's infamous dynamic forms.
  • Local LLM fallback: If you hit Groq rate limits, you can swap the API call for a WebLLM model (like a quantized Llama-3 running entirely in the browser via a service worker). This keeps the tool 100% free and offline.
  • Multi-resume support: Use chrome.storage to save multiple parsed resumes (e.g., one for engineering roles, one for management) and let the user pick before filling.
  • Smarter field mapping: The current prompt is a blunt instrument. For a more resilient system, consider building a retrieval-augmented generation (RAG) pipeline over your resume. The approach we covered in Build a Codebase Q&A Tool That Indexes a Repo and Answers Questions in Natural Language translates directly to chunking and embedding your resume for precise field matching.

Common Pitfalls

  • CORS errors on Groq: Never call the Groq API directly from the content script. Always route through the background service worker, which has no cross-origin restrictions.
  • React forms not updating: Modern ATS platforms use React or Angular. Simply setting el.value won't trigger their state. Always dispatch an input event with { bubbles: true }.
  • pdf.js worker loading: The worker file must be accessible from the extension context. In popup.js, set pdfjsLib.GlobalWorkerOptions.workerSrc = 'lib/pdf.worker.min.js'; before any parsing call.
  • Groq rate limits: The free tier is generous but has RPM limits. Implement a simple retry with exponential backoff in background.js if you get a 429.

FAQ

Q: Will this work on any job application form? A: Out of the box, it's tuned for Greenhouse and Lever because their DOM structures are predictable. The scraping logic uses generic label-input heuristics, so it will partially work on other platforms, but you'll need to add custom handlers for edge cases. This is exactly the kind of messy enterprise integration problem that a Forward Deployed Engineer tackles in a week.

Q: Is my resume data safe? A: The PDF parsing happens entirely in your browser. The raw text is sent to Groq's API for the mapping call. Groq's free tier does not use your data for training, but if you're handling sensitive PII, you could replace the Groq call with a fully local model using WebLLM.

Q: How do I handle "Education" sections that are not standard text inputs? A: Many forms use a series of inputs for degree, school, and year. The LLM will try to map each sub-field individually. For a more structured approach, you could pre-process the resume into a JSON schema before sending it to Groq, similar to how we structure agent guardrails in GPT‑5.6 Lost $447 Running a Business: How to Structure Agent Guardrails That Actually Work.

Q: The extension only fills some fields. Why? A: The LLM mapping is probabilistic. If a field label is very unusual ("What gets you out of bed in the morning?"), the model might not find a direct match in your resume. You can improve accuracy by adding a few-shot example in the prompt that shows how to handle creative questions by pulling from your cover letter or a custom "additional info" field you add to the stored resume data.

#browser-extension#automation#groq#job-search

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More build guides

August 15 · 0d left
Enroll Now