Spaces:
Sleeping
Job Automation Agent
ATS resume tailoring system: Chrome extension + HuggingFace server pipeline. Extracts JDs from job boards, generates tailored resumes via LLM pool, returns compiled PDFs.
Scope Lock
Do NOT redesign the ATS extractor, scorer, evidence gate, or rewriting architecture. Do NOT work on V2 (multi-agent natural tailoring). Current work is V3 bulk generation.
Security
- Never hard-code API tokens into committed JS/manifest/packaged assets
- Never expose server filesystem paths to the client
- Sanitize compiler output before returning it
- Disable shell escape in LaTeX, use unique temp dirs, apply strict compilation timeouts
Extension Architecture (MV3)
Files
| File | Role |
|---|---|
extension/manifest.json |
MV3 manifest, <all_urls> content script, side panel |
extension/content.js |
JD extraction + job card scanning, runs on every page |
extension/popup/popup.js |
Side panel UI: single gen, bulk gen, results, editors |
extension/popup/popup.html |
Side panel markup (380px popup, 100% sidepanel) |
extension/background.js |
Service worker: generation lifecycle, SSE streaming, storage persistence |
extension/options/options.html |
Settings: API URL/token, resume LaTeX, gen version, autofill profile |
extension/default_resume.js |
Fallback LaTeX resume (self.DEFAULT_RESUME_LATEX) |
Content Script Message Types
| Message | Handler | Async |
|---|---|---|
EXTRACT_JD |
extractJD() platform router |
no |
SCAN_JOB_CARDS |
scanJobCards() / scanJobCardsLinkedIn() |
no |
NAVIGATE_EXTRACT_JOB |
navigateAndExtractJD(jobId) — LinkedIn pushState |
yes (return true) |
FETCH_EXTRACT_JOB |
fetchExtractJD(url) — background fetch+parse |
yes |
CLICK_AND_EXTRACT_JOB |
clickAndExtractJob(index) — click card fallback |
yes |
SCAN_FORM |
scanApplicationFields() |
no |
APPLY_FORM_FILL |
applyAutofillAnswers(answers) |
no |
LinkedIn Bulk Extraction (V3)
LinkedIn uses SDUI (Server-Driven UI) with virtualized rendering:
li[data-occludable-job-id]elements exist for all 25 jobs but only ~7 visible ones have content- Programmatic scroll does NOT trigger hydration
- Solution:
scanJobCardsLinkedIn()reads job IDs from ALL<li>elements (even empty placeholders).navigateAndExtractJD(jobId)useshistory.pushStateto setcurrentJobIdin the URL +dispatchEvent(popstate)to trigger LinkedIn's SPA router. Detail pane updates with full JD. - No clicking, no navigation, no profile redirects.
#job-detailselement contains the full JD text in the detail pane.expandDetailPane()clicks only<button>"show more" elements (never<a>links — those navigate to company pages).
Bulk Generation Flow (popup.js)
checkBulkAvailable()runs on init — requiresgenVersionDefault === 'v3'AND URL matchesBULK_HOSTS- Sends
SCAN_JOB_CARDSto content script; shows button ifjobs.length > 1 handleBulkGenerate()processes jobs sequentially:- LinkedIn (has
job_id):NAVIGATE_EXTRACT_JOB - Others:
FETCH_EXTRACT_JOBthenCLICK_AND_EXTRACT_JOBfallback
- LinkedIn (has
- Each extracted JD →
generateForBulkJob()→ SSE stream → result saved - Bulk history persisted in
ats_bulk_history(max 5 runs, LaTeX/PDF stripped for quota)
Platform Support
| Platform | Scan | Extract | Method |
|---|---|---|---|
data-occludable-job-id |
pushState + #job-details |
SPA router | |
| Indeed | CSS card selectors | #jobDescriptionText |
fetch or click |
| Naukri | CSS card selectors | .job-desc |
fetch or click |
| Glassdoor | CSS card selectors | [data-test="job-details-container"] |
fetch |
| IIMJobs | CSS card selectors | generic | fetch |
| Generic | text-density heuristic | densest block | direct |
Server Endpoints
| Endpoint | Method | Purpose |
|---|---|---|
/api/health |
GET | Connection check |
/api/generate-application-stream |
POST (multipart) | SSE resume generation |
/api/generate-stream |
POST | SSE generation (background.js) |
/api/generate |
POST | Blocking fallback |
/api/compile-resume |
POST | LaTeX recompile |
/api/compile-cover-letter |
POST | CL LaTeX recompile |
/api/repair-with-feedback |
POST | Feedback-based repair |
/api/form-assist |
POST | AI form field answers |
Storage Keys
| Key | Purpose |
|---|---|
api_url, api_token |
Server connection |
resume_latex, resume_b64 |
User's resume (LaTeX preferred, PDF fallback) |
default_cover_letter_latex |
CL template |
gen_version_default |
v3/v2/v1 — controls bulk visibility |
ats_app_results |
Per-URL result cache (max 10) |
ats_bulk_history |
Bulk run history (max 5) |
autofill_profile_json |
16-field profile for form autofill |
Scoring & Honesty Contract (V1)
The score is a percentage of what the JD actually asks for. Invariants — breaking any of these is how the pipeline previously reported a fabricated 90–100:
- Denominators include gaps. Coverage is
present_in_final / ALL_JD_criteria, never/ supported_subset. Dividing by the supported subset made "2 of 26 must-haves" = 100%. - No free points. A category the JD does not mention is dropped and its weight
redistributed — never scored
1.0. Never default a missing component to full marks. - Criticality comes from the JD, not from our
calibration_weight. Calibration up-ranks what the résumé already evidences, so scoring off it is self-grading. - Tool gaps are never written; competency gaps go to Skills. Claiming a specific tool/platform the resume never mentions IS fabrication (the old "LinkedIn Ads" bug). But soft skills, responsibilities, domain knowledge, and core competencies are placed into the Skills section — a senior PM/founder has these skills even if the resume doesn't list every one. Years, degrees, and certifications stay blocked.
- Never re-calibrate on post-placement text. An inserted keyword is not evidence. Calibration stays anchored to the ORIGINAL résumé.
- Truthful ≠ readable.
verify_rewriteruns_reads_naturally(); a rewrite whose tokens are all legitimate can still be nonsense ("communication skills cadence"). - Never round-trip LaTeX through plain text. Use
_surgical_latex_edit(); re-escaping stripped text turns\textrightarrowinto\textbackslash{}and loses\textbf{}. - Partial evidence needs same-statement co-occurrence. Whole-document token matching
combined
linkedin.com(contact line) + "Ads" into a fabricated "LinkedIn Ads".
With competency gap placement, an honest score lands in the 65–85 band. Remaining gaps are tool/platform-specific skills and hard constraints (years, degrees). The ceiling reflects the max reachable score; score == ceiling means the résumé is maxed out.
Keyword extraction must yield searchable noun phrases. _is_ats_keyword() rejects by
shape (parentheticals, clause markers, pronouns, weak/verb heads, dangling function words),
never by topic, so unfamiliar real skills still pass.
Development
Build Extension Zip
Compress-Archive -Path "extension\*" -DestinationPath "resume-tailor-extension-v{VERSION}.zip" -Force
Version Bump Checklist
extension/manifest.json— bumpversionHISTORY.md— add entry at top- Build zip with new version number
Key Gotchas
- LinkedIn SDUI: CSS classes are hashed/random, never rely on them. Use
data-*attributes and semantic selectors. expandDetailPane()must NEVER click<a>tags — LinkedIn "Show more" links navigate to company pages.- Content script double-injection guard:
window.__atsContentScriptLoadedprevents duplicate listeners. - Service worker keepalive:
startKeepAlive()pings every 20s during long SSE fetches to prevent MV3 suspension. fetchExtractJD()returns SPA shell for LinkedIn (not full JD) — that's why LinkedIn uses pushState instead.