From 9251ae1a3afa698e061f0c891ba44e434e114139 Mon Sep 17 00:00:00 2001 From: Aryanbuha890 Date: Tue, 28 Jul 2026 15:03:53 +0530 Subject: [PATCH 1/2] feat: Add browser extension for one-click resume upload from job sites (#385) --- extension/README.md | 32 ++++++++ extension/background.js | 47 ++++++++++++ extension/content.js | 76 +++++++++++++++++++ extension/manifest.json | 27 +++++++ extension/popup.html | 84 +++++++++++++++++++++ extension/popup.js | 50 +++++++++++++ frontend/src/App.tsx | 158 +++++++++++++++++++++++++++++++++++++++- 7 files changed, 470 insertions(+), 4 deletions(-) create mode 100644 extension/README.md create mode 100644 extension/background.js create mode 100644 extension/content.js create mode 100644 extension/manifest.json create mode 100644 extension/popup.html create mode 100644 extension/popup.js diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 00000000..7e6c4cc3 --- /dev/null +++ b/extension/README.md @@ -0,0 +1,32 @@ +# AI Resume Analyzer - Job Scraper Browser Extension + +This lightweight Manifest V3 browser extension allows you to scrape job titles and description bodies directly from sites like **LinkedIn** and **Indeed** and launch the AI Resume Analyzer web application with a single click. + +## Features +- **Scrape Job Postings**: Automatically extracts the job role and detailed description from supported sites. +- **Context Menu Integration**: Right-click anywhere on a job posting page and choose **"Analyze Job Posting with AI Resume Analyzer"** to launch the analysis. +- **One-Click Extension Popup**: Quick access button from your toolbar. +- **Default Resume Support**: Pre-loads your default resume from the web application's local storage automatically. + +--- + +## Installation Instructions + +1. Open your browser and go to the Extensions page: + - **Chrome**: `chrome://extensions/` + - **Edge**: `edge://extensions/` + - **Brave**: `brave://extensions/` +2. Enable **Developer mode** (typically a toggle switch in the upper-right corner). +3. Click the **Load unpacked** button in the top-left. +4. Select the `extension/` folder inside this repository root. +5. Pin the extension to your toolbar for quick access! + +--- + +## How to Set a Default Resume in the Web App + +To ensure the extension can preload a resume: +1. Open the web app (e.g. `http://localhost:5173/`). +2. Go to the **Profile** / **Settings** page (click your username in the top-right navbar). +3. Under the **Default Resume** section, upload or select your primary resume, and click **Save as Default**. +4. When launched from the extension, this resume will be pre-loaded and selected automatically! diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 00000000..5b7be28b --- /dev/null +++ b/extension/background.js @@ -0,0 +1,47 @@ +// Context menu option +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ + id: "analyze-job", + title: "Analyze Job Posting with AI Resume Analyzer", + contexts: ["all"] + }); +}); + +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId === "analyze-job" && tab && tab.id) { + chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["content.js"] + }, (results) => { + if (chrome.runtime.lastError || !results || !results[0]) { + console.error("Scraping failed:", chrome.runtime.lastError); + return; + } + const data = results[0].result; + openApp(data); + }); + } +}); + +// Listener for message from popup script +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.action === "analyze") { + openApp(message.data); + sendResponse({ success: true }); + } +}); + +function openApp(data) { + const baseUrl = "http://localhost:5173/"; + const queryParams = new URLSearchParams(); + + if (data.role) { + queryParams.append("role", data.role.trim()); + } + if (data.job_description) { + queryParams.append("job_description", data.job_description.trim()); + } + + const targetUrl = `${baseUrl}?${queryParams.toString()}`; + chrome.tabs.create({ url: targetUrl }); +} diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 00000000..d70bfb0f --- /dev/null +++ b/extension/content.js @@ -0,0 +1,76 @@ +(() => { + // Scraper selectors + const selectors = { + linkedin: { + role: [ + ".job-details-jobs-unified-top-card__job-title", + ".jobs-unified-top-card__job-title", + "h1.t-24", + "h1" + ], + description: [ + "#job-details", + ".jobs-description-content__text", + ".jobs-description__content", + ".jobs-box__html-content" + ] + }, + indeed: { + role: [ + ".jobsearch-JobInfoHeader-title", + "h1.jobsearch-JobInfoHeader-title", + "h1" + ], + description: [ + "#jobDescriptionText", + ".jobsearch-jobDescriptionText" + ] + } + }; + + const getElementText = (selectorList) => { + for (const selector of selectorList) { + const el = document.querySelector(selector); + if (el && el.innerText.trim()) { + return el.innerText.trim(); + } + } + return ""; + }; + + const host = window.location.hostname.toLowerCase(); + let role = ""; + let job_description = ""; + + if (host.includes("linkedin.com")) { + role = getElementText(selectors.linkedin.role); + job_description = getElementText(selectors.linkedin.description); + } else if (host.includes("indeed.com")) { + role = getElementText(selectors.indeed.role); + job_description = getElementText(selectors.indeed.description); + } + + // Fallbacks + if (!role) { + // Attempt general heading + const mainHeading = document.querySelector("h1"); + role = mainHeading ? mainHeading.innerText.trim() : document.title.split("-")[0].trim(); + } + + if (!job_description) { + // Attempt to grab selected text + const selectedText = window.getSelection().toString().trim(); + if (selectedText) { + job_description = selectedText; + } else { + // Fallback to body text or article content + const article = document.querySelector("article"); + job_description = article ? article.innerText.trim() : document.body.innerText.trim(); + } + } + + return { + role: role || "Frontend Developer", + job_description: job_description || "" + }; +})(); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 00000000..cdb8545b --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,27 @@ +{ + "manifest_version": 3, + "name": "AI Resume Analyzer - Job Scraper", + "version": "1.0.0", + "description": "Scrapes job descriptions and titles to analyze against your resume in one click.", + "permissions": [ + "activeTab", + "scripting", + "contextMenus" + ], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icon.png", + "48": "icon.png", + "128": "icon.png" + } + }, + "icons": { + "16": "icon.png", + "48": "icon.png", + "128": "icon.png" + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 00000000..1267d475 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,84 @@ + + + + + + + +
+ +

Resume Scraper

+
+

+ Click the button below to scrape the job details from the current page and open them in the AI Resume Analyzer app. +

+ +
Launching Analyzer App...
+ + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 00000000..7869e0b9 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,50 @@ +document.getElementById("scrape-btn").addEventListener("click", async () => { + const statusDiv = document.getElementById("status"); + statusDiv.style.display = "block"; + statusDiv.innerText = "Scraping page details..."; + + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab || !tab.id) { + statusDiv.innerText = "Error: Active tab not found."; + statusDiv.style.color = "#ef4444"; + return; + } + + // Execute the scraper content script + chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["content.js"] + }, (results) => { + if (chrome.runtime.lastError || !results || !results[0]) { + console.error("Scrape failed:", chrome.runtime.lastError); + statusDiv.innerText = "Could not scrape. Opening default app..."; + statusDiv.style.color = "#fbbf24"; + + // Open app anyway with empty data + chrome.runtime.sendMessage({ + action: "analyze", + data: { role: "", job_description: "" } + }, () => { + setTimeout(() => window.close(), 1500); + }); + return; + } + + const data = results[0].result; + statusDiv.innerText = "Launching Analyzer App..."; + statusDiv.style.color = "#22c55e"; + + chrome.runtime.sendMessage({ + action: "analyze", + data: data + }, () => { + setTimeout(() => window.close(), 1000); + }); + }); + } catch (err) { + console.error("Popup handler error:", err); + statusDiv.innerText = "Error launching app."; + statusDiv.style.color = "#ef4444"; + } +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 78bf7f00..c0ac245e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -287,6 +287,8 @@ function App() { const [theme, setTheme] = useState(getInitialTheme) const [loading, setLoading] = useState(false) const [file, setFile] = useState(null) + const [useDefaultResume, setUseDefaultResume] = useState(false) + const [defaultResumeName, setDefaultResumeName] = useState(null) const [retryAfter, setRetryAfter] = useState(null) const [retryDisabled, setRetryDisabled] = useState(false) const [score, setScore] = useState(null) @@ -479,6 +481,37 @@ function App() { if (user) fetchDbHistory(user.token) }, [user, fetchDbHistory]) + useEffect(() => { + try { + const savedName = localStorage.getItem('default_resume_name') + if (savedName) { + setDefaultResumeName(savedName) + } + } catch { + /* ignore */ + } + + const params = new URLSearchParams(window.location.search) + const urlRole = params.get('role') + const urlJd = params.get('job_description') + + if (urlRole) { + setTargetRole(urlRole) + } + if (urlJd) { + setJobDesc(urlJd) + } + + try { + const savedName = localStorage.getItem('default_resume_name') + if ((urlRole || urlJd) && savedName) { + setUseDefaultResume(true) + } + } catch { + /* ignore */ + } + }, []) + useEffect(() => { document.documentElement.setAttribute('data-theme', theme) try { @@ -783,6 +816,22 @@ function App() { } } + const saveAsDefaultResume = (fileToSave: File) => { + const reader = new FileReader() + reader.onload = () => { + try { + localStorage.setItem('default_resume_base64', reader.result as string) + localStorage.setItem('default_resume_name', fileToSave.name) + localStorage.setItem('default_resume_type', fileToSave.type) + setDefaultResumeName(fileToSave.name) + alert(`Successfully set "${fileToSave.name}" as your default resume!`) + } catch (err) { + alert('Failed to save default resume to local storage.') + } + } + reader.readAsDataURL(fileToSave) + } + const uploadResume = async () => { let hasError = false @@ -793,8 +842,32 @@ function App() { setRoleError(null) } + let fileToAnalyze: File | null = file + if (uploadMode === 'file') { - if (!file) { + if (useDefaultResume) { + try { + const b64 = localStorage.getItem('default_resume_base64') + const name = localStorage.getItem('default_resume_name') + const type = localStorage.getItem('default_resume_type') + if (b64 && name && type) { + const byteString = atob(b64.split(',')[1]) + const ab = new ArrayBuffer(byteString.length) + const ia = new Uint8Array(ab) + for (let i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i) + } + fileToAnalyze = new File([ab], name, { type }) + setFileError(null) + } else { + setFileError('No default resume is currently saved.') + hasError = true + } + } catch { + setFileError('Failed to load the default resume. Please upload manually.') + hasError = true + } + } else if (!file) { setFileError('Please upload a resume file before analyzing.') hasError = true } else { @@ -825,7 +898,7 @@ function App() { await requestNotificationPermission() if (uploadMode === 'file') { - await runAnalysis(file!, 'upload') + await runAnalysis(fileToAnalyze, 'upload') } else { await runAnalysis(null, 'upload', resumeUrl.trim()) } @@ -1415,8 +1488,42 @@ function App() { )}
- {file ? ( - {file.name} + {useDefaultResume && defaultResumeName ? ( +
+ ✅ + + Default Resume Pre-loaded: {defaultResumeName} + + + (Uncheck the default option below to upload another file) + +
+ ) : file ? ( +
+ {file.name} + {defaultResumeName !== file.name && ( + + )} +
) : ( <> @@ -1431,6 +1538,49 @@ function App() {
+ + {defaultResumeName && ( +
+ { + setUseDefaultResume(e.target.checked) + if (e.target.checked) { + setFile(null) + setFileError(null) + } + }} + style={{ width: '16px', height: '16px', cursor: 'pointer' }} + /> + +
+ )} ) : (
{uploadMode === 'file' ? ( -
+
)} + ) : (