From f90eb2b5489e2fe0776848f49253d4b42f119eae Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:15:10 +0100 Subject: [PATCH 01/64] Exclude extensions directory from root tsconfig and gitignore --- .gitignore | 2 ++ tsconfig.json | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6b21b33..0175dfe 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # dependencies /node_modules +extensions/*/node_modules +extensions/*/dist /.pnp .pnp.* .yarn/* diff --git a/tsconfig.json b/tsconfig.json index ea6ad01..74ae27f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,7 @@ "**/*.mts" ], "exclude": [ - "node_modules" + "node_modules", + "extensions" ] } From 1258297b448250d81f9cca57c14fb914b980fa58 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:15:21 +0100 Subject: [PATCH 02/64] Add Chrome extension project scaffold with Vite and CRXJS --- extensions/chrome/package.json | 21 ++++++++++++++++ extensions/chrome/src/manifest.json | 39 +++++++++++++++++++++++++++++ extensions/chrome/tsconfig.json | 20 +++++++++++++++ extensions/chrome/vite.config.ts | 11 ++++++++ 4 files changed, 91 insertions(+) create mode 100644 extensions/chrome/package.json create mode 100644 extensions/chrome/src/manifest.json create mode 100644 extensions/chrome/tsconfig.json create mode 100644 extensions/chrome/vite.config.ts diff --git a/extensions/chrome/package.json b/extensions/chrome/package.json new file mode 100644 index 0000000..82b0c1a --- /dev/null +++ b/extensions/chrome/package.json @@ -0,0 +1,21 @@ +{ + "name": "shortlist-chrome-extension", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.28", + "@types/chrome": "^0.0.287", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/extensions/chrome/src/manifest.json b/extensions/chrome/src/manifest.json new file mode 100644 index 0000000..80bda0c --- /dev/null +++ b/extensions/chrome/src/manifest.json @@ -0,0 +1,39 @@ +{ + "manifest_version": 3, + "name": "Shortlist — Job Importer", + "version": "0.1.0", + "description": "Import job listings into your Shortlist feed with one click", + "permissions": ["activeTab", "storage"], + "host_permissions": [ + "https://shortlist.johnmoorman.com/*", + "http://localhost:3001/*" + ], + "action": { + "default_popup": "src/popup/popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "content_scripts": [ + { + "matches": [ + "*://boards.greenhouse.io/*/jobs/*", + "*://jobs.greenhouse.io/*/jobs/*", + "*://jobs.lever.co/*/*" + ], + "js": ["src/content/content.ts"], + "run_at": "document_idle" + } + ], + "background": { + "service_worker": "src/background/service-worker.ts", + "type": "module" + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/extensions/chrome/tsconfig.json b/extensions/chrome/tsconfig.json new file mode 100644 index 0000000..e6df9e7 --- /dev/null +++ b/extensions/chrome/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "outDir": "dist", + "rootDir": "src", + "types": ["chrome"] + }, + "include": ["src"] +} diff --git a/extensions/chrome/vite.config.ts b/extensions/chrome/vite.config.ts new file mode 100644 index 0000000..82e4923 --- /dev/null +++ b/extensions/chrome/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import { crx } from "@crxjs/vite-plugin"; +import manifest from "./src/manifest.json"; + +export default defineConfig({ + plugins: [crx({ manifest })], + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); From fb67ae4edcea1a20d9ac5fe00a48e564a391535c Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:15:31 +0100 Subject: [PATCH 03/64] Add shared types and extractor utility functions --- extensions/chrome/src/lib/extractors/utils.ts | 70 +++++++++++++++ extensions/chrome/src/types.ts | 90 +++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 extensions/chrome/src/lib/extractors/utils.ts create mode 100644 extensions/chrome/src/types.ts diff --git a/extensions/chrome/src/lib/extractors/utils.ts b/extensions/chrome/src/lib/extractors/utils.ts new file mode 100644 index 0000000..d85c2a8 --- /dev/null +++ b/extensions/chrome/src/lib/extractors/utils.ts @@ -0,0 +1,70 @@ +/** Infer location type from a free-text location string. */ +export function inferLocationType( + location: string | null, +): "REMOTE" | "HYBRID" | "ONSITE" | null { + if (!location) return null; + const lower = location.toLowerCase(); + if (lower.includes("remote")) return "REMOTE"; + if (lower.includes("hybrid")) return "HYBRID"; + return null; +} + +/** Parse a free-text job type string into a structured enum value. */ +export function parseJobType( + text: string | null, +): + | "FULL_TIME" + | "PART_TIME" + | "CONTRACT" + | "FREELANCE" + | "INTERNSHIP" + | null { + if (!text) return null; + const lower = text.toLowerCase(); + if (lower.includes("full")) return "FULL_TIME"; + if (lower.includes("part")) return "PART_TIME"; + if (lower.includes("contract") || lower.includes("temporary")) + return "CONTRACT"; + if (lower.includes("freelance")) return "FREELANCE"; + if (lower.includes("intern")) return "INTERNSHIP"; + return null; +} + +/** Best-effort salary range extraction from free text. */ +export function parseSalaryFromText(text: string | undefined): { + salaryMin: number | null; + salaryMax: number | null; + currency: string | null; +} { + if (!text) return { salaryMin: null, salaryMax: null, currency: null }; + + const currencyMap: Record = { + $: "USD", + "\u20AC": "EUR", + "\u00A3": "GBP", + EUR: "EUR", + GBP: "GBP", + CHF: "CHF", + USD: "USD", + }; + + // Match patterns: "$120K - $180K", "60,000 - 90,000 EUR", "$120,000-$180,000/yr" + const match = text.match( + /([$\u20AC\u00A3]|EUR|GBP|CHF|USD)?\s*([\d,.]+)\s*[Kk]?\s*[-\u2013to]+\s*[$\u20AC\u00A3]?\s*([\d,.]+)\s*[Kk]?\s*(?:\/?\s*(?:yr|year|annum|pa))?\s*(EUR|GBP|CHF|USD)?/, + ); + if (!match) return { salaryMin: null, salaryMax: null, currency: null }; + + const currencySymbol = match[1] ?? match[4] ?? ""; + const currency = currencyMap[currencySymbol] ?? (currencySymbol || null); + + const normalize = (s: string) => { + const n = parseFloat(s.replace(/,/g, "")); + return text.toLowerCase().includes("k") && n < 1000 ? n * 1000 : n; + }; + + return { + salaryMin: normalize(match[2]), + salaryMax: normalize(match[3]), + currency, + }; +} diff --git a/extensions/chrome/src/types.ts b/extensions/chrome/src/types.ts new file mode 100644 index 0000000..3408db6 --- /dev/null +++ b/extensions/chrome/src/types.ts @@ -0,0 +1,90 @@ +// ── Extracted job data ──────────────────────────────────────────────────── + +export interface ExtractedJob { + title: string; + company: string; + description: string; + location: string | null; + locationType: "REMOTE" | "HYBRID" | "ONSITE" | null; + url: string; + postedAt: string | null; + jobType: + | "FULL_TIME" + | "PART_TIME" + | "CONTRACT" + | "FREELANCE" + | "INTERNSHIP" + | null; + salaryMin: number | null; + salaryMax: number | null; + currency: string | null; + skills: string[]; + source: + | "LINKEDIN" + | "GREENHOUSE" + | "LEVER" + | "ASHBY" + | "INDEED" + | "CUSTOM"; +} + +// ── Extractor interface ────────────────────────────────────────────────── + +export interface Extractor { + /** URL pattern this extractor handles */ + matches: RegExp; + /** Extract structured job data from the current page DOM */ + extract: () => ExtractedJob | null; +} + +// ── Extraction result variants ─────────────────────────────────────────── + +export type ExtractionResult = + | { type: "structured"; data: ExtractedJob } + | { + type: "generic"; + url: string; + html: string; + title: string; + meta: Record; + } + | { type: "none" }; + +// ── Chrome message types ───────────────────────────────────────────────── + +export type Message = + // Page detection (content script -> background) + | { type: "JOB_PAGE_DETECTED" } + + // Auth + | { type: "GET_AUTH_STATUS" } + | { type: "AUTH_STATUS"; authenticated: boolean } + + // Profiles + | { type: "GET_PROFILES" } + | { + type: "PROFILES"; + profiles: Array<{ id: string; name: string; isActive: boolean }>; + } + + // Extraction (popup -> content script) + | { type: "EXTRACT" } + | { type: "EXTRACTED"; result: ExtractionResult } + + // Import (structured job from extractor) + | { type: "IMPORT_JOB"; profileId: string; job: ExtractedJob } + | { type: "IMPORT_RESULT"; ok: boolean; jobId?: string; error?: string } + + // Import (generic fallback — needs AI extraction first) + | { + type: "EXTRACT_AND_IMPORT"; + profileId: string; + html: string; + url: string; + } + | { + type: "EXTRACT_AND_IMPORT_RESULT"; + ok: boolean; + jobId?: string; + error?: string; + }; From 98628135cd2df5e04585157437d85129c584b152 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:15:40 +0100 Subject: [PATCH 04/64] Add Greenhouse and Lever DOM extractors for job pages --- .../chrome/src/lib/extractors/greenhouse.ts | 50 ++++++++++++++++ extensions/chrome/src/lib/extractors/lever.ts | 60 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 extensions/chrome/src/lib/extractors/greenhouse.ts create mode 100644 extensions/chrome/src/lib/extractors/lever.ts diff --git a/extensions/chrome/src/lib/extractors/greenhouse.ts b/extensions/chrome/src/lib/extractors/greenhouse.ts new file mode 100644 index 0000000..9e2c0a3 --- /dev/null +++ b/extensions/chrome/src/lib/extractors/greenhouse.ts @@ -0,0 +1,50 @@ +import type { Extractor, ExtractedJob } from "../../types"; +import { inferLocationType } from "./utils"; + +export const greenhouseExtractor: Extractor = { + matches: + /^https?:\/\/(boards|jobs)\.greenhouse\.io\/[^/]+\/(jobs\/\d+|embed\/job_app)/, + + extract(): ExtractedJob | null { + const title = + document + .querySelector(".app-title, #header .company-name + h1") + ?.textContent?.trim() ?? + document.querySelector("h1")?.textContent?.trim(); + + const company = + document + .querySelector(".company-name, #header .company-name") + ?.textContent?.trim() ?? + document + .querySelector('meta[property="og:site_name"]') + ?.getAttribute("content") ?? + ""; + + if (!title || !company) return null; + + const location = + document + .querySelector(".location, .body--metadata--location") + ?.textContent?.trim() ?? null; + + const descEl = document.querySelector("#content, .body--content"); + const description = descEl?.innerHTML ?? ""; + + return { + title, + company, + description, + location, + locationType: inferLocationType(location), + url: window.location.href, + postedAt: null, + jobType: null, + salaryMin: null, + salaryMax: null, + currency: null, + skills: [], + source: "GREENHOUSE", + }; + }, +}; diff --git a/extensions/chrome/src/lib/extractors/lever.ts b/extensions/chrome/src/lib/extractors/lever.ts new file mode 100644 index 0000000..ce775c2 --- /dev/null +++ b/extensions/chrome/src/lib/extractors/lever.ts @@ -0,0 +1,60 @@ +import type { Extractor, ExtractedJob } from "../../types"; +import { inferLocationType, parseJobType } from "./utils"; + +export const leverExtractor: Extractor = { + matches: /^https?:\/\/jobs\.lever\.co\/[^/]+\/[a-f0-9-]+/, + + extract(): ExtractedJob | null { + const title = document + .querySelector(".posting-headline h2") + ?.textContent?.trim(); + + const company = + document + .querySelector(".posting-headline .company-name, .main-header-logo img") + ?.getAttribute("alt") ?? + document + .querySelector('meta[property="og:site_name"]') + ?.getAttribute("content") ?? + ""; + + if (!title || !company) return null; + + // Lever puts location and work type in category tags + const categories = document.querySelectorAll( + ".posting-categories .posting-category", + ); + let location: string | null = null; + let commitment: string | null = null; + + categories.forEach((el) => { + const text = el.textContent?.trim() ?? ""; + const label = el.querySelector( + ".sort-by-commitment, .sort-by-location, .sort-by-team", + ); + if (label?.classList.contains("sort-by-location")) location = text; + if (label?.classList.contains("sort-by-commitment")) commitment = text; + }); + + const descEl = document.querySelector( + ".posting-page .content, .posting-description", + ); + const description = descEl?.innerHTML ?? ""; + + return { + title, + company, + description, + location, + locationType: inferLocationType(location), + url: window.location.href, + postedAt: null, + jobType: parseJobType(commitment), + salaryMin: null, + salaryMax: null, + currency: null, + skills: [], + source: "LEVER", + }; + }, +}; From c7f1f2493ae15b69f903c6a8fc20181c6c09626d Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:15:50 +0100 Subject: [PATCH 05/64] Add generic fallback extractor and extractor registry --- .../chrome/src/lib/extractors/generic.ts | 41 +++++++++++++++++++ extensions/chrome/src/lib/extractors/index.ts | 29 +++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 extensions/chrome/src/lib/extractors/generic.ts create mode 100644 extensions/chrome/src/lib/extractors/index.ts diff --git a/extensions/chrome/src/lib/extractors/generic.ts b/extensions/chrome/src/lib/extractors/generic.ts new file mode 100644 index 0000000..b5e7922 --- /dev/null +++ b/extensions/chrome/src/lib/extractors/generic.ts @@ -0,0 +1,41 @@ +const MAX_HTML_LENGTH = 10_000; + +/** + * Generic fallback: collects page metadata and cleaned body text + * for AI extraction on the server side. + */ +export function collectPageContent(): { + url: string; + html: string; + title: string; + meta: Record; +} { + // Collect useful meta tags + const meta: Record = {}; + document.querySelectorAll("meta[property], meta[name]").forEach((el) => { + const key = + el.getAttribute("property") ?? el.getAttribute("name") ?? ""; + const value = el.getAttribute("content") ?? ""; + if (key && value) meta[key] = value; + }); + + // Get the main content area, or fall back to body + const mainEl = + document.querySelector("main") ?? + document.querySelector("article") ?? + document.querySelector("[role='main']") ?? + document.body; + + // Strip scripts, styles, navs, footers to reduce noise + const clone = mainEl.cloneNode(true) as HTMLElement; + clone + .querySelectorAll("script, style, nav, footer, header, iframe, noscript") + .forEach((el) => el.remove()); + + return { + url: window.location.href, + html: clone.innerText.slice(0, MAX_HTML_LENGTH), + title: document.title, + meta, + }; +} diff --git a/extensions/chrome/src/lib/extractors/index.ts b/extensions/chrome/src/lib/extractors/index.ts new file mode 100644 index 0000000..29fb3bd --- /dev/null +++ b/extensions/chrome/src/lib/extractors/index.ts @@ -0,0 +1,29 @@ +import { greenhouseExtractor } from "./greenhouse"; +import { leverExtractor } from "./lever"; +import { collectPageContent } from "./generic"; +import type { Extractor, ExtractionResult } from "../../types"; + +const extractors: Extractor[] = [greenhouseExtractor, leverExtractor]; + +/** + * Attempt extraction using a site-specific extractor first, then fall back + * to the generic page content collector for AI extraction on the server. + */ +export function extractFromPage(): ExtractionResult { + const url = window.location.href; + + for (const extractor of extractors) { + if (extractor.matches.test(url)) { + const data = extractor.extract(); + if (data) return { type: "structured", data }; + // Extractor matched URL but failed to parse -- fall through to generic + break; + } + } + + // Generic fallback: collect raw page content for AI extraction + const content = collectPageContent(); + if (content.html.length < 100) return { type: "none" }; + + return { type: "generic", ...content }; +} From 17906743e9905ae1a514c40728671099e92f3f9c Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:16:00 +0100 Subject: [PATCH 06/64] Add Shortlist API client with Clerk cookie auth --- extensions/chrome/src/lib/api.ts | 136 +++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 extensions/chrome/src/lib/api.ts diff --git a/extensions/chrome/src/lib/api.ts b/extensions/chrome/src/lib/api.ts new file mode 100644 index 0000000..ead2ea4 --- /dev/null +++ b/extensions/chrome/src/lib/api.ts @@ -0,0 +1,136 @@ +const PROD_URL = "https://shortlist.johnmoorman.com"; +const DEV_URL = "http://localhost:3001"; + +interface ApiResponse { + ok: boolean; + status: number; + data: T | null; + error: string | null; +} + +/** Resolve the API base URL from extension storage, defaulting to production. */ +async function getBaseUrl(): Promise { + try { + const result = await chrome.storage.local.get("apiBaseUrl"); + return (result.apiBaseUrl as string) || PROD_URL; + } catch { + return PROD_URL; + } +} + +/** Generic API call that includes cookies for Clerk session auth. */ +export async function apiCall( + path: string, + options: RequestInit = {}, +): Promise> { + const baseUrl = await getBaseUrl(); + + try { + const res = await fetch(`${baseUrl}${path}`, { + ...options, + credentials: "include", + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + + const data = await res.json().catch(() => null); + + if (res.status === 401) { + return { + ok: false, + status: 401, + data: null, + error: "Please sign in to Shortlist to import jobs.", + }; + } + + if (!res.ok) { + return { + ok: false, + status: res.status, + data: null, + error: data?.error ?? `Request failed (${res.status})`, + }; + } + + return { ok: true, status: res.status, data, error: null }; + } catch { + return { + ok: false, + status: 0, + data: null, + error: "Could not reach Shortlist. Check your internet connection.", + }; + } +} + +// ── Typed API helpers ──────────────────────────────────────────────────── + +interface AuthStatus { + authenticated: boolean; + userId?: string; +} + +interface ProfilesResponse { + profiles: Array<{ id: string; name: string; isActive: boolean }>; +} + +interface ImportResponse { + job: { id: string }; +} + +interface ExtractResponse { + title: string; + company: string; + description: string; + location: string | null; + locationType: "REMOTE" | "HYBRID" | "ONSITE" | null; + url: string | null; + postedAt: string | null; + jobType: string | null; + salaryMin: number | null; + salaryMax: number | null; + currency: string | null; + skills: string[]; +} + +export function checkAuth() { + return apiCall("/api/extension/status"); +} + +export function fetchProfiles() { + return apiCall("/api/extension/profiles"); +} + +export function importJob(payload: { + profileId: string; + originalInput: string; + title: string; + company: string; + description: string; + location?: string | null; + locationType?: string | null; + url?: string | null; + postedAt?: string | null; + jobType?: string | null; + salaryMin?: number | null; + salaryMax?: number | null; + currency?: string | null; + skills?: string[]; +}) { + return apiCall("/api/jobs/import", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function extractJob(payload: { input: string; profileId: string }) { + return apiCall("/api/jobs/extract", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export { DEV_URL, PROD_URL }; From a21ba0c11b59c0a5f2ea6a7c62fce9d4524f1352 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:16:10 +0100 Subject: [PATCH 07/64] Add content script for page detection and extraction --- extensions/chrome/src/content/content.ts | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 extensions/chrome/src/content/content.ts diff --git a/extensions/chrome/src/content/content.ts b/extensions/chrome/src/content/content.ts new file mode 100644 index 0000000..d8e1327 --- /dev/null +++ b/extensions/chrome/src/content/content.ts @@ -0,0 +1,30 @@ +import { extractFromPage } from "../lib/extractors"; +import type { Message } from "../types"; + +/** + * Content script — injected into job board pages. + * + * Responsibilities: + * 1. Detect supported job pages and notify the background worker (badge). + * 2. On request from the popup, extract job data from the DOM. + */ + +// Notify the background worker that this page may be a job listing. +// The worker uses this to set the toolbar badge. +chrome.runtime.sendMessage({ type: "JOB_PAGE_DETECTED" }); + +// Listen for extraction requests from the popup +chrome.runtime.onMessage.addListener( + ( + message: Message, + _sender: chrome.runtime.MessageSender, + sendResponse: (response: Message) => void, + ) => { + if (message.type === "EXTRACT") { + const result = extractFromPage(); + sendResponse({ type: "EXTRACTED", result }); + } + // Return true to keep the message channel open for async sendResponse + return true; + }, +); From 07f2a68739fa9f1d1b70524a4019be895d54c6af Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:16:25 +0100 Subject: [PATCH 08/64] Add background service worker for API coordination --- .../chrome/src/background/service-worker.ts | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 extensions/chrome/src/background/service-worker.ts diff --git a/extensions/chrome/src/background/service-worker.ts b/extensions/chrome/src/background/service-worker.ts new file mode 100644 index 0000000..567504e --- /dev/null +++ b/extensions/chrome/src/background/service-worker.ts @@ -0,0 +1,160 @@ +import { + checkAuth, + fetchProfiles, + importJob, + extractJob, +} from "../lib/api"; +import type { Message, ExtractedJob } from "../types"; + +/** + * Background service worker — coordinates between popup, content script, + * and the Shortlist API. + */ + +// ── Badge management ───────────────────────────────────────────────────── + +// When a content script detects a job page, show a badge on the icon +chrome.runtime.onMessage.addListener( + ( + message: Message, + sender: chrome.runtime.MessageSender, + ) => { + if (message.type === "JOB_PAGE_DETECTED" && sender.tab?.id) { + chrome.action.setBadgeText({ text: "+", tabId: sender.tab.id }); + chrome.action.setBadgeBackgroundColor({ + color: "#6366f1", + tabId: sender.tab.id, + }); + } + }, +); + +// ── Message handler ────────────────────────────────────────────────────── + +chrome.runtime.onMessage.addListener( + ( + message: Message, + _sender: chrome.runtime.MessageSender, + sendResponse: (response: unknown) => void, + ) => { + switch (message.type) { + case "GET_AUTH_STATUS": + handleAuthStatus(sendResponse); + return true; // keep channel open for async + + case "GET_PROFILES": + handleGetProfiles(sendResponse); + return true; + + case "IMPORT_JOB": + handleImportJob(message.profileId, message.job, sendResponse); + return true; + + case "EXTRACT_AND_IMPORT": + handleExtractAndImport( + message.profileId, + message.html, + message.url, + sendResponse, + ); + return true; + } + }, +); + +// ── Handlers ───────────────────────────────────────────────────────────── + +async function handleAuthStatus( + sendResponse: (response: unknown) => void, +): Promise { + const result = await checkAuth(); + sendResponse({ + type: "AUTH_STATUS", + authenticated: result.ok && (result.data?.authenticated ?? false), + }); +} + +async function handleGetProfiles( + sendResponse: (response: unknown) => void, +): Promise { + const result = await fetchProfiles(); + sendResponse({ + type: "PROFILES", + profiles: result.data?.profiles ?? [], + }); +} + +async function handleImportJob( + profileId: string, + job: ExtractedJob, + sendResponse: (response: unknown) => void, +): Promise { + const result = await importJob({ + profileId, + originalInput: job.url, + title: job.title, + company: job.company, + description: job.description, + location: job.location, + locationType: job.locationType, + url: job.url, + postedAt: job.postedAt, + jobType: job.jobType, + salaryMin: job.salaryMin, + salaryMax: job.salaryMax, + currency: job.currency, + skills: job.skills, + }); + + sendResponse({ + type: "IMPORT_RESULT", + ok: result.ok, + jobId: result.data?.job?.id, + error: result.error, + }); +} + +async function handleExtractAndImport( + profileId: string, + html: string, + url: string, + sendResponse: (response: unknown) => void, +): Promise { + // Step 1: AI extraction + const extractResult = await extractJob({ input: html, profileId }); + if (!extractResult.ok || !extractResult.data) { + sendResponse({ + type: "EXTRACT_AND_IMPORT_RESULT", + ok: false, + error: extractResult.error ?? "Extraction failed", + }); + return; + } + + const extracted = extractResult.data; + + // Step 2: Import the extracted job + const importResult = await importJob({ + profileId, + originalInput: url, + title: extracted.title, + company: extracted.company, + description: extracted.description, + location: extracted.location, + locationType: extracted.locationType, + url: extracted.url ?? url, + postedAt: extracted.postedAt, + jobType: extracted.jobType, + salaryMin: extracted.salaryMin, + salaryMax: extracted.salaryMax, + currency: extracted.currency, + skills: extracted.skills, + }); + + sendResponse({ + type: "EXTRACT_AND_IMPORT_RESULT", + ok: importResult.ok, + jobId: importResult.data?.job?.id, + error: importResult.error, + }); +} From 5859091a0d6f595f1ffad7697fa88e09ee23bf56 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:16:35 +0100 Subject: [PATCH 09/64] Add React popup UI with auth, extraction, and import flow --- extensions/chrome/src/popup/popup.css | 184 ++++++++++++++ extensions/chrome/src/popup/popup.html | 13 + extensions/chrome/src/popup/popup.tsx | 322 +++++++++++++++++++++++++ 3 files changed, 519 insertions(+) create mode 100644 extensions/chrome/src/popup/popup.css create mode 100644 extensions/chrome/src/popup/popup.html create mode 100644 extensions/chrome/src/popup/popup.tsx diff --git a/extensions/chrome/src/popup/popup.css b/extensions/chrome/src/popup/popup.css new file mode 100644 index 0000000..4d2af05 --- /dev/null +++ b/extensions/chrome/src/popup/popup.css @@ -0,0 +1,184 @@ +/* ── Reset + base ──────────────────────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #ffffff; + --bg-secondary: #f9fafb; + --text: #111827; + --text-muted: #6b7280; + --border: #e5e7eb; + --primary: #6366f1; + --primary-hover: #4f46e5; + --success: #10b981; + --error: #ef4444; + --radius: 8px; +} + +body { + width: 360px; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--text); + background: var(--bg); +} + +/* ── Layout ────────────────────────────────────────────────────────────── */ +.popup { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.popup-header { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.popup-header h1 { + font-size: 16px; + font-weight: 600; +} + +/* ── Job preview ───────────────────────────────────────────────────────── */ +.job-preview { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; +} + +.job-preview .title { + font-weight: 600; + font-size: 14px; + color: var(--text); +} + +.job-preview .company { + font-size: 13px; + color: var(--text-muted); + margin-top: 2px; +} + +.job-preview .location { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; +} + +.job-preview .generic-notice { + font-size: 12px; + color: var(--text-muted); + font-style: italic; + margin-top: 4px; +} + +/* ── Profile selector ──────────────────────────────────────────────────── */ +.profile-select { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; + background: var(--bg); + color: var(--text); + cursor: pointer; +} + +.profile-select:focus { + outline: 2px solid var(--primary); + outline-offset: -1px; +} + +/* ── Buttons ───────────────────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + padding: 10px 16px; + border: none; + border-radius: var(--radius); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-hover); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-link { + background: none; + color: var(--primary); + font-size: 13px; + padding: 6px; +} + +.btn-link:hover { + text-decoration: underline; +} + +/* ── Status messages ───────────────────────────────────────────────────── */ +.status { + text-align: center; + padding: 8px; + border-radius: var(--radius); + font-size: 13px; +} + +.status-success { + background: #ecfdf5; + color: #065f46; +} + +.status-error { + background: #fef2f2; + color: #991b1b; +} + +.status-loading { + color: var(--text-muted); +} + +/* ── Auth prompt ───────────────────────────────────────────────────────── */ +.auth-prompt { + text-align: center; + padding: 24px 16px; + color: var(--text-muted); +} + +.auth-prompt p { + margin-bottom: 12px; +} + +/* ── Empty state ───────────────────────────────────────────────────────── */ +.empty-state { + text-align: center; + padding: 24px 16px; + color: var(--text-muted); +} diff --git a/extensions/chrome/src/popup/popup.html b/extensions/chrome/src/popup/popup.html new file mode 100644 index 0000000..ba9621a --- /dev/null +++ b/extensions/chrome/src/popup/popup.html @@ -0,0 +1,13 @@ + + + + + + Shortlist + + + +
+ + + diff --git a/extensions/chrome/src/popup/popup.tsx b/extensions/chrome/src/popup/popup.tsx new file mode 100644 index 0000000..e07ae63 --- /dev/null +++ b/extensions/chrome/src/popup/popup.tsx @@ -0,0 +1,322 @@ +import React, { useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import type { ExtractionResult, ExtractedJob, Message } from "../types"; + +// ── Types ──────────────────────────────────────────────────────────────── + +interface Profile { + id: string; + name: string; + isActive: boolean; +} + +type Status = "idle" | "loading" | "extracting" | "importing" | "success" | "error"; + +// ── Helpers ────────────────────────────────────────────────────────────── + +function sendMessage(message: Message): Promise { + return chrome.runtime.sendMessage(message); +} + +function sendTabMessage(tabId: number, message: Message): Promise { + return chrome.tabs.sendMessage(tabId, message); +} + +const SHORTLIST_URL = "https://shortlist.johnmoorman.com"; + +// ── App ────────────────────────────────────────────────────────────────── + +function Popup() { + const [authenticated, setAuthenticated] = useState(null); + const [profiles, setProfiles] = useState([]); + const [selectedProfileId, setSelectedProfileId] = useState(""); + const [extraction, setExtraction] = useState(null); + const [status, setStatus] = useState("loading"); + const [errorMessage, setErrorMessage] = useState(""); + const [importedJobId, setImportedJobId] = useState(null); + + // Initialize: check auth, fetch profiles, extract from current tab + useEffect(() => { + async function init() { + // 1. Check auth + const authResult = await sendMessage<{ + type: string; + authenticated: boolean; + }>({ type: "GET_AUTH_STATUS" }); + + if (!authResult.authenticated) { + setAuthenticated(false); + setStatus("idle"); + return; + } + setAuthenticated(true); + + // 2. Fetch profiles + const profilesResult = await sendMessage<{ + type: string; + profiles: Profile[]; + }>({ type: "GET_PROFILES" }); + + const profs = profilesResult.profiles ?? []; + setProfiles(profs); + + // Default to the active profile + const active = profs.find((p) => p.isActive); + if (active) setSelectedProfileId(active.id); + else if (profs.length > 0) setSelectedProfileId(profs[0].id); + + // 3. Extract from the current tab + setStatus("extracting"); + try { + const [tab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + if (tab?.id) { + const result = await sendTabMessage<{ + type: string; + result: ExtractionResult; + }>(tab.id, { type: "EXTRACT" }); + setExtraction(result.result); + } else { + setExtraction({ type: "none" }); + } + } catch { + // Content script not injected on this page -- use generic fallback + setExtraction({ type: "none" }); + } + + setStatus("idle"); + } + + init(); + }, []); + + // ── Import handler ─────────────────────────────────────────────────── + + async function handleImport() { + if (!selectedProfileId || !extraction) return; + + setStatus("importing"); + setErrorMessage(""); + + try { + if (extraction.type === "structured") { + const result = await sendMessage<{ + type: string; + ok: boolean; + jobId?: string; + error?: string; + }>({ + type: "IMPORT_JOB", + profileId: selectedProfileId, + job: extraction.data, + }); + + if (result.ok) { + setStatus("success"); + setImportedJobId(result.jobId ?? null); + } else { + setStatus("error"); + setErrorMessage(result.error ?? "Import failed"); + } + } else if (extraction.type === "generic") { + const result = await sendMessage<{ + type: string; + ok: boolean; + jobId?: string; + error?: string; + }>({ + type: "EXTRACT_AND_IMPORT", + profileId: selectedProfileId, + html: extraction.html, + url: extraction.url, + }); + + if (result.ok) { + setStatus("success"); + setImportedJobId(result.jobId ?? null); + } else { + setStatus("error"); + setErrorMessage(result.error ?? "Import failed"); + } + } + } catch { + setStatus("error"); + setErrorMessage("Something went wrong. Please try again."); + } + } + + // ── Render ───────────────────────────────────────────────────────────── + + // Loading state + if (authenticated === null) { + return ( +
+
+
Connecting...
+
+ ); + } + + // Not authenticated + if (!authenticated) { + return ( +
+
+
+

Sign in to Shortlist to import jobs.

+ +
+
+ ); + } + + // Success state + if (status === "success") { + return ( +
+
+
+ Job imported successfully! +
+ {importedJobId && ( + + )} + +
+ ); + } + + // No extractable content + if (extraction?.type === "none") { + return ( +
+
+
+

No job listing detected on this page.

+

+ Try opening a specific job posting page. +

+
+ +
+ ); + } + + return ( +
+
+ + {/* Job preview */} + {extraction?.type === "structured" && ( + + )} + {extraction?.type === "generic" && ( +
+
{extraction.title || "Untitled Page"}
+
+ AI will extract job details from this page +
+
+ )} + + {/* Extracting state */} + {status === "extracting" && ( +
Reading page...
+ )} + + {/* Profile selector */} + {profiles.length > 1 && ( + + )} + + {/* Import button */} + {extraction && status !== "extracting" && ( + + )} + + {/* Error message */} + {status === "error" && ( +
{errorMessage}
+ )} +
+ ); +} + +// ── Sub-components ─────────────────────────────────────────────────────── + +function Header() { + return ( +
+

Shortlist

+
+ ); +} + +function JobPreview({ job }: { job: ExtractedJob }) { + return ( +
+
{job.title}
+
{job.company}
+ {job.location &&
{job.location}
} +
+ ); +} + +// ── Mount ──────────────────────────────────────────────────────────────── + +const root = document.getElementById("root"); +if (root) { + createRoot(root).render(); +} From dfd275278001d493d62bc208fbf9d71a8f5d5608 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:16:51 +0100 Subject: [PATCH 10/64] Add placeholder SVG icons and development README --- extensions/chrome/README.md | 118 ++++++++++++++++++++++++++++ extensions/chrome/icons/icon128.svg | 4 + extensions/chrome/icons/icon16.svg | 4 + extensions/chrome/icons/icon48.svg | 4 + 4 files changed, 130 insertions(+) create mode 100644 extensions/chrome/README.md create mode 100644 extensions/chrome/icons/icon128.svg create mode 100644 extensions/chrome/icons/icon16.svg create mode 100644 extensions/chrome/icons/icon48.svg diff --git a/extensions/chrome/README.md b/extensions/chrome/README.md new file mode 100644 index 0000000..e54a98c --- /dev/null +++ b/extensions/chrome/README.md @@ -0,0 +1,118 @@ +# Shortlist Chrome Extension + +Import job listings into your Shortlist feed with one click while browsing any job board. + +## Features + +- **Greenhouse & Lever extractors** -- structured DOM extraction on supported job boards +- **Generic fallback** -- collects page content for AI extraction on any other site +- **One-click import** -- sends extracted data to the Shortlist API via your existing session +- **Profile selector** -- choose which search profile to import into + +## Development Setup + +### Prerequisites + +- Node.js 18+ +- pnpm (or npm) +- A running Shortlist app (for API calls) + +### Install dependencies + +```bash +cd extensions/chrome +npm install +``` + +### Generate icons + +The `icons/` directory contains SVG source files. Chrome requires PNG icons. Convert them before loading: + +```bash +# Using ImageMagick (or any SVG-to-PNG tool) +convert icons/icon16.svg icons/icon16.png +convert icons/icon48.svg icons/icon48.png +convert icons/icon128.svg icons/icon128.png +``` + +Or use an online converter. The PNGs must be exactly 16x16, 48x48, and 128x128 pixels. + +### Build + +```bash +npm run build +``` + +### Development mode (with hot reload) + +```bash +npm run dev +``` + +### Load in Chrome + +1. Open `chrome://extensions` +2. Enable "Developer mode" (top right) +3. Click "Load unpacked" +4. Select the `extensions/chrome/dist` directory +5. The Shortlist icon appears in your toolbar + +### API base URL + +By default the extension talks to `https://shortlist.johnmoorman.com`. For local development, open the extension's background page console and run: + +```js +chrome.storage.local.set({ apiBaseUrl: "http://localhost:3001" }); +``` + +To reset back to production: + +```js +chrome.storage.local.remove("apiBaseUrl"); +``` + +## Architecture + +``` +src/ + manifest.json Manifest V3 configuration + popup/ + popup.html Popup entry HTML + popup.tsx React popup UI + popup.css Popup styles + background/ + service-worker.ts Background script -- coordinates API calls + content/ + content.ts Content script -- extracts page data + lib/ + api.ts Shortlist API client (fetch + Clerk cookies) + extractors/ + index.ts Extractor registry + greenhouse.ts Greenhouse job page extractor + lever.ts Lever job page extractor + generic.ts Generic fallback (page text for AI extraction) + utils.ts Shared extraction utilities + types.ts Shared TypeScript types +``` + +### Message flow + +1. Content script detects a job page and notifies the background worker (badge) +2. User clicks the extension icon, popup opens +3. Popup asks the background worker to check auth and fetch profiles +4. Popup asks the content script to extract job data from the DOM +5. User clicks "Import" -- popup tells the background worker to call the API +6. Background worker posts to `/api/jobs/import` (or `/api/jobs/extract` for generic pages) +7. Popup shows success/error + +### Authentication + +The extension uses Clerk session cookies -- no API keys needed. The user must be signed in to Shortlist in at least one browser tab. The background service worker makes `fetch()` calls with `credentials: "include"` to attach cookies automatically. + +## Supported Sites + +| Site | Extractor | Status | +|------|-----------|--------| +| Greenhouse (`boards.greenhouse.io`, `jobs.greenhouse.io`) | Structured | MVP | +| Lever (`jobs.lever.co`) | Structured | MVP | +| Any other page | Generic (AI extraction) | MVP | diff --git a/extensions/chrome/icons/icon128.svg b/extensions/chrome/icons/icon128.svg new file mode 100644 index 0000000..d9b5597 --- /dev/null +++ b/extensions/chrome/icons/icon128.svg @@ -0,0 +1,4 @@ + + + S + diff --git a/extensions/chrome/icons/icon16.svg b/extensions/chrome/icons/icon16.svg new file mode 100644 index 0000000..96d6f2b --- /dev/null +++ b/extensions/chrome/icons/icon16.svg @@ -0,0 +1,4 @@ + + + S + diff --git a/extensions/chrome/icons/icon48.svg b/extensions/chrome/icons/icon48.svg new file mode 100644 index 0000000..a228da5 --- /dev/null +++ b/extensions/chrome/icons/icon48.svg @@ -0,0 +1,4 @@ + + + S + From bc19bc546b956124e467a44256138083d4f685da Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:17:12 +0100 Subject: [PATCH 11/64] Add extension package-lock.json for reproducible installs --- extensions/chrome/package-lock.json | 1879 +++++++++++++++++++++++++++ 1 file changed, 1879 insertions(+) create mode 100644 extensions/chrome/package-lock.json diff --git a/extensions/chrome/package-lock.json b/extensions/chrome/package-lock.json new file mode 100644 index 0000000..cac7e40 --- /dev/null +++ b/extensions/chrome/package-lock.json @@ -0,0 +1,1879 @@ +{ + "name": "shortlist-chrome-extension", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shortlist-chrome-extension", + "version": "0.1.0", + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.28", + "@types/chrome": "^0.0.287", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@crxjs/vite-plugin": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.4.0.tgz", + "integrity": "sha512-bDLdq0W2V1SkMQDJjrcYyjK9/uKtdl4joT7GRImcootCjZdKRiRYt+cv9z8tJoU/tK3o1lX48LTqN7JMsk5AQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^4.1.2", + "@webcomponents/custom-elements": "^1.5.0", + "acorn-walk": "^8.2.0", + "convert-source-map": "^1.7.0", + "debug": "^4.3.3", + "es-module-lexer": "^0.10.0", + "fast-glob": "^3.2.11", + "fs-extra": "^10.0.1", + "jsesc": "^3.0.2", + "magic-string": "^0.30.12", + "node-html-parser": "^7.0.2", + "pathe": "^2.0.1", + "picocolors": "^1.1.1", + "react-refresh": "^0.13.0", + "rollup": "2.79.2", + "rxjs": "7.5.7" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chrome": { + "version": "0.0.287", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", + "integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@webcomponents/custom-elements": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz", + "integrity": "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.10.5.tgz", + "integrity": "sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz", + "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz", + "integrity": "sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", + "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + } + } +} From 89afdf488effee5284223f0a5be35960f42b53b1 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:01:59 +0100 Subject: [PATCH 12/64] Add extension status endpoint for auth checks --- src/app/api/extension/status/route.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/app/api/extension/status/route.ts diff --git a/src/app/api/extension/status/route.ts b/src/app/api/extension/status/route.ts new file mode 100644 index 0000000..8712c62 --- /dev/null +++ b/src/app/api/extension/status/route.ts @@ -0,0 +1,9 @@ +import { auth } from "@clerk/nextjs/server"; + +export async function GET() { + const { userId } = await auth(); + if (!userId) { + return Response.json({ authenticated: false }, { status: 401 }); + } + return Response.json({ authenticated: true, userId }); +} From 81713dd89a78a72049e2ff5260b7c46085e3251a Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:02:37 +0100 Subject: [PATCH 13/64] Add extension profiles endpoint for profile listing --- src/app/api/extension/profiles/route.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/app/api/extension/profiles/route.ts diff --git a/src/app/api/extension/profiles/route.ts b/src/app/api/extension/profiles/route.ts new file mode 100644 index 0000000..b4eb7d8 --- /dev/null +++ b/src/app/api/extension/profiles/route.ts @@ -0,0 +1,19 @@ +import { auth } from "@clerk/nextjs/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + const { userId } = await auth(); + if (!userId) return new Response("Unauthorized", { status: 401 }); + + const profiles = await prisma.profile.findMany({ + where: { userId }, + select: { + id: true, + name: true, + isActive: true, + }, + orderBy: { createdAt: "asc" }, + }); + + return Response.json({ profiles }); +} From 6e1cc7b16940bdb279ad51eca2fbfc3b30fad815 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:03:51 +0100 Subject: [PATCH 14/64] Add CORS handling for Chrome extension requests --- src/middleware.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/middleware.ts b/src/middleware.ts index 159955b..f6cf1c5 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,6 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; const isPublicRoute = createRouteMatcher([ "/", @@ -15,15 +16,45 @@ const isPublicRoute = createRouteMatcher([ "/disabled", ]); +const EXTENSION_CORS_PATHS = ["/api/extension/", "/api/jobs/"]; + +function needsExtensionCors(req: NextRequest): string | null { + const origin = req.headers.get("origin"); + if (!origin?.startsWith("chrome-extension://")) return null; + const path = req.nextUrl.pathname; + if (EXTENSION_CORS_PATHS.some((p) => path.startsWith(p))) return origin; + return null; +} + +function withCorsHeaders(response: NextResponse, origin: string): NextResponse { + response.headers.set("Access-Control-Allow-Origin", origin); + response.headers.set("Access-Control-Allow-Credentials", "true"); + response.headers.set("Access-Control-Allow-Headers", "Content-Type"); + response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + return response; +} + export default clerkMiddleware(async (auth, req) => { if (process.env.NODE_ENV === "development") { console.log(`[middleware] ${req.method} ${req.nextUrl.pathname}`); } + const corsOrigin = needsExtensionCors(req); + + if (corsOrigin && req.method === "OPTIONS") { + return withCorsHeaders(new NextResponse(null, { status: 204 }), corsOrigin); + } + if (isPublicRoute(req)) return NextResponse.next(); const { userId } = await auth(); if (!userId) { + if (corsOrigin) { + return withCorsHeaders( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + corsOrigin, + ); + } if (process.env.NODE_ENV === "development") { console.log(`[middleware] Unauthenticated — redirecting to sign-in from: ${req.nextUrl.pathname}`); } @@ -128,6 +159,7 @@ export default clerkMiddleware(async (auth, req) => { headers: { cookie: req.headers.get("cookie") ?? "" }, }).catch(() => {}); } + if (corsOrigin) withCorsHeaders(response, corsOrigin); return response; }); From ec4ebcea708e10c0aa0f9e092a3a0ff038589cd1 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:05:15 +0100 Subject: [PATCH 15/64] Add source and externalId to import schema for dedup --- src/app/api/jobs/import/route.ts | 17 ++++++++++------- src/lib/validations.ts | 6 ++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/app/api/jobs/import/route.ts b/src/app/api/jobs/import/route.ts index 8b18c9f..98c4396 100644 --- a/src/app/api/jobs/import/route.ts +++ b/src/app/api/jobs/import/route.ts @@ -3,7 +3,7 @@ import { headers } from "next/headers"; import { prisma } from "@/lib/prisma"; import { env } from "@/env"; import { importJobSchema } from "@/lib/validations"; -import type { LocationType, JobType } from "@prisma/client"; +import type { LocationType, JobType, ScraperSource } from "@prisma/client"; const URL_RE = /^https?:\/\//i; @@ -35,22 +35,25 @@ export async function POST(req: Request) { salaryMax, currency, skills, + source, + externalId: clientExternalId, } = parsed.data; const profile = await prisma.profile.findFirst({ where: { id: profileId, userId } }); if (!profile) return new Response("Profile not found", { status: 404 }); - // Determine a stable externalId for deduplication + // Determine a stable externalId for deduplication. + // Prefer client-supplied externalId (e.g. from Chrome extension scraping a known source), + // then fall back to URL-based or random ID for manual imports. const isUrl = URL_RE.test(originalInput.trim()); - const externalId = isUrl - ? originalInput.trim() - : (url?.trim() || crypto.randomUUID()); + const externalId = clientExternalId + ?? (isUrl ? originalInput.trim() : (url?.trim() || crypto.randomUUID())); try { const poolEntry = await prisma.jobPool.upsert({ - where: { source_externalId: { source: "CUSTOM", externalId } }, + where: { source_externalId: { source: source as ScraperSource, externalId } }, create: { - source: "CUSTOM", + source: source as ScraperSource, externalId, url: url || "", title, diff --git a/src/lib/validations.ts b/src/lib/validations.ts index 841e786..7404fe0 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -137,6 +137,12 @@ export const importJobSchema = z.object({ salaryMax: z.number().int().positive().nullish(), currency: z.string().max(10).nullish(), skills: z.array(z.string()).optional(), + source: z.enum([ + "LINKEDIN", "GREENHOUSE", "LEVER", "ASHBY", "USAJOBS", "ADZUNA", + "ARBEITNOW", "INDEED", "BERLIN_STARTUP_JOBS", "HONEYPOT", "YC_JOBS", + "NO_FLUFF_JOBS", "CUSTOM", + ]).default("CUSTOM"), + externalId: z.string().optional(), }); // ── Custom job field update ──────────────────────────────────────────────── From 74bda8b4d5bba113e0a6aaab39624ba1a2ccc313 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:03:14 +0100 Subject: [PATCH 16/64] Add LinkedIn DOM extractor for job pages --- .../chrome/src/lib/extractors/linkedin.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 extensions/chrome/src/lib/extractors/linkedin.ts diff --git a/extensions/chrome/src/lib/extractors/linkedin.ts b/extensions/chrome/src/lib/extractors/linkedin.ts new file mode 100644 index 0000000..08a7d0d --- /dev/null +++ b/extensions/chrome/src/lib/extractors/linkedin.ts @@ -0,0 +1,150 @@ +import type { Extractor, ExtractedJob } from "../../types"; +import { inferLocationType, parseSalaryFromText } from "./utils"; + +function parseSalaryRange(text: string | undefined): { + salaryMin: number | null; + salaryMax: number | null; + currency: string | null; +} { + return parseSalaryFromText(text); +} + +function extractLinkedInDate(text: string | null): string | null { + if (!text) return null; + const lower = text.toLowerCase().trim(); + + const match = lower.match(/(\d+)\s+(minute|hour|day|week|month)s?\s+ago/); + if (!match) return null; + + const amount = parseInt(match[1], 10); + const unit = match[2]; + const now = new Date(); + + switch (unit) { + case "minute": + now.setMinutes(now.getMinutes() - amount); + break; + case "hour": + now.setHours(now.getHours() - amount); + break; + case "day": + now.setDate(now.getDate() - amount); + break; + case "week": + now.setDate(now.getDate() - amount * 7); + break; + case "month": + now.setMonth(now.getMonth() - amount); + break; + } + + return now.toISOString(); +} + +function extractLinkedInSkills(): string[] { + const skills: string[] = []; + document + .querySelectorAll(".job-details-skill-match-status-list li span") + .forEach((el) => { + const text = el.textContent?.trim(); + if (text) skills.push(text); + }); + return skills; +} + +export const linkedinExtractor: Extractor = { + matches: /^https?:\/\/(www\.)?linkedin\.com\/jobs\/view\//, + + extract(): ExtractedJob | null { + const title = + document + .querySelector( + ".job-details-jobs-unified-top-card__job-title h1", + ) + ?.textContent?.trim() ?? + document.querySelector(".top-card-layout__title")?.textContent?.trim(); + + const company = + document + .querySelector( + ".job-details-jobs-unified-top-card__company-name a", + ) + ?.textContent?.trim() ?? + document + .querySelector(".topcard__org-name-link") + ?.textContent?.trim(); + + if (!title || !company) return null; + + const location = + document + .querySelector( + ".job-details-jobs-unified-top-card__bullet", + ) + ?.textContent?.trim() ?? + document + .querySelector(".topcard__flavor--bullet") + ?.textContent?.trim() ?? + null; + + const workplaceType = + document + .querySelector( + ".job-details-jobs-unified-top-card__workplace-type", + ) + ?.textContent?.trim() ?? null; + + const locationType = + workplaceType?.toLowerCase().includes("remote") + ? "REMOTE" as const + : workplaceType?.toLowerCase().includes("hybrid") + ? "HYBRID" as const + : workplaceType?.toLowerCase().includes("on-site") + ? "ONSITE" as const + : inferLocationType(location); + + const salaryText = + document + .querySelector( + ".job-details-jobs-unified-top-card__job-insight--highlight span", + ) + ?.textContent?.trim(); + const salary = parseSalaryRange(salaryText); + + const descEl = + document.querySelector(".jobs-description__content") ?? + document.querySelector(".show-more-less-html__markup"); + const description = descEl?.innerHTML ?? ""; + + const postedText = + document + .querySelector( + ".job-details-jobs-unified-top-card__posted-date", + ) + ?.textContent?.trim() ?? null; + const postedAt = extractLinkedInDate(postedText); + + const skills = extractLinkedInSkills(); + + const idMatch = window.location.pathname.match(/\/jobs\/view\/(\d+)/); + const cleanUrl = idMatch + ? `https://www.linkedin.com/jobs/view/${idMatch[1]}/` + : window.location.origin + window.location.pathname; + + return { + title, + company, + description, + location, + locationType, + url: cleanUrl, + postedAt, + jobType: null, + salaryMin: salary.salaryMin, + salaryMax: salary.salaryMax, + currency: salary.currency, + skills, + source: "LINKEDIN", + }; + }, +}; From b7aa6191f92441eb8683742a8849f1f3efc13b6e Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:03:40 +0100 Subject: [PATCH 17/64] Add Ashby DOM extractor for job pages --- extensions/chrome/src/lib/extractors/ashby.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 extensions/chrome/src/lib/extractors/ashby.ts diff --git a/extensions/chrome/src/lib/extractors/ashby.ts b/extensions/chrome/src/lib/extractors/ashby.ts new file mode 100644 index 0000000..d021649 --- /dev/null +++ b/extensions/chrome/src/lib/extractors/ashby.ts @@ -0,0 +1,69 @@ +import type { Extractor, ExtractedJob } from "../../types"; +import { inferLocationType, parseSalaryFromText } from "./utils"; + +export const ashbyExtractor: Extractor = { + matches: /^https?:\/\/jobs\.ashbyhq\.com\/[^/]+\/[a-f0-9-]+/, + + extract(): ExtractedJob | null { + const title = + document + .querySelector("h1.ashby-job-posting-brief-title") + ?.textContent?.trim() ?? + document + .querySelector("[data-testid='job-title']") + ?.textContent?.trim(); + + const company = + document + .querySelector(".ashby-job-posting-brief-company-name") + ?.textContent?.trim() ?? + document + .querySelector("[data-testid='company-name']") + ?.textContent?.trim() ?? + document + .querySelector('meta[property="og:site_name"]') + ?.getAttribute("content") ?? + ""; + + if (!title || !company) return null; + + const location = + document + .querySelector(".ashby-job-posting-brief-location") + ?.textContent?.trim() ?? + document + .querySelector("[data-testid='job-location']") + ?.textContent?.trim() ?? + null; + + const compensationText = + document + .querySelector(".ashby-job-posting-compensation") + ?.textContent?.trim() ?? + document + .querySelector("[data-testid='compensation']") + ?.textContent?.trim(); + const salary = parseSalaryFromText(compensationText); + + const descEl = + document.querySelector(".ashby-job-posting-description") ?? + document.querySelector("[data-testid='job-description']"); + const description = descEl?.innerHTML ?? ""; + + return { + title, + company, + description, + location, + locationType: inferLocationType(location), + url: window.location.href, + postedAt: null, + jobType: null, + salaryMin: salary.salaryMin, + salaryMax: salary.salaryMax, + currency: salary.currency, + skills: [], + source: "ASHBY", + }; + }, +}; From d199e10eadba09d8f705586598bbf78e3e5f13e5 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:04:08 +0100 Subject: [PATCH 18/64] Add USAJobs DOM extractor for federal job listings --- .../chrome/src/lib/extractors/usajobs.ts | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 extensions/chrome/src/lib/extractors/usajobs.ts diff --git a/extensions/chrome/src/lib/extractors/usajobs.ts b/extensions/chrome/src/lib/extractors/usajobs.ts new file mode 100644 index 0000000..73d53d3 --- /dev/null +++ b/extensions/chrome/src/lib/extractors/usajobs.ts @@ -0,0 +1,143 @@ +import type { Extractor, ExtractedJob } from "../../types"; + +function parseUsajobsSalary(text: string | null): { + salaryMin: number | null; + salaryMax: number | null; +} { + if (!text) return { salaryMin: null, salaryMax: null }; + + const match = text.match( + /\$\s*([\d,]+(?:\.\d+)?)\s*[-\u2013to]+\s*\$\s*([\d,]+(?:\.\d+)?)/, + ); + if (!match) return { salaryMin: null, salaryMax: null }; + + return { + salaryMin: parseFloat(match[1].replace(/,/g, "")), + salaryMax: parseFloat(match[2].replace(/,/g, "")), + }; +} + +function parseAppointmentType( + text: string | null, +): "FULL_TIME" | "CONTRACT" | "INTERNSHIP" | null { + if (!text) return null; + const lower = text.toLowerCase(); + if (lower.includes("intern")) return "INTERNSHIP"; + if (lower.includes("temporary") || lower.includes("term")) return "CONTRACT"; + if (lower.includes("permanent")) return "FULL_TIME"; + return null; +} + +function parseUsajobsDate(text: string | null): string | null { + if (!text) return null; + const match = text.match(/(\d{2})\/(\d{2})\/(\d{4})/); + if (!match) return null; + const [, month, day, year] = match; + const date = new Date(`${year}-${month}-${day}T00:00:00Z`); + return isNaN(date.getTime()) ? null : date.toISOString(); +} + +function extractUsajobsSkills(): string[] { + const skills: string[] = []; + const qualSection = + document.querySelector("#qualifications") ?? + document.querySelector(".usajobs-joa-qualifications"); + if (!qualSection) return skills; + + qualSection.querySelectorAll("li").forEach((el) => { + const text = el.textContent?.trim(); + if (text && text.length < 100) skills.push(text); + }); + return skills.slice(0, 20); +} + +export const usajobsExtractor: Extractor = { + matches: /^https?:\/\/(www\.)?usajobs\.gov\/job\//, + + extract(): ExtractedJob | null { + let title = + document.querySelector("#job-title")?.textContent?.trim() ?? + document + .querySelector(".usajobs-joa-banner__title h1") + ?.textContent?.trim(); + + const company = + document.querySelector("#agency-name")?.textContent?.trim() ?? + document + .querySelector(".usajobs-joa-banner__agency") + ?.textContent?.trim() ?? + "U.S. Government"; + + if (!title) return null; + + const grade = + document.querySelector("#grade")?.textContent?.trim() ?? + document + .querySelector(".usajobs-joa-summary__grade") + ?.textContent?.trim(); + if (grade) { + const gsMatch = grade.match(/GS[- ]?\d+/i); + if (gsMatch) title = `${title} (${gsMatch[0].toUpperCase()})`; + } + + const location = + document.querySelector("#locations")?.textContent?.trim() ?? + document + .querySelector(".usajobs-joa-locations") + ?.textContent?.trim() ?? + null; + + const salaryText = + document.querySelector("#salary-range")?.textContent?.trim() ?? + document + .querySelector(".usajobs-joa-summary__salary") + ?.textContent?.trim(); + const salary = parseUsajobsSalary(salaryText ?? null); + + const telework = + document + .querySelector("#telework-eligible, .usajobs-joa-summary__telework") + ?.textContent?.trim() ?? null; + const locationType = telework?.toLowerCase().includes("yes") + ? ("REMOTE" as const) + : null; + + const appointmentText = + document + .querySelector( + "#appointment-type, .usajobs-joa-summary__appointment-type", + ) + ?.textContent?.trim() ?? null; + const jobType = parseAppointmentType(appointmentText); + + const descEl = + document.querySelector("#duties") ?? + document.querySelector(".usajobs-joa-duties") ?? + document.querySelector("#qualifications"); + const description = descEl?.innerHTML ?? ""; + + const openDateText = + document + .querySelector("#opening-date, .usajobs-joa-summary__opening-date") + ?.textContent?.trim() ?? null; + const postedAt = parseUsajobsDate(openDateText); + + const skills = extractUsajobsSkills(); + + return { + title, + company, + description, + location, + locationType, + url: window.location.href, + postedAt, + jobType, + salaryMin: salary.salaryMin, + salaryMax: salary.salaryMax, + currency: "USD", + skills, + source: "CUSTOM", + }; + }, +}; From 4b2f69e700d5e427d32fea5cbb1ba8aface59a55 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:04:26 +0100 Subject: [PATCH 19/64] Add Indeed DOM extractor for job pages --- .../chrome/src/lib/extractors/indeed.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 extensions/chrome/src/lib/extractors/indeed.ts diff --git a/extensions/chrome/src/lib/extractors/indeed.ts b/extensions/chrome/src/lib/extractors/indeed.ts new file mode 100644 index 0000000..6d9c298 --- /dev/null +++ b/extensions/chrome/src/lib/extractors/indeed.ts @@ -0,0 +1,85 @@ +import type { Extractor, ExtractedJob } from "../../types"; +import { inferLocationType, parseJobType, parseSalaryFromText } from "./utils"; + +function cleanIndeedUrl(): string { + const url = new URL(window.location.href); + const jk = url.searchParams.get("jk"); + if (jk) { + return `${url.origin}${url.pathname}?jk=${jk}`; + } + return `${url.origin}${url.pathname}`; +} + +export const indeedExtractor: Extractor = { + matches: /^https?:\/\/(www\.)?indeed\.com\/viewjob/, + + extract(): ExtractedJob | null { + const title = + document + .querySelector(".jobsearch-JobInfoHeader-title") + ?.textContent?.trim() ?? + document + .querySelector( + "[data-testid='jobsearch-JobInfoHeader-title'] span", + ) + ?.textContent?.trim(); + + const company = + document + .querySelector("[data-testid='inlineHeader-companyName'] a") + ?.textContent?.trim() ?? + document + .querySelector(".jobsearch-CompanyInfoContainer a") + ?.textContent?.trim(); + + if (!title || !company) return null; + + const location = + document + .querySelector("[data-testid='inlineHeader-companyLocation']") + ?.textContent?.trim() ?? + document + .querySelector( + ".jobsearch-CompanyInfoContainer .companyLocation", + ) + ?.textContent?.trim() ?? + null; + + const salaryText = + document + .querySelector("#salaryInfoAndJobType span") + ?.textContent?.trim() ?? + document + .querySelector( + "[data-testid='attribute_snippet_testid'] span", + ) + ?.textContent?.trim(); + const salary = parseSalaryFromText(salaryText); + + const descEl = + document.querySelector("#jobDescriptionText") ?? + document.querySelector(".jobsearch-JobComponent-description"); + const description = descEl?.innerHTML ?? ""; + + const jobTypeText = + document + .querySelector(".jobsearch-JobMetadataHeader-item") + ?.textContent?.trim() ?? null; + + return { + title, + company, + description, + location, + locationType: inferLocationType(location), + url: cleanIndeedUrl(), + postedAt: null, + jobType: parseJobType(jobTypeText), + salaryMin: salary.salaryMin, + salaryMax: salary.salaryMax, + currency: salary.currency, + skills: [], + source: "INDEED", + }; + }, +}; From d0b107d4cddf7413f391b0ff2d3e795f03d58b6f Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:04:43 +0100 Subject: [PATCH 20/64] Add Glassdoor DOM extractor for job pages --- .../chrome/src/lib/extractors/glassdoor.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 extensions/chrome/src/lib/extractors/glassdoor.ts diff --git a/extensions/chrome/src/lib/extractors/glassdoor.ts b/extensions/chrome/src/lib/extractors/glassdoor.ts new file mode 100644 index 0000000..b330de9 --- /dev/null +++ b/extensions/chrome/src/lib/extractors/glassdoor.ts @@ -0,0 +1,57 @@ +import type { Extractor, ExtractedJob } from "../../types"; +import { inferLocationType, parseSalaryFromText } from "./utils"; + +export const glassdoorExtractor: Extractor = { + matches: /^https?:\/\/(www\.)?glassdoor\.(com|co\.\w+)\/job-listing\//, + + extract(): ExtractedJob | null { + const title = + document + .querySelector("[data-test='job-title']") + ?.textContent?.trim() ?? + document.querySelector(".e1tk4kwz5")?.textContent?.trim(); + + const company = + document + .querySelector("[data-test='employer-name']") + ?.textContent?.trim() ?? + document.querySelector(".e1tk4kwz4")?.textContent?.trim(); + + if (!title || !company) return null; + + const location = + document + .querySelector("[data-test='location']") + ?.textContent?.trim() ?? + document.querySelector(".e1tk4kwz1")?.textContent?.trim() ?? + null; + + const salaryText = + document + .querySelector("[data-test='detailSalary']") + ?.textContent?.trim() ?? + document.querySelector(".e1wijj240")?.textContent?.trim(); + const salary = parseSalaryFromText(salaryText); + + const descEl = + document.querySelector(".jobDescriptionContent") ?? + document.querySelector("[data-test='job-description']"); + const description = descEl?.innerHTML ?? ""; + + return { + title, + company, + description, + location, + locationType: inferLocationType(location), + url: window.location.href, + postedAt: null, + jobType: null, + salaryMin: salary.salaryMin, + salaryMax: salary.salaryMax, + currency: salary.currency, + skills: [], + source: "CUSTOM", + }; + }, +}; From eadb3ae3d4ba06617a03e3eff7e45f6294f823fe Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:05:17 +0100 Subject: [PATCH 21/64] Register all extractors and update manifest patterns --- extensions/chrome/src/lib/extractors/index.ts | 15 ++++++++++++++- extensions/chrome/src/manifest.json | 8 +++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/extensions/chrome/src/lib/extractors/index.ts b/extensions/chrome/src/lib/extractors/index.ts index 29fb3bd..61ca5ce 100644 --- a/extensions/chrome/src/lib/extractors/index.ts +++ b/extensions/chrome/src/lib/extractors/index.ts @@ -1,9 +1,22 @@ +import { linkedinExtractor } from "./linkedin"; import { greenhouseExtractor } from "./greenhouse"; import { leverExtractor } from "./lever"; +import { ashbyExtractor } from "./ashby"; +import { usajobsExtractor } from "./usajobs"; +import { indeedExtractor } from "./indeed"; +import { glassdoorExtractor } from "./glassdoor"; import { collectPageContent } from "./generic"; import type { Extractor, ExtractionResult } from "../../types"; -const extractors: Extractor[] = [greenhouseExtractor, leverExtractor]; +const extractors: Extractor[] = [ + linkedinExtractor, + greenhouseExtractor, + leverExtractor, + ashbyExtractor, + usajobsExtractor, + indeedExtractor, + glassdoorExtractor, +]; /** * Attempt extraction using a site-specific extractor first, then fall back diff --git a/extensions/chrome/src/manifest.json b/extensions/chrome/src/manifest.json index 80bda0c..10b847c 100644 --- a/extensions/chrome/src/manifest.json +++ b/extensions/chrome/src/manifest.json @@ -19,9 +19,15 @@ "content_scripts": [ { "matches": [ + "*://*.linkedin.com/jobs/view/*", "*://boards.greenhouse.io/*/jobs/*", "*://jobs.greenhouse.io/*/jobs/*", - "*://jobs.lever.co/*/*" + "*://jobs.lever.co/*/*", + "*://jobs.ashbyhq.com/*/*", + "*://*.usajobs.gov/job/*", + "*://*.indeed.com/viewjob*", + "*://*.glassdoor.com/job-listing/*", + "*://*.glassdoor.co.*/job-listing/*" ], "js": ["src/content/content.ts"], "run_at": "document_idle" From 526a2ac4ace104516c97ef4f739a5951c6bdbb78 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:13:22 +0100 Subject: [PATCH 22/64] Add externalId extraction to all site-specific extractors --- extensions/chrome/src/lib/extractors/ashby.ts | 5 +++++ extensions/chrome/src/lib/extractors/glassdoor.ts | 1 + extensions/chrome/src/lib/extractors/greenhouse.ts | 3 +++ extensions/chrome/src/lib/extractors/indeed.ts | 3 +++ extensions/chrome/src/lib/extractors/lever.ts | 5 +++++ extensions/chrome/src/lib/extractors/linkedin.ts | 1 + extensions/chrome/src/lib/extractors/usajobs.ts | 5 ++++- extensions/chrome/src/types.ts | 2 ++ 8 files changed, 24 insertions(+), 1 deletion(-) diff --git a/extensions/chrome/src/lib/extractors/ashby.ts b/extensions/chrome/src/lib/extractors/ashby.ts index d021649..02b9dca 100644 --- a/extensions/chrome/src/lib/extractors/ashby.ts +++ b/extensions/chrome/src/lib/extractors/ashby.ts @@ -50,6 +50,10 @@ export const ashbyExtractor: Extractor = { document.querySelector("[data-testid='job-description']"); const description = descEl?.innerHTML ?? ""; + const uuidMatch = window.location.pathname.match( + /\/([a-f0-9-]{36})/, + ); + return { title, company, @@ -63,6 +67,7 @@ export const ashbyExtractor: Extractor = { salaryMax: salary.salaryMax, currency: salary.currency, skills: [], + externalId: uuidMatch?.[1] ?? null, source: "ASHBY", }; }, diff --git a/extensions/chrome/src/lib/extractors/glassdoor.ts b/extensions/chrome/src/lib/extractors/glassdoor.ts index b330de9..db6e8a1 100644 --- a/extensions/chrome/src/lib/extractors/glassdoor.ts +++ b/extensions/chrome/src/lib/extractors/glassdoor.ts @@ -51,6 +51,7 @@ export const glassdoorExtractor: Extractor = { salaryMax: salary.salaryMax, currency: salary.currency, skills: [], + externalId: null, source: "CUSTOM", }; }, diff --git a/extensions/chrome/src/lib/extractors/greenhouse.ts b/extensions/chrome/src/lib/extractors/greenhouse.ts index 9e2c0a3..0aa9438 100644 --- a/extensions/chrome/src/lib/extractors/greenhouse.ts +++ b/extensions/chrome/src/lib/extractors/greenhouse.ts @@ -31,6 +31,8 @@ export const greenhouseExtractor: Extractor = { const descEl = document.querySelector("#content, .body--content"); const description = descEl?.innerHTML ?? ""; + const idMatch = window.location.pathname.match(/\/jobs\/(\d+)/); + return { title, company, @@ -44,6 +46,7 @@ export const greenhouseExtractor: Extractor = { salaryMax: null, currency: null, skills: [], + externalId: idMatch?.[1] ?? null, source: "GREENHOUSE", }; }, diff --git a/extensions/chrome/src/lib/extractors/indeed.ts b/extensions/chrome/src/lib/extractors/indeed.ts index 6d9c298..643cfc6 100644 --- a/extensions/chrome/src/lib/extractors/indeed.ts +++ b/extensions/chrome/src/lib/extractors/indeed.ts @@ -66,6 +66,8 @@ export const indeedExtractor: Extractor = { .querySelector(".jobsearch-JobMetadataHeader-item") ?.textContent?.trim() ?? null; + const jk = new URL(window.location.href).searchParams.get("jk"); + return { title, company, @@ -79,6 +81,7 @@ export const indeedExtractor: Extractor = { salaryMax: salary.salaryMax, currency: salary.currency, skills: [], + externalId: jk ?? null, source: "INDEED", }; }, diff --git a/extensions/chrome/src/lib/extractors/lever.ts b/extensions/chrome/src/lib/extractors/lever.ts index ce775c2..7f36dbe 100644 --- a/extensions/chrome/src/lib/extractors/lever.ts +++ b/extensions/chrome/src/lib/extractors/lever.ts @@ -41,6 +41,10 @@ export const leverExtractor: Extractor = { ); const description = descEl?.innerHTML ?? ""; + const uuidMatch = window.location.pathname.match( + /\/([a-f0-9-]{36})/, + ); + return { title, company, @@ -54,6 +58,7 @@ export const leverExtractor: Extractor = { salaryMax: null, currency: null, skills: [], + externalId: uuidMatch?.[1] ?? null, source: "LEVER", }; }, diff --git a/extensions/chrome/src/lib/extractors/linkedin.ts b/extensions/chrome/src/lib/extractors/linkedin.ts index 08a7d0d..da9ca5e 100644 --- a/extensions/chrome/src/lib/extractors/linkedin.ts +++ b/extensions/chrome/src/lib/extractors/linkedin.ts @@ -144,6 +144,7 @@ export const linkedinExtractor: Extractor = { salaryMax: salary.salaryMax, currency: salary.currency, skills, + externalId: idMatch?.[1] ?? null, source: "LINKEDIN", }; }, diff --git a/extensions/chrome/src/lib/extractors/usajobs.ts b/extensions/chrome/src/lib/extractors/usajobs.ts index 73d53d3..8e0cde0 100644 --- a/extensions/chrome/src/lib/extractors/usajobs.ts +++ b/extensions/chrome/src/lib/extractors/usajobs.ts @@ -124,6 +124,8 @@ export const usajobsExtractor: Extractor = { const skills = extractUsajobsSkills(); + const idMatch = window.location.pathname.match(/\/job\/(\d+)/); + return { title, company, @@ -137,7 +139,8 @@ export const usajobsExtractor: Extractor = { salaryMax: salary.salaryMax, currency: "USD", skills, - source: "CUSTOM", + externalId: idMatch?.[1] ?? null, + source: "USAJOBS", }; }, }; diff --git a/extensions/chrome/src/types.ts b/extensions/chrome/src/types.ts index 3408db6..b064134 100644 --- a/extensions/chrome/src/types.ts +++ b/extensions/chrome/src/types.ts @@ -19,12 +19,14 @@ export interface ExtractedJob { salaryMax: number | null; currency: string | null; skills: string[]; + externalId: string | null; source: | "LINKEDIN" | "GREENHOUSE" | "LEVER" | "ASHBY" | "INDEED" + | "USAJOBS" | "CUSTOM"; } From 5c87cf5561f3bef6e0d7eb375b1ecd25f2c18ff7 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:13:43 +0100 Subject: [PATCH 23/64] Wire source and externalId through extension API client --- extensions/chrome/src/background/service-worker.ts | 2 ++ extensions/chrome/src/lib/api.ts | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/extensions/chrome/src/background/service-worker.ts b/extensions/chrome/src/background/service-worker.ts index 567504e..3d5dd72 100644 --- a/extensions/chrome/src/background/service-worker.ts +++ b/extensions/chrome/src/background/service-worker.ts @@ -104,6 +104,8 @@ async function handleImportJob( salaryMax: job.salaryMax, currency: job.currency, skills: job.skills, + source: job.source, + externalId: job.externalId, }); sendResponse({ diff --git a/extensions/chrome/src/lib/api.ts b/extensions/chrome/src/lib/api.ts index ead2ea4..75422f2 100644 --- a/extensions/chrome/src/lib/api.ts +++ b/extensions/chrome/src/lib/api.ts @@ -119,10 +119,14 @@ export function importJob(payload: { salaryMax?: number | null; currency?: string | null; skills?: string[]; + source?: string; + externalId?: string | null; }) { + const { externalId, ...rest } = payload; + const body = externalId != null ? { ...rest, externalId } : rest; return apiCall("/api/jobs/import", { method: "POST", - body: JSON.stringify(payload), + body: JSON.stringify(body), }); } From 4c85c3d13ee4c266c9fac05b93abe8eaf976cc54 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 01:25:52 +0100 Subject: [PATCH 24/64] Add Alt+Shift+S keyboard shortcut to open extension popup --- extensions/chrome/src/manifest.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/extensions/chrome/src/manifest.json b/extensions/chrome/src/manifest.json index 10b847c..d4ec78f 100644 --- a/extensions/chrome/src/manifest.json +++ b/extensions/chrome/src/manifest.json @@ -37,6 +37,15 @@ "service_worker": "src/background/service-worker.ts", "type": "module" }, + "commands": { + "_execute_action": { + "suggested_key": { + "default": "Alt+Shift+S", + "mac": "Alt+Shift+S" + }, + "description": "Import current page as a job listing" + } + }, "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", From f34d40049156a092e032247b5aa5cf0e28bceb73 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:02:06 +0100 Subject: [PATCH 25/64] Add import history storage to background service worker --- .../chrome/src/background/service-worker.ts | 65 +++++++++++++++++-- extensions/chrome/src/types.ts | 20 +++++- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/extensions/chrome/src/background/service-worker.ts b/extensions/chrome/src/background/service-worker.ts index 3d5dd72..a60f897 100644 --- a/extensions/chrome/src/background/service-worker.ts +++ b/extensions/chrome/src/background/service-worker.ts @@ -4,13 +4,28 @@ import { importJob, extractJob, } from "../lib/api"; -import type { Message, ExtractedJob } from "../types"; +import type { Message, ExtractedJob, ImportRecord } from "../types"; /** * Background service worker — coordinates between popup, content script, * and the Shortlist API. */ +// ── Import history ────────────────────────────────────────────────────── + +const MAX_HISTORY = 5; + +async function saveImportRecord(record: ImportRecord): Promise { + const { importHistory = [] } = await chrome.storage.local.get("importHistory"); + const updated = [record, ...importHistory].slice(0, MAX_HISTORY); + await chrome.storage.local.set({ importHistory: updated }); +} + +async function getImportHistory(): Promise { + const { importHistory = [] } = await chrome.storage.local.get("importHistory"); + return importHistory; +} + // ── Badge management ───────────────────────────────────────────────────── // When a content script detects a job page, show a badge on the icon @@ -47,17 +62,22 @@ chrome.runtime.onMessage.addListener( return true; case "IMPORT_JOB": - handleImportJob(message.profileId, message.job, sendResponse); + handleImportJob(message.profileId, message.profileName, message.job, sendResponse); return true; case "EXTRACT_AND_IMPORT": handleExtractAndImport( message.profileId, + message.profileName, message.html, message.url, sendResponse, ); return true; + + case "GET_IMPORT_HISTORY": + handleGetImportHistory(sendResponse); + return true; } }, ); @@ -86,6 +106,7 @@ async function handleGetProfiles( async function handleImportJob( profileId: string, + profileName: string, job: ExtractedJob, sendResponse: (response: unknown) => void, ): Promise { @@ -108,21 +129,34 @@ async function handleImportJob( externalId: job.externalId, }); + const jobId = result.data?.job?.id; + + if (result.ok && jobId) { + await saveImportRecord({ + jobId, + title: job.title, + company: job.company, + source: job.source, + importedAt: new Date().toISOString(), + profileName, + }); + } + sendResponse({ type: "IMPORT_RESULT", ok: result.ok, - jobId: result.data?.job?.id, + jobId, error: result.error, }); } async function handleExtractAndImport( profileId: string, + profileName: string, html: string, url: string, sendResponse: (response: unknown) => void, ): Promise { - // Step 1: AI extraction const extractResult = await extractJob({ input: html, profileId }); if (!extractResult.ok || !extractResult.data) { sendResponse({ @@ -135,7 +169,6 @@ async function handleExtractAndImport( const extracted = extractResult.data; - // Step 2: Import the extracted job const importResult = await importJob({ profileId, originalInput: url, @@ -153,10 +186,30 @@ async function handleExtractAndImport( skills: extracted.skills, }); + const jobId = importResult.data?.job?.id; + + if (importResult.ok && jobId) { + await saveImportRecord({ + jobId, + title: extracted.title, + company: extracted.company, + source: "CUSTOM", + importedAt: new Date().toISOString(), + profileName, + }); + } + sendResponse({ type: "EXTRACT_AND_IMPORT_RESULT", ok: importResult.ok, - jobId: importResult.data?.job?.id, + jobId, error: importResult.error, }); } + +async function handleGetImportHistory( + sendResponse: (response: unknown) => void, +): Promise { + const history = await getImportHistory(); + sendResponse({ type: "IMPORT_HISTORY", history }); +} diff --git a/extensions/chrome/src/types.ts b/extensions/chrome/src/types.ts index b064134..b9b2db1 100644 --- a/extensions/chrome/src/types.ts +++ b/extensions/chrome/src/types.ts @@ -1,3 +1,14 @@ +// ── Import history ──────────────────────────────────────────────────────── + +export interface ImportRecord { + jobId: string; + title: string; + company: string; + source: string; + importedAt: string; + profileName: string; +} + // ── Extracted job data ──────────────────────────────────────────────────── export interface ExtractedJob { @@ -74,13 +85,14 @@ export type Message = | { type: "EXTRACTED"; result: ExtractionResult } // Import (structured job from extractor) - | { type: "IMPORT_JOB"; profileId: string; job: ExtractedJob } + | { type: "IMPORT_JOB"; profileId: string; profileName: string; job: ExtractedJob } | { type: "IMPORT_RESULT"; ok: boolean; jobId?: string; error?: string } // Import (generic fallback — needs AI extraction first) | { type: "EXTRACT_AND_IMPORT"; profileId: string; + profileName: string; html: string; url: string; } @@ -89,4 +101,8 @@ export type Message = ok: boolean; jobId?: string; error?: string; - }; + } + + // Import history + | { type: "GET_IMPORT_HISTORY" } + | { type: "IMPORT_HISTORY"; history: ImportRecord[] }; From 615bd390ae37d0c4c249c19a4cc1771ab6b3533b Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:04:28 +0100 Subject: [PATCH 26/64] Show recent imports in extension popup --- extensions/chrome/src/popup/popup.css | 57 ++++++++++++++++ extensions/chrome/src/popup/popup.tsx | 93 ++++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/extensions/chrome/src/popup/popup.css b/extensions/chrome/src/popup/popup.css index 4d2af05..59842c6 100644 --- a/extensions/chrome/src/popup/popup.css +++ b/extensions/chrome/src/popup/popup.css @@ -182,3 +182,60 @@ body { padding: 24px 16px; color: var(--text-muted); } + +/* ── Import history ───────────────────────────────────────────────────── */ +.import-history { + border-top: 1px solid var(--border); + padding-top: 10px; +} + +.import-history-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; + margin-bottom: 6px; +} + +.import-history-list { + list-style: none; +} + +.import-history-item { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 4px 0; + border: none; + background: none; + cursor: pointer; + text-align: left; + font-family: inherit; +} + +.import-history-item:hover .import-history-title { + color: var(--primary); +} + +.import-history-title { + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.import-history-company { + color: var(--text-muted); +} + +.import-history-time { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} diff --git a/extensions/chrome/src/popup/popup.tsx b/extensions/chrome/src/popup/popup.tsx index e07ae63..774dca8 100644 --- a/extensions/chrome/src/popup/popup.tsx +++ b/extensions/chrome/src/popup/popup.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; -import type { ExtractionResult, ExtractedJob, Message } from "../types"; +import type { ExtractionResult, ExtractedJob, ImportRecord, Message } from "../types"; // ── Types ──────────────────────────────────────────────────────────────── @@ -24,6 +24,18 @@ function sendTabMessage(tabId: number, message: Message): Promise { const SHORTLIST_URL = "https://shortlist.johnmoorman.com"; +function timeAgo(isoDate: string): string { + const seconds = Math.floor((Date.now() - new Date(isoDate).getTime()) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days === 1) return "yesterday"; + return `${days}d ago`; +} + // ── App ────────────────────────────────────────────────────────────────── function Popup() { @@ -34,6 +46,7 @@ function Popup() { const [status, setStatus] = useState("loading"); const [errorMessage, setErrorMessage] = useState(""); const [importedJobId, setImportedJobId] = useState(null); + const [importHistory, setImportHistory] = useState([]); // Initialize: check auth, fetch profiles, extract from current tab useEffect(() => { @@ -51,7 +64,14 @@ function Popup() { } setAuthenticated(true); - // 2. Fetch profiles + // 2. Load import history + const historyResult = await sendMessage<{ + type: string; + history: ImportRecord[]; + }>({ type: "GET_IMPORT_HISTORY" }); + setImportHistory(historyResult.history ?? []); + + // 3. Fetch profiles const profilesResult = await sendMessage<{ type: string; profiles: Profile[]; @@ -65,7 +85,7 @@ function Popup() { if (active) setSelectedProfileId(active.id); else if (profs.length > 0) setSelectedProfileId(profs[0].id); - // 3. Extract from the current tab + // 4. Extract from the current tab setStatus("extracting"); try { const [tab] = await chrome.tabs.query({ @@ -94,6 +114,13 @@ function Popup() { // ── Import handler ─────────────────────────────────────────────────── + const selectedProfileName = + profiles.find((p) => p.id === selectedProfileId)?.name ?? "Unknown"; + + function addToHistory(record: ImportRecord) { + setImportHistory((prev) => [record, ...prev].slice(0, 5)); + } + async function handleImport() { if (!selectedProfileId || !extraction) return; @@ -110,12 +137,23 @@ function Popup() { }>({ type: "IMPORT_JOB", profileId: selectedProfileId, + profileName: selectedProfileName, job: extraction.data, }); if (result.ok) { setStatus("success"); setImportedJobId(result.jobId ?? null); + if (result.jobId) { + addToHistory({ + jobId: result.jobId, + title: extraction.data.title, + company: extraction.data.company, + source: extraction.data.source, + importedAt: new Date().toISOString(), + profileName: selectedProfileName, + }); + } } else { setStatus("error"); setErrorMessage(result.error ?? "Import failed"); @@ -129,6 +167,7 @@ function Popup() { }>({ type: "EXTRACT_AND_IMPORT", profileId: selectedProfileId, + profileName: selectedProfileName, html: extraction.html, url: extraction.url, }); @@ -136,6 +175,16 @@ function Popup() { if (result.ok) { setStatus("success"); setImportedJobId(result.jobId ?? null); + if (result.jobId) { + addToHistory({ + jobId: result.jobId, + title: extraction.title || "Imported job", + company: "Via AI extraction", + source: "CUSTOM", + importedAt: new Date().toISOString(), + profileName: selectedProfileName, + }); + } } else { setStatus("error"); setErrorMessage(result.error ?? "Import failed"); @@ -205,6 +254,7 @@ function Popup() { > Open Dashboard + ); } @@ -228,6 +278,7 @@ function Popup() { > Open Shortlist + ); } @@ -290,6 +341,8 @@ function Popup() { {status === "error" && (
{errorMessage}
)} + + ); } @@ -314,6 +367,40 @@ function JobPreview({ job }: { job: ExtractedJob }) { ); } +function ImportHistory({ history }: { history: ImportRecord[] }) { + if (history.length === 0) return null; + + return ( +
+
Recent imports
+
    + {history.map((record) => ( +
  • + +
  • + ))} +
+
+ ); +} + // ── Mount ──────────────────────────────────────────────────────────────── const root = document.getElementById("root"); From a97879bc4a57a5c0430990fe52632c4313b2e1da Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:01:13 +0100 Subject: [PATCH 27/64] Add React Vite plugin and generate PNG icons for build --- extensions/chrome/icons/icon128.png | Bin 0 -> 1716 bytes extensions/chrome/icons/icon16.png | Bin 0 -> 441 bytes extensions/chrome/icons/icon48.png | Bin 0 -> 856 bytes extensions/chrome/package.json | 1 + extensions/chrome/vite.config.ts | 3 ++- 5 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 extensions/chrome/icons/icon128.png create mode 100644 extensions/chrome/icons/icon16.png create mode 100644 extensions/chrome/icons/icon48.png diff --git a/extensions/chrome/icons/icon128.png b/extensions/chrome/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..c14ef605237ae1dfe354aea324d63a413b60c8e7 GIT binary patch literal 1716 zcmV;l221&gP)C0000WV@Og>004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00060P)t-s|NsB}{r}wD{Is?B zm6!E>fAei{@@8rAV`lMvfb+Dr_}tz6?CkxjtoL$t@^g0b@$vqjq4uDn_VV)n#m4%^ z$NKyG|DK@sprQ8o`2LNL^^TDBj*<0?j`g9V_wDZeXKL}Qt@r-^|8#cp-QN7Qw)mEq z_I`l#ZgKK&a`LgW_{hon)Ybdm-~8z4{p#!e;o|(*+56AX`@FsRfP?gKbMmRH_ut_B z+S>elfAhV*`T6?)>FNEpxcGW~^PHac_4WRno%U#J@nvZ7!NU2ZruS@b^1#9Qla=*< zg7oh0{o337qontloA&1B{jsw6l9cs=h4g@e^ofh~zrgw6;Qajj|8#frX>0M6mi5KQ z`jM0M&d>XGc=NNg_{z)radh(0()-KI`oF>XWM}cIs`sp|_=t-1y1V)I_Wsw``*(Ts z&CdJL(ffjg^yumRdwuhGd-HgD^ZNS#frIqz?fvED{e_40*xCH1r}xv;`)Y0Sb$9c^ z!}`q4`lqS)e}VJ4y7`5O^rNNshKcmBviSM={>jSv+1mW$iv6u^U~A%Yi{!I@BXW-_tw|@ z)zuD`ALa~ixifLsP*nEr?fC5ke3P1t;&j7o+xMb7jExOXOaz{l*rO3UtSU}a* zYA$4dyDfh^GeA{MtzeH~$Ie|$0Cn{dyDVIJL!;aPXA@epnq>w!9c$;CcFPQCSvw!K z&>fB%;94gi?U5H?-n&kIE9C)c0sGcF0c|n^s(ESc9i3h7ZddyL1D@qiQfZg@lz<*G zzSg+5vrNYMJHiMi%9~)(_K19gp@iFP0T@s5H%q9Vu302Om&|*IKQ!jB z24H-bH$B{A55Vqe0&pJyz}vFTKmg(hKp6zUOaLB00PF;S?shmGz*P}pM>BZX}-BiRJ_>f*f~BsaO((9eBv}Ri?yik6`ME3Gd(U@>{2#a`PsbN&j#ji zJ?45sx-5F8FMiqrD6Dk3%tj>h&tHO#uDpCE^Fq&XUc^=(XR#LgY_gpRv&~nyVBxnK ze*+LaG{w3e(sTd-02y>eSaefwW^{L9a%BK_cXuvnZfkR6VQ^(GZ*pgw?mQX*0000< KMNUMnLSTZU3XS0a literal 0 HcmV?d00001 diff --git a/extensions/chrome/icons/icon16.png b/extensions/chrome/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..9e477728fd47c07ac387659f3db2ac465b63b766 GIT binary patch literal 441 zcmV;q0Y?6bP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw0001fP)t-s`}_Z^toL$t@?&Q4 ztF8B}t@v_u^6TsU^78)L+x&`*^^A`7|NsBg)%&fl_|eh(e}eR$p!S)Y_Wk|;#Krn^ zcJg(2^T){g{{H{;^!|H&^N*4Bv$Xi7r}wtE_?4ISu(J53sQ35x{>jSvXKL|eXYp@x z@@8rAbawKruK3oRGJF6400Cl4M??&Pvr%IJ000SaNLh0L01m?d01m?e$8V@)00007 zbV*G`2kHYD3pp-#0r+qL003}FL_t&-(_N3-4uU`oL}%%;fK<7zq6o@G(f|KJW1`Y} z-sU7TO#u_okH7+k6d{}z4)n)Im?%y*LZrOiWw{Uqm%8HOs`hGQe`t;;DTrD=3(@tL zD-UvX?9MzCV_|S*E^l&Yo9;Xs00000NkvXXu0mjf4X4Qc literal 0 HcmV?d00001 diff --git a/extensions/chrome/icons/icon48.png b/extensions/chrome/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..9f9511782d4a163c3b324d6fdb66b5a2c3567a15 GIT binary patch literal 856 zcmV-e1E>6nP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw0003jP)t-s|NsBvDi}dH`{r&y_)z+JpZ_x`M|_-1MGZE*6;&HCTq{Bm{j-QE1?=>3qA_2A(Aw6^%L zviR%k{lCHa{{H{!>;2u{{O#@i?e6{a^#0Dz`?t9HtgZOEy!qHV*< z_-tmdQr}vAE^{A@%QvOib00001VoOIv0Eh)0NB{r;32;bR za{vGi!vFvd!vV){sAK>D00(qQO+^Rl0~iZCFETKyWB>pGVM#y+`Xmy?l7a@8dG0N6YT#t+$gMO7hVjKc$KnLG>{>}iX^%1)T zSc5~uiILHlH*=89xsZHf78?_`Mpi@S29d;29C!196j77mYSfbHX0ii$=A zh}9DzPRRi6vlIj8bbxrlz@_aE%HIzL2L*p{m9C1PEnPEkLs(J`;P&qR!F}YLc4?rK zOkEAso{#`^w<7D$FAbysKB!m6d8^}}>6U-K{{@N%N<;O?YJ#B#-XW-V?6v^HDxl4; z4iS3)LtZwbHmz>AP46>~#dZJdPxG_IyI**dLgRvAhtmK602y>eSaefwW^{L9a%BK_ icXuvnZfkR6VQ^(GZ*pgw?mQX*0000 Date: Tue, 24 Mar 2026 13:35:59 +0100 Subject: [PATCH 28/64] Replace invalid Glassdoor host wildcard with explicit TLD patterns --- extensions/chrome/src/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/chrome/src/manifest.json b/extensions/chrome/src/manifest.json index d4ec78f..8f5a2f7 100644 --- a/extensions/chrome/src/manifest.json +++ b/extensions/chrome/src/manifest.json @@ -27,7 +27,8 @@ "*://*.usajobs.gov/job/*", "*://*.indeed.com/viewjob*", "*://*.glassdoor.com/job-listing/*", - "*://*.glassdoor.co.*/job-listing/*" + "*://*.glassdoor.co.uk/job-listing/*", + "*://*.glassdoor.co.in/job-listing/*" ], "js": ["src/content/content.ts"], "run_at": "document_idle" From 7bb06488185a874e06103658174c61b311a6945a Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:45:53 +0100 Subject: [PATCH 29/64] Resolve popup URLs from storage and add localhost:3000 to host permissions --- extensions/chrome/src/lib/api.ts | 4 ++-- extensions/chrome/src/manifest.json | 1 + extensions/chrome/src/popup/popup.tsx | 28 +++++++++++++++------------ 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/extensions/chrome/src/lib/api.ts b/extensions/chrome/src/lib/api.ts index 75422f2..e848837 100644 --- a/extensions/chrome/src/lib/api.ts +++ b/extensions/chrome/src/lib/api.ts @@ -1,5 +1,5 @@ const PROD_URL = "https://shortlist.johnmoorman.com"; -const DEV_URL = "http://localhost:3001"; +const DEV_URL = "http://localhost:3000"; interface ApiResponse { ok: boolean; @@ -9,7 +9,7 @@ interface ApiResponse { } /** Resolve the API base URL from extension storage, defaulting to production. */ -async function getBaseUrl(): Promise { +export async function getBaseUrl(): Promise { try { const result = await chrome.storage.local.get("apiBaseUrl"); return (result.apiBaseUrl as string) || PROD_URL; diff --git a/extensions/chrome/src/manifest.json b/extensions/chrome/src/manifest.json index 8f5a2f7..1d7a57c 100644 --- a/extensions/chrome/src/manifest.json +++ b/extensions/chrome/src/manifest.json @@ -6,6 +6,7 @@ "permissions": ["activeTab", "storage"], "host_permissions": [ "https://shortlist.johnmoorman.com/*", + "http://localhost:3000/*", "http://localhost:3001/*" ], "action": { diff --git a/extensions/chrome/src/popup/popup.tsx b/extensions/chrome/src/popup/popup.tsx index 774dca8..26cba4c 100644 --- a/extensions/chrome/src/popup/popup.tsx +++ b/extensions/chrome/src/popup/popup.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import type { ExtractionResult, ExtractedJob, ImportRecord, Message } from "../types"; +import { getBaseUrl } from "../lib/api"; // ── Types ──────────────────────────────────────────────────────────────── @@ -22,8 +23,6 @@ function sendTabMessage(tabId: number, message: Message): Promise { return chrome.tabs.sendMessage(tabId, message); } -const SHORTLIST_URL = "https://shortlist.johnmoorman.com"; - function timeAgo(isoDate: string): string { const seconds = Math.floor((Date.now() - new Date(isoDate).getTime()) / 1000); if (seconds < 60) return "just now"; @@ -47,10 +46,15 @@ function Popup() { const [errorMessage, setErrorMessage] = useState(""); const [importedJobId, setImportedJobId] = useState(null); const [importHistory, setImportHistory] = useState([]); + const [baseUrl, setBaseUrl] = useState("https://shortlist.johnmoorman.com"); // Initialize: check auth, fetch profiles, extract from current tab useEffect(() => { async function init() { + // 0. Resolve base URL for links + const url = await getBaseUrl(); + setBaseUrl(url); + // 1. Check auth const authResult = await sendMessage<{ type: string; @@ -217,9 +221,9 @@ function Popup() {

Sign in to Shortlist to import jobs.

@@ -239,7 +243,7 @@ function Popup() { className="btn btn-link" onClick={() => chrome.tabs.create({ - url: `${SHORTLIST_URL}/jobs/${importedJobId}`, + url: `${baseUrl}/jobs/${importedJobId}`, }) } > @@ -249,12 +253,12 @@ function Popup() { - + ); } @@ -273,12 +277,12 @@ function Popup() { - + ); } @@ -342,7 +346,7 @@ function Popup() {
{errorMessage}
)} - + ); } @@ -367,7 +371,7 @@ function JobPreview({ job }: { job: ExtractedJob }) { ); } -function ImportHistory({ history }: { history: ImportRecord[] }) { +function ImportHistory({ history, baseUrl }: { history: ImportRecord[]; baseUrl: string }) { if (history.length === 0) return null; return ( @@ -380,7 +384,7 @@ function ImportHistory({ history }: { history: ImportRecord[] }) { className="import-history-item" onClick={() => chrome.tabs.create({ - url: `${SHORTLIST_URL}/jobs/${record.jobId}`, + url: `${baseUrl}/jobs/${record.jobId}`, }) } > From f64c1534c276d592cfa83a51768a683521349011 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:49:32 +0100 Subject: [PATCH 30/64] Match LinkedIn collections and search pages in addition to direct view --- extensions/chrome/src/lib/extractors/linkedin.ts | 12 +++++++----- extensions/chrome/src/manifest.json | 2 ++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/extensions/chrome/src/lib/extractors/linkedin.ts b/extensions/chrome/src/lib/extractors/linkedin.ts index da9ca5e..4c2f272 100644 --- a/extensions/chrome/src/lib/extractors/linkedin.ts +++ b/extensions/chrome/src/lib/extractors/linkedin.ts @@ -53,7 +53,7 @@ function extractLinkedInSkills(): string[] { } export const linkedinExtractor: Extractor = { - matches: /^https?:\/\/(www\.)?linkedin\.com\/jobs\/view\//, + matches: /^https?:\/\/(www\.)?linkedin\.com\/jobs\/(view|collections|search)\//, extract(): ExtractedJob | null { const title = @@ -126,9 +126,11 @@ export const linkedinExtractor: Extractor = { const skills = extractLinkedInSkills(); - const idMatch = window.location.pathname.match(/\/jobs\/view\/(\d+)/); - const cleanUrl = idMatch - ? `https://www.linkedin.com/jobs/view/${idMatch[1]}/` + const pathMatch = window.location.pathname.match(/\/jobs\/view\/(\d+)/); + const queryId = new URLSearchParams(window.location.search).get("currentJobId"); + const jobId = pathMatch?.[1] ?? queryId; + const cleanUrl = jobId + ? `https://www.linkedin.com/jobs/view/${jobId}/` : window.location.origin + window.location.pathname; return { @@ -144,7 +146,7 @@ export const linkedinExtractor: Extractor = { salaryMax: salary.salaryMax, currency: salary.currency, skills, - externalId: idMatch?.[1] ?? null, + externalId: jobId ?? null, source: "LINKEDIN", }; }, diff --git a/extensions/chrome/src/manifest.json b/extensions/chrome/src/manifest.json index 1d7a57c..1970980 100644 --- a/extensions/chrome/src/manifest.json +++ b/extensions/chrome/src/manifest.json @@ -21,6 +21,8 @@ { "matches": [ "*://*.linkedin.com/jobs/view/*", + "*://*.linkedin.com/jobs/collections/*", + "*://*.linkedin.com/jobs/search/*", "*://boards.greenhouse.io/*/jobs/*", "*://jobs.greenhouse.io/*/jobs/*", "*://jobs.lever.co/*/*", From c896536172f15df285dd7b811e34ec8d23586565 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:57:23 +0100 Subject: [PATCH 31/64] Inject content script on all pages and always fall through to generic --- .../chrome/src/background/service-worker.ts | 18 ----------- extensions/chrome/src/content/content.ts | 13 ++------ extensions/chrome/src/lib/extractors/index.ts | 5 ++-- extensions/chrome/src/manifest.json | 27 +++++------------ extensions/chrome/src/popup/popup.tsx | 30 +++---------------- extensions/chrome/src/types.ts | 3 -- 6 files changed, 16 insertions(+), 80 deletions(-) diff --git a/extensions/chrome/src/background/service-worker.ts b/extensions/chrome/src/background/service-worker.ts index a60f897..0e2b829 100644 --- a/extensions/chrome/src/background/service-worker.ts +++ b/extensions/chrome/src/background/service-worker.ts @@ -26,24 +26,6 @@ async function getImportHistory(): Promise { return importHistory; } -// ── Badge management ───────────────────────────────────────────────────── - -// When a content script detects a job page, show a badge on the icon -chrome.runtime.onMessage.addListener( - ( - message: Message, - sender: chrome.runtime.MessageSender, - ) => { - if (message.type === "JOB_PAGE_DETECTED" && sender.tab?.id) { - chrome.action.setBadgeText({ text: "+", tabId: sender.tab.id }); - chrome.action.setBadgeBackgroundColor({ - color: "#6366f1", - tabId: sender.tab.id, - }); - } - }, -); - // ── Message handler ────────────────────────────────────────────────────── chrome.runtime.onMessage.addListener( diff --git a/extensions/chrome/src/content/content.ts b/extensions/chrome/src/content/content.ts index d8e1327..738a7ce 100644 --- a/extensions/chrome/src/content/content.ts +++ b/extensions/chrome/src/content/content.ts @@ -2,18 +2,12 @@ import { extractFromPage } from "../lib/extractors"; import type { Message } from "../types"; /** - * Content script — injected into job board pages. + * Content script — injected on demand via chrome.scripting.executeScript + * when the user opens the popup, or pre-injected on known job board URLs. * - * Responsibilities: - * 1. Detect supported job pages and notify the background worker (badge). - * 2. On request from the popup, extract job data from the DOM. + * Listens for EXTRACT messages from the popup and returns extraction results. */ -// Notify the background worker that this page may be a job listing. -// The worker uses this to set the toolbar badge. -chrome.runtime.sendMessage({ type: "JOB_PAGE_DETECTED" }); - -// Listen for extraction requests from the popup chrome.runtime.onMessage.addListener( ( message: Message, @@ -24,7 +18,6 @@ chrome.runtime.onMessage.addListener( const result = extractFromPage(); sendResponse({ type: "EXTRACTED", result }); } - // Return true to keep the message channel open for async sendResponse return true; }, ); diff --git a/extensions/chrome/src/lib/extractors/index.ts b/extensions/chrome/src/lib/extractors/index.ts index 61ca5ce..3a1457f 100644 --- a/extensions/chrome/src/lib/extractors/index.ts +++ b/extensions/chrome/src/lib/extractors/index.ts @@ -34,9 +34,8 @@ export function extractFromPage(): ExtractionResult { } } - // Generic fallback: collect raw page content for AI extraction + // Generic fallback: collect raw page content for AI extraction. + // The user clicked the extension, so trust that there's a job on this page. const content = collectPageContent(); - if (content.html.length < 100) return { type: "none" }; - return { type: "generic", ...content }; } diff --git a/extensions/chrome/src/manifest.json b/extensions/chrome/src/manifest.json index 1970980..ad588f8 100644 --- a/extensions/chrome/src/manifest.json +++ b/extensions/chrome/src/manifest.json @@ -9,6 +9,13 @@ "http://localhost:3000/*", "http://localhost:3001/*" ], + "content_scripts": [ + { + "matches": [""], + "js": ["src/content/content.ts"], + "run_at": "document_idle" + } + ], "action": { "default_popup": "src/popup/popup.html", "default_icon": { @@ -17,26 +24,6 @@ "128": "icons/icon128.png" } }, - "content_scripts": [ - { - "matches": [ - "*://*.linkedin.com/jobs/view/*", - "*://*.linkedin.com/jobs/collections/*", - "*://*.linkedin.com/jobs/search/*", - "*://boards.greenhouse.io/*/jobs/*", - "*://jobs.greenhouse.io/*/jobs/*", - "*://jobs.lever.co/*/*", - "*://jobs.ashbyhq.com/*/*", - "*://*.usajobs.gov/job/*", - "*://*.indeed.com/viewjob*", - "*://*.glassdoor.com/job-listing/*", - "*://*.glassdoor.co.uk/job-listing/*", - "*://*.glassdoor.co.in/job-listing/*" - ], - "js": ["src/content/content.ts"], - "run_at": "document_idle" - } - ], "background": { "service_worker": "src/background/service-worker.ts", "type": "module" diff --git a/extensions/chrome/src/popup/popup.tsx b/extensions/chrome/src/popup/popup.tsx index 26cba4c..f02523a 100644 --- a/extensions/chrome/src/popup/popup.tsx +++ b/extensions/chrome/src/popup/popup.tsx @@ -103,11 +103,11 @@ function Popup() { }>(tab.id, { type: "EXTRACT" }); setExtraction(result.result); } else { - setExtraction({ type: "none" }); + setExtraction({ type: "generic", url: "", html: "", title: "Unknown page", meta: {} }); } } catch { - // Content script not injected on this page -- use generic fallback - setExtraction({ type: "none" }); + // Content script may not be injected on restricted pages (chrome://, etc.) + setExtraction({ type: "generic", url: "", html: "", title: "Unknown page", meta: {} }); } setStatus("idle"); @@ -263,29 +263,7 @@ function Popup() { ); } - // No extractable content - if (extraction?.type === "none") { - return ( -
-
-
-

No job listing detected on this page.

-

- Try opening a specific job posting page. -

-
- - -
- ); - } + return (
diff --git a/extensions/chrome/src/types.ts b/extensions/chrome/src/types.ts index b9b2db1..fc060eb 100644 --- a/extensions/chrome/src/types.ts +++ b/extensions/chrome/src/types.ts @@ -66,9 +66,6 @@ export type ExtractionResult = // ── Chrome message types ───────────────────────────────────────────────── export type Message = - // Page detection (content script -> background) - | { type: "JOB_PAGE_DETECTED" } - // Auth | { type: "GET_AUTH_STATUS" } | { type: "AUTH_STATUS"; authenticated: boolean } From 80138bc552991259cd47e884267e5ad9c94ef190 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:30:01 +0100 Subject: [PATCH 32/64] Fall back to scripting API when content script is not yet injected --- extensions/chrome/src/manifest.json | 2 +- extensions/chrome/src/popup/popup.tsx | 65 ++++++++++++++++++++++----- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/extensions/chrome/src/manifest.json b/extensions/chrome/src/manifest.json index ad588f8..1581a4c 100644 --- a/extensions/chrome/src/manifest.json +++ b/extensions/chrome/src/manifest.json @@ -3,7 +3,7 @@ "name": "Shortlist — Job Importer", "version": "0.1.0", "description": "Import job listings into your Shortlist feed with one click", - "permissions": ["activeTab", "storage"], + "permissions": ["activeTab", "storage", "scripting"], "host_permissions": [ "https://shortlist.johnmoorman.com/*", "http://localhost:3000/*", diff --git a/extensions/chrome/src/popup/popup.tsx b/extensions/chrome/src/popup/popup.tsx index f02523a..90d1921 100644 --- a/extensions/chrome/src/popup/popup.tsx +++ b/extensions/chrome/src/popup/popup.tsx @@ -97,17 +97,57 @@ function Popup() { currentWindow: true, }); if (tab?.id) { - const result = await sendTabMessage<{ - type: string; - result: ExtractionResult; - }>(tab.id, { type: "EXTRACT" }); - setExtraction(result.result); - } else { - setExtraction({ type: "generic", url: "", html: "", title: "Unknown page", meta: {} }); + let result: { type: string; result: ExtractionResult } | null = null; + + // Try messaging the content script (already injected via manifest) + try { + result = await sendTabMessage<{ + type: string; + result: ExtractionResult; + }>(tab.id, { type: "EXTRACT" }); + } catch { + // Content script not present (page loaded before extension install/refresh). + // Fall back to collecting page content directly via scripting API. + try { + const [injected] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => { + const mainEl = + document.querySelector("main") ?? + document.querySelector("article") ?? + document.querySelector("[role='main']") ?? + document.body; + const clone = mainEl.cloneNode(true) as HTMLElement; + clone.querySelectorAll("script, style, nav, footer, header, iframe, noscript") + .forEach((el) => el.remove()); + return { + url: window.location.href, + html: clone.innerText.slice(0, 50000), + title: document.title, + }; + }, + }); + if (injected?.result) { + result = { + type: "EXTRACTED", + result: { + type: "generic", + url: injected.result.url, + html: injected.result.html, + title: injected.result.title, + meta: {}, + }, + }; + } + } catch { + // Page is restricted (chrome://, edge://, etc.) + } + } + + setExtraction(result?.result ?? null); } } catch { - // Content script may not be injected on restricted pages (chrome://, etc.) - setExtraction({ type: "generic", url: "", html: "", title: "Unknown page", meta: {} }); + setExtraction(null); } setStatus("idle"); @@ -275,12 +315,17 @@ function Popup() { )} {extraction?.type === "generic" && (
-
{extraction.title || "Untitled Page"}
+
{extraction.title || document.title || "This page"}
AI will extract job details from this page
)} + {extraction === null && status === "idle" && ( +
+

Could not read this page. Try reloading it first.

+
+ )} {/* Extracting state */} {status === "extracting" && ( From f4dca894009b7ca65d5188f7603371c7190aa65a Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:37:13 +0100 Subject: [PATCH 33/64] Send innerHTML from generic extractor and run Turndown on HTML input --- .../chrome/src/lib/extractors/generic.ts | 28 +++++++++++-------- extensions/chrome/src/popup/popup.tsx | 15 ++++++---- src/app/api/jobs/extract/route.ts | 7 +++-- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/extensions/chrome/src/lib/extractors/generic.ts b/extensions/chrome/src/lib/extractors/generic.ts index b5e7922..dea348e 100644 --- a/extensions/chrome/src/lib/extractors/generic.ts +++ b/extensions/chrome/src/lib/extractors/generic.ts @@ -1,8 +1,9 @@ -const MAX_HTML_LENGTH = 10_000; +const MAX_CONTENT_LENGTH = 50_000; /** - * Generic fallback: collects page metadata and cleaned body text - * for AI extraction on the server side. + * Generic fallback: collects cleaned page HTML and metadata for + * AI extraction on the server side. Sends innerHTML so the server + * can use Turndown to convert to clean markdown for the AI. */ export function collectPageContent(): { url: string; @@ -10,7 +11,6 @@ export function collectPageContent(): { title: string; meta: Record; } { - // Collect useful meta tags const meta: Record = {}; document.querySelectorAll("meta[property], meta[name]").forEach((el) => { const key = @@ -19,22 +19,28 @@ export function collectPageContent(): { if (key && value) meta[key] = value; }); - // Get the main content area, or fall back to body - const mainEl = + // Try job-specific containers first, then general content areas + const contentEl = + document.querySelector("[role='main'] article") ?? + document.querySelector("[role='main']") ?? + document.querySelector("main article") ?? document.querySelector("main") ?? document.querySelector("article") ?? - document.querySelector("[role='main']") ?? document.body; - // Strip scripts, styles, navs, footers to reduce noise - const clone = mainEl.cloneNode(true) as HTMLElement; + const clone = contentEl.cloneNode(true) as HTMLElement; clone - .querySelectorAll("script, style, nav, footer, header, iframe, noscript") + .querySelectorAll( + "script, style, nav, footer, header, iframe, noscript, " + + "svg, img, video, audio, canvas, " + + "[role='navigation'], [role='banner'], [role='contentinfo'], " + + "[aria-hidden='true']" + ) .forEach((el) => el.remove()); return { url: window.location.href, - html: clone.innerText.slice(0, MAX_HTML_LENGTH), + html: clone.innerHTML.slice(0, MAX_CONTENT_LENGTH), title: document.title, meta, }; diff --git a/extensions/chrome/src/popup/popup.tsx b/extensions/chrome/src/popup/popup.tsx index 90d1921..20e7b23 100644 --- a/extensions/chrome/src/popup/popup.tsx +++ b/extensions/chrome/src/popup/popup.tsx @@ -112,17 +112,20 @@ function Popup() { const [injected] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => { - const mainEl = + const el = + document.querySelector("[role='main'] article") ?? + document.querySelector("[role='main']") ?? document.querySelector("main") ?? document.querySelector("article") ?? - document.querySelector("[role='main']") ?? document.body; - const clone = mainEl.cloneNode(true) as HTMLElement; - clone.querySelectorAll("script, style, nav, footer, header, iframe, noscript") - .forEach((el) => el.remove()); + const clone = el.cloneNode(true) as HTMLElement; + clone.querySelectorAll( + "script, style, nav, footer, header, iframe, noscript, " + + "svg, img, video, audio, canvas, [aria-hidden='true']" + ).forEach((n) => n.remove()); return { url: window.location.href, - html: clone.innerText.slice(0, 50000), + html: clone.innerHTML.slice(0, 50000), title: document.title, }; }, diff --git a/src/app/api/jobs/extract/route.ts b/src/app/api/jobs/extract/route.ts index 43e005b..2064cb1 100644 --- a/src/app/api/jobs/extract/route.ts +++ b/src/app/api/jobs/extract/route.ts @@ -115,6 +115,9 @@ export async function POST(req: Request) { { status: 422 }, ); } + } else if (/<[a-z][\s\S]*>/i.test(input)) { + // Input contains HTML (e.g. from the Chrome extension) — convert to markdown + cleanedText = td.turndown(input); } else { cleanedText = input; } @@ -123,10 +126,10 @@ export async function POST(req: Request) { try { const response = await openrouter.chat.completions.create({ model: models.extract, - max_tokens: 250, + max_tokens: 500, messages: [ { role: "system", content: EXTRACTION_SYSTEM_PROMPT }, - { role: "user", content: cleanedText.slice(0, 12000) }, + { role: "user", content: cleanedText.slice(0, 24000) }, ], }); From 6d00786c47edd8ab0f8ca8ff67e25d59aca0243b Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:28:33 +0100 Subject: [PATCH 34/64] Add Zod schemas for selector identification and normalization --- src/lib/validations.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/lib/validations.ts b/src/lib/validations.ts index 7404fe0..d3d8b2f 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -187,6 +187,26 @@ export const deleteAccountSchema = z.object({ confirmation: z.literal("DELETE"), }); +// ── Chrome extension: selector identification ─────────────────────────── +export const identifySelectorsSchema = z.object({ + skeleton: z.string().min(1).max(20000), + profileId: z.string().cuid(), +}); + +// ── Chrome extension: extraction normalization ────────────────────────── +export const normalizeExtractionSchema = z.object({ + title: z.string().nullable(), + company: z.string().nullable(), + location: z.string().nullable(), + salaryText: z.string().nullable(), + jobTypeText: z.string().nullable(), + skillsText: z.string().nullable(), + descriptionHtml: z.string().min(1).max(100000), + postedDateText: z.string().nullable(), + url: z.string(), + profileId: z.string().cuid(), +}); + // ── Custom model settings ──────────────────────────────────────────────── export const updateModelSettingsSchema = z.object({ profileId: z.string().cuid(), From 40a33003e0c8a0ecf638527b2cd818a4d0acd2b5 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:29:21 +0100 Subject: [PATCH 35/64] Add selector identification endpoint using Gemini Flash --- src/app/api/jobs/extract/identify/route.ts | 155 +++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/app/api/jobs/extract/identify/route.ts diff --git a/src/app/api/jobs/extract/identify/route.ts b/src/app/api/jobs/extract/identify/route.ts new file mode 100644 index 0000000..0f17589 --- /dev/null +++ b/src/app/api/jobs/extract/identify/route.ts @@ -0,0 +1,155 @@ +import { auth } from "@clerk/nextjs/server"; +import { prisma } from "@/lib/prisma"; +import { openrouter } from "@/lib/openrouter"; +import { getModels } from "@/lib/models"; +import { identifySelectorsSchema } from "@/lib/validations"; + +const SYSTEM_PROMPT = `You are a DOM analyst. Given a stripped HTML skeleton of a job listing page, identify CSS selectors that would extract key fields. Return ONLY valid JSON — no markdown fences, no explanation: + +{ + "title": "CSS selector for the job title element, or null", + "company": "CSS selector for the company name element, or null", + "location": "CSS selector for the job location element, or null", + "salary": "CSS selector for the salary/compensation element, or null", + "jobType": "CSS selector for employment type element, or null", + "skills": "CSS selector for skills list container, or null", + "description": "CSS selector for the main job description container, or null", + "postedDate": "CSS selector for the posting date element, or null" +} + +Rules: +- Use the most specific selector possible (prefer IDs > data attributes > semantic classes > tag hierarchy) +- For description, select the CONTAINER element (innerHTML will be extracted) +- For skills, select the LIST CONTAINER (each child will be one skill) +- Return null for any field you cannot identify with confidence +- Selectors must be valid CSS for document.querySelector()`; + +interface SelectorResult { + title: string | null; + company: string | null; + location: string | null; + salary: string | null; + jobType: string | null; + skills: string | null; + description: string | null; + postedDate: string | null; +} + +function isValidResult(parsed: unknown): parsed is SelectorResult { + if (typeof parsed !== "object" || parsed === null) return false; + const p = parsed as Record; + const fields = ["title", "company", "location", "salary", "jobType", "skills", "description", "postedDate"]; + return fields.every((f) => p[f] === null || typeof p[f] === "string"); +} + +export async function POST(req: Request) { + const { userId } = await auth(); + if (!userId) return new Response("Unauthorized", { status: 401 }); + + const body = await req.json().catch(() => null); + const parsed = identifySelectorsSchema.safeParse(body); + if (!parsed.success) { + return Response.json({ error: "Invalid request" }, { status: 400 }); + } + + const { skeleton, profileId } = parsed.data; + + const profile = await prisma.profile.findFirst({ where: { id: profileId, userId } }); + if (!profile) return new Response("Profile not found", { status: 404 }); + + const usage = await prisma.usage.findUnique({ where: { userId } }); + if (usage && usage.currentMonthInputTokens >= usage.monthlyLimitInputTokens) { + return Response.json( + { error: "Monthly AI usage limit reached." }, + { status: 429 }, + ); + } + + const models = getModels(profile); + + try { + const response = await openrouter.chat.completions.create({ + model: models.triage, + max_tokens: 300, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: skeleton }, + ], + }); + + const inputTokens = response.usage?.prompt_tokens ?? 0; + const outputTokens = response.usage?.completion_tokens ?? 0; + + if (inputTokens > 0) { + await prisma.usage.upsert({ + where: { userId }, + create: { + userId, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, + currentMonthInputTokens: inputTokens, + currentMonthOutputTokens: outputTokens, + triageCallCount: 1, + triageInputTokens: inputTokens, + triageOutputTokens: outputTokens, + }, + update: { + totalInputTokens: { increment: inputTokens }, + totalOutputTokens: { increment: outputTokens }, + currentMonthInputTokens: { increment: inputTokens }, + currentMonthOutputTokens: { increment: outputTokens }, + triageCallCount: { increment: 1 }, + triageInputTokens: { increment: inputTokens }, + triageOutputTokens: { increment: outputTokens }, + }, + }); + } + + const text = response.choices[0]?.message?.content ?? ""; + + let selectors: SelectorResult; + try { + const direct = JSON.parse(text.trim()); + if (isValidResult(direct)) { + selectors = direct; + } else { + throw new Error("Invalid shape"); + } + } catch { + // Try extracting JSON object from response + const match = text.match(/\{[\s\S]*\}/); + if (!match) { + console.error("[/api/jobs/extract/identify] No JSON in response:", text.slice(0, 200)); + return Response.json( + { error: "Could not identify selectors from this page." }, + { status: 422 }, + ); + } + try { + const extracted = JSON.parse(match[0]); + if (!isValidResult(extracted)) { + console.error("[/api/jobs/extract/identify] Invalid shape:", text.slice(0, 200)); + return Response.json( + { error: "Could not identify selectors from this page." }, + { status: 422 }, + ); + } + selectors = extracted; + } catch { + console.error("[/api/jobs/extract/identify] JSON parse failed:", text.slice(0, 200)); + return Response.json( + { error: "Could not identify selectors from this page." }, + { status: 422 }, + ); + } + } + + return Response.json({ selectors }); + } catch (err) { + console.error("[/api/jobs/extract/identify]", err); + return Response.json( + { error: "Selector identification failed. Please try again." }, + { status: 500 }, + ); + } +} From b2962a94f89f3843e98a817bb60ac7ba755dc3d8 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:30:00 +0100 Subject: [PATCH 36/64] Add extraction normalization endpoint using Haiku --- src/app/api/jobs/extract/normalize/route.ts | 174 ++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/app/api/jobs/extract/normalize/route.ts diff --git a/src/app/api/jobs/extract/normalize/route.ts b/src/app/api/jobs/extract/normalize/route.ts new file mode 100644 index 0000000..cb50cc5 --- /dev/null +++ b/src/app/api/jobs/extract/normalize/route.ts @@ -0,0 +1,174 @@ +import { auth } from "@clerk/nextjs/server"; +import { prisma } from "@/lib/prisma"; +import { openrouter } from "@/lib/openrouter"; +import { getModels } from "@/lib/models"; +import { normalizeExtractionSchema } from "@/lib/validations"; +import TurndownService from "turndown"; + +const td = new TurndownService({ headingStyle: "atx", bulletListMarker: "-" }); +td.remove(["script", "style", "noscript", "iframe"]); + +const SYSTEM_PROMPT = `Normalize these raw job listing fields into structured data. Return ONLY valid JSON — no markdown fences, no explanation: + +{ + "title": "cleaned title string", + "company": "cleaned company name", + "location": "cleaned location string or null", + "locationType": "REMOTE" or "HYBRID" or "ONSITE" or null, + "postedAt": "YYYY-MM-DD or null", + "jobType": "FULL_TIME" or "PART_TIME" or "CONTRACT" or "FREELANCE" or "INTERNSHIP" or null, + "salaryMin": integer or null, + "salaryMax": integer or null, + "currency": "USD" or "EUR" or "GBP" etc or null, + "skills": ["skill1", "skill2"] +} + +Rules: +- Parse salary ranges like "$120K - $180K" into salaryMin: 120000, salaryMax: 180000, currency: "USD" +- Convert hourly rates to annual (multiply by 2080) +- Extract individual skills from comma-separated or bullet-pointed lists +- Use null for anything you cannot determine`; + +interface NormalizeResult { + title: string; + company: string; + location: string | null; + locationType: "REMOTE" | "HYBRID" | "ONSITE" | null; + postedAt: string | null; + jobType: "FULL_TIME" | "PART_TIME" | "CONTRACT" | "FREELANCE" | "INTERNSHIP" | null; + salaryMin: number | null; + salaryMax: number | null; + currency: string | null; + skills: string[]; +} + +function isValidResult(parsed: unknown): parsed is NormalizeResult { + if (typeof parsed !== "object" || parsed === null) return false; + const p = parsed as Record; + return typeof p.title === "string" && typeof p.company === "string"; +} + +export async function POST(req: Request) { + const { userId } = await auth(); + if (!userId) return new Response("Unauthorized", { status: 401 }); + + const body = await req.json().catch(() => null); + const parsed = normalizeExtractionSchema.safeParse(body); + if (!parsed.success) { + return Response.json({ error: "Invalid request" }, { status: 400 }); + } + + const { profileId } = parsed.data; + + const profile = await prisma.profile.findFirst({ where: { id: profileId, userId } }); + if (!profile) return new Response("Profile not found", { status: 404 }); + + const usage = await prisma.usage.findUnique({ where: { userId } }); + if (usage && usage.currentMonthInputTokens >= usage.monthlyLimitInputTokens) { + return Response.json( + { error: "Monthly AI usage limit reached." }, + { status: 429 }, + ); + } + + const models = getModels(profile); + + // Convert description HTML to markdown (no AI needed for this) + const descriptionMarkdown = td.turndown(parsed.data.descriptionHtml); + + // Build compact user message from extracted fields + const fields = [ + `Title: ${parsed.data.title ?? "unknown"}`, + `Company: ${parsed.data.company ?? "unknown"}`, + `Location: ${parsed.data.location ?? "unknown"}`, + `Salary: ${parsed.data.salaryText ?? "unknown"}`, + `Job Type: ${parsed.data.jobTypeText ?? "unknown"}`, + `Skills: ${parsed.data.skillsText ?? "unknown"}`, + `Posted: ${parsed.data.postedDateText ?? "unknown"}`, + ].join("\n"); + + try { + const response = await openrouter.chat.completions.create({ + model: models.extract, + max_tokens: 400, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: fields }, + ], + }); + + const inputTokens = response.usage?.prompt_tokens ?? 0; + const outputTokens = response.usage?.completion_tokens ?? 0; + + if (inputTokens > 0) { + await prisma.usage.upsert({ + where: { userId }, + create: { + userId, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, + currentMonthInputTokens: inputTokens, + currentMonthOutputTokens: outputTokens, + analysisCallCount: 1, + }, + update: { + totalInputTokens: { increment: inputTokens }, + totalOutputTokens: { increment: outputTokens }, + currentMonthInputTokens: { increment: inputTokens }, + currentMonthOutputTokens: { increment: outputTokens }, + analysisCallCount: { increment: 1 }, + }, + }); + } + + const text = response.choices[0]?.message?.content ?? ""; + + let result: NormalizeResult; + try { + const direct = JSON.parse(text.trim()); + if (isValidResult(direct)) { + result = direct; + } else { + throw new Error("Invalid shape"); + } + } catch { + const match = text.match(/\{[\s\S]*\}/); + if (!match) { + console.error("[/api/jobs/extract/normalize] No JSON in response:", text.slice(0, 200)); + return Response.json( + { error: "Could not normalize the extracted data." }, + { status: 422 }, + ); + } + try { + const extracted = JSON.parse(match[0]); + if (!isValidResult(extracted)) { + console.error("[/api/jobs/extract/normalize] Invalid shape:", text.slice(0, 200)); + return Response.json( + { error: "Could not normalize the extracted data." }, + { status: 422 }, + ); + } + result = extracted; + } catch { + console.error("[/api/jobs/extract/normalize] JSON parse failed:", text.slice(0, 200)); + return Response.json( + { error: "Could not normalize the extracted data." }, + { status: 422 }, + ); + } + } + + return Response.json({ + ...result, + description: descriptionMarkdown, + url: parsed.data.url, + }); + } catch (err) { + console.error("[/api/jobs/extract/normalize]", err); + return Response.json( + { error: "Normalization failed. Please try again." }, + { status: 500 }, + ); + } +} From 8d1aa8377f3f2bab069b8a8278e9bc28adfad687 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:30:28 +0100 Subject: [PATCH 37/64] Add usage tracking to existing extract route --- src/app/api/jobs/extract/route.ts | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/app/api/jobs/extract/route.ts b/src/app/api/jobs/extract/route.ts index 2064cb1..b859b3b 100644 --- a/src/app/api/jobs/extract/route.ts +++ b/src/app/api/jobs/extract/route.ts @@ -82,6 +82,15 @@ export async function POST(req: Request) { const models = getModels(profile); + // Usage limit check + const usage = await prisma.usage.findUnique({ where: { userId } }); + if (usage && usage.currentMonthInputTokens >= usage.monthlyLimitInputTokens) { + return Response.json( + { error: "Monthly AI usage limit reached." }, + { status: 429 }, + ); + } + // Resolve input to clean text let cleanedText: string; const isUrl = URL_RE.test(input.trim()); @@ -133,6 +142,30 @@ export async function POST(req: Request) { ], }); + const inputTokens = response.usage?.prompt_tokens ?? 0; + const outputTokens = response.usage?.completion_tokens ?? 0; + + if (inputTokens > 0) { + await prisma.usage.upsert({ + where: { userId }, + create: { + userId, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, + currentMonthInputTokens: inputTokens, + currentMonthOutputTokens: outputTokens, + analysisCallCount: 1, + }, + update: { + totalInputTokens: { increment: inputTokens }, + totalOutputTokens: { increment: outputTokens }, + currentMonthInputTokens: { increment: inputTokens }, + currentMonthOutputTokens: { increment: outputTokens }, + analysisCallCount: { increment: 1 }, + }, + }); + } + const text = response.choices[0]?.message?.content ?? ""; const result = parseAiResponse(text); From fbaf33f839c6f87062e483928ee66040025d3d55 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:29:51 +0100 Subject: [PATCH 38/64] Update extension types for two-step extraction flow --- extensions/chrome/src/types.ts | 63 ++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/extensions/chrome/src/types.ts b/extensions/chrome/src/types.ts index fc060eb..4dea0b0 100644 --- a/extensions/chrome/src/types.ts +++ b/extensions/chrome/src/types.ts @@ -41,26 +41,38 @@ export interface ExtractedJob { | "CUSTOM"; } -// ── Extractor interface ────────────────────────────────────────────────── +// ── Selector map for AI-identified fields ──────────────────────────────── -export interface Extractor { - /** URL pattern this extractor handles */ - matches: RegExp; - /** Extract structured job data from the current page DOM */ - extract: () => ExtractedJob | null; +export interface SelectorMap { + title: string | null; + company: string | null; + location: string | null; + salary: string | null; + jobType: string | null; + skills: string | null; + description: string | null; + postedDate: string | null; +} + +// ── Raw extraction before normalization ────────────────────────────────── + +export interface RawExtraction { + title: string | null; + company: string | null; + location: string | null; + salaryText: string | null; + jobTypeText: string | null; + skillsText: string | null; + descriptionHtml: string; + postedDateText: string | null; + url: string; } // ── Extraction result variants ─────────────────────────────────────────── export type ExtractionResult = - | { type: "structured"; data: ExtractedJob } - | { - type: "generic"; - url: string; - html: string; - title: string; - meta: Record; - } + | { type: "extracted"; raw: RawExtraction } + | { type: "needs_identification"; skeleton: string; url: string; domain: string } | { type: "none" }; // ── Chrome message types ───────────────────────────────────────────────── @@ -81,25 +93,18 @@ export type Message = | { type: "EXTRACT" } | { type: "EXTRACTED"; result: ExtractionResult } + // Two-step AI extraction + | { type: "IDENTIFY_SELECTORS"; skeleton: string; profileId: string } + | { type: "SELECTORS_IDENTIFIED"; selectors: SelectorMap } + | { type: "APPLY_SELECTORS"; selectors: SelectorMap } + | { type: "SELECTORS_APPLIED"; result: ExtractionResult } + | { type: "NORMALIZE_AND_IMPORT"; raw: RawExtraction; profileId: string; profileName: string } + | { type: "IMPORT_COMPLETE"; jobId: string } + // Import (structured job from extractor) | { type: "IMPORT_JOB"; profileId: string; profileName: string; job: ExtractedJob } | { type: "IMPORT_RESULT"; ok: boolean; jobId?: string; error?: string } - // Import (generic fallback — needs AI extraction first) - | { - type: "EXTRACT_AND_IMPORT"; - profileId: string; - profileName: string; - html: string; - url: string; - } - | { - type: "EXTRACT_AND_IMPORT_RESULT"; - ok: boolean; - jobId?: string; - error?: string; - } - // Import history | { type: "GET_IMPORT_HISTORY" } | { type: "IMPORT_HISTORY"; history: ImportRecord[] }; From 323f0036437cb78bf33a042cd4711c793783d4ec Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:30:40 +0100 Subject: [PATCH 39/64] Rewrite generic extractor with DOM stripping and selector caching --- .../chrome/src/lib/extractors/generic.ts | 220 +++++++++++++++--- 1 file changed, 186 insertions(+), 34 deletions(-) diff --git a/extensions/chrome/src/lib/extractors/generic.ts b/extensions/chrome/src/lib/extractors/generic.ts index dea348e..248d902 100644 --- a/extensions/chrome/src/lib/extractors/generic.ts +++ b/extensions/chrome/src/lib/extractors/generic.ts @@ -1,47 +1,199 @@ -const MAX_CONTENT_LENGTH = 50_000; - -/** - * Generic fallback: collects cleaned page HTML and metadata for - * AI extraction on the server side. Sends innerHTML so the server - * can use Turndown to convert to clean markdown for the AI. - */ -export function collectPageContent(): { - url: string; - html: string; - title: string; - meta: Record; -} { - const meta: Record = {}; - document.querySelectorAll("meta[property], meta[name]").forEach((el) => { - const key = - el.getAttribute("property") ?? el.getAttribute("name") ?? ""; - const value = el.getAttribute("content") ?? ""; - if (key && value) meta[key] = value; - }); - - // Try job-specific containers first, then general content areas +import type { SelectorMap, RawExtraction } from "../../types"; + +// ── CSS framework class blocklist ──────────────────────────────────────── + +const FRAMEWORK_PATTERNS = [ + // Tailwind utilities + /^-?(sm|md|lg|xl|2xl):/, + /^(hover|focus|active|disabled|group-|peer-):/, + /^!?-?(bg|text|font|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|w|h|min-w|min-h|max-w|max-h)-/, + /^(flex|grid|col|row|gap|space|place|items|justify|self|order)-/, + /^(border|rounded|shadow|ring|outline)-/, + /^(opacity|transition|transform|duration|ease|delay|animate)-/, + /^(absolute|relative|fixed|sticky|inset|top|right|bottom|left|z)-/, + /^(overflow|truncate|whitespace|break|tracking|leading)-/, + /^(block|inline-block|inline|hidden|invisible|visible|sr-only|not-sr-only)$/, + /^(cursor|pointer-events|select|resize|appearance)-/, + /^(underline|overline|line-through|no-underline|uppercase|lowercase|capitalize|normal-case)$/, + /^(antialiased|subpixel-antialiased)$/, + /^(fill|stroke)-/, + /^(table|clear|float)-/, + /^(accent|caret|scroll)-/, + /^(snap|touch|will-change)-/, + /^(columns|break-|object-|aspect-)/, + /^(decoration|indent|align|content)-/, + /^(backdrop|filter|blur|brightness|contrast|grayscale|invert|saturate|sepia|drop-shadow)-/, + /^(mix-blend|bg-blend)-/, + /^(isolate|isolation)-/, + /^(list|placeholder)-/, + /^(ring|divide|space)-/, + /^(from|via|to)-/, + /^(basis|grow|shrink)-/, + // Bootstrap + /^(col-|row|container|btn|nav-|card|modal|badge|alert|form-|input-)/, + /^(d-|justify-content|align-items|float-|position-|text-center|text-start|text-end)$/, + /^(mb-|mt-|ms-|me-|mx-|my-|pb-|pt-|ps-|pe-|px-|py-|g-|gx-|gy-)\d/, + /^(visually-hidden|stretched-link|clearfix)$/, + // CSS Modules / Styled Components / Emotion (hashed) + /^(css|sc|emotion)-[a-zA-Z0-9]/, + /^_[a-zA-Z]+_[a-f0-9]+/, + /[a-zA-Z]+_[a-f0-9]{5,}$/, + // Generic minified classes (1-2 chars) + /^[a-zA-Z]{1,2}$/, +]; + +const KEEP_ATTRIBUTES = new Set(["id", "data-testid", "data-test", "role", "aria-label", "class"]); + +const NOISE_SELECTOR = + "script, style, nav, footer, header, iframe, noscript, svg, img, video, audio, canvas, " + + "[aria-hidden='true'], [role='navigation'], [role='banner'], [role='contentinfo']"; + +const MAX_SKELETON_LENGTH = 15_000; +const MAX_TEXT_NODE_LENGTH = 30; + +// ── DOM skeleton builder ───────────────────────────────────────────────── + +function isFrameworkClass(cls: string): boolean { + return FRAMEWORK_PATTERNS.some((pattern) => pattern.test(cls)); +} + +function stripAttributes(el: Element): void { + const attrs = Array.from(el.attributes); + for (const attr of attrs) { + if (attr.name === "style") { + el.removeAttribute("style"); + continue; + } + if (attr.name === "class") { + const filtered = attr.value + .split(/\s+/) + .filter((cls) => cls && !isFrameworkClass(cls)) + .join(" "); + if (filtered) { + el.setAttribute("class", filtered); + } else { + el.removeAttribute("class"); + } + continue; + } + if (!KEEP_ATTRIBUTES.has(attr.name)) { + el.removeAttribute(attr.name); + } + } +} + +function truncateTextNodes(node: Node): void { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? ""; + if (text.length > MAX_TEXT_NODE_LENGTH) { + node.textContent = text.slice(0, MAX_TEXT_NODE_LENGTH) + "..."; + } + return; + } + for (const child of Array.from(node.childNodes)) { + truncateTextNodes(child); + } +} + +export function stripDomSkeleton(): string { const contentEl = document.querySelector("[role='main'] article") ?? document.querySelector("[role='main']") ?? - document.querySelector("main article") ?? document.querySelector("main") ?? document.querySelector("article") ?? document.body; const clone = contentEl.cloneNode(true) as HTMLElement; - clone - .querySelectorAll( - "script, style, nav, footer, header, iframe, noscript, " + - "svg, img, video, audio, canvas, " + - "[role='navigation'], [role='banner'], [role='contentinfo'], " + - "[aria-hidden='true']" - ) - .forEach((el) => el.remove()); + + clone.querySelectorAll(NOISE_SELECTOR).forEach((el) => el.remove()); + + const elements = clone.querySelectorAll("*"); + for (const el of elements) { + stripAttributes(el); + } + + truncateTextNodes(clone); + + const html = clone.outerHTML; + return html.slice(0, MAX_SKELETON_LENGTH); +} + +// ── Selector application ───────────────────────────────────────────────── + +export function applySelectors(selectors: SelectorMap): RawExtraction | null { + const query = (sel: string | null) => { + if (!sel) return null; + try { + return document.querySelector(sel); + } catch { + return null; + } + }; + + const textOf = (sel: string | null) => query(sel)?.textContent?.trim() ?? null; + + const title = textOf(selectors.title); + const company = textOf(selectors.company); + + if (!title && !company) return null; + + let skillsText: string | null = null; + if (selectors.skills) { + const container = query(selectors.skills); + if (container) { + const children = container.querySelectorAll("li, span, a"); + if (children.length > 0) { + skillsText = Array.from(children) + .map((el) => el.textContent?.trim()) + .filter(Boolean) + .join(", "); + } else { + skillsText = container.textContent?.trim() ?? null; + } + } + } + + const descriptionEl = query(selectors.description); return { + title, + company, + location: textOf(selectors.location), + salaryText: textOf(selectors.salary), + jobTypeText: textOf(selectors.jobType), + skillsText, + descriptionHtml: descriptionEl?.innerHTML ?? "", + postedDateText: textOf(selectors.postedDate), url: window.location.href, - html: clone.innerHTML.slice(0, MAX_CONTENT_LENGTH), - title: document.title, - meta, }; } + +// ── Selector caching ───────────────────────────────────────────────────── + +const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +interface CachedSelectors { + selectors: SelectorMap; + cachedAt: number; +} + +export async function getCachedSelectors(domain: string): Promise { + const key = `selectors_${domain}`; + const { [key]: cached } = await chrome.storage.local.get(key); + if (!cached) return null; + const entry = cached as CachedSelectors; + if (Date.now() - entry.cachedAt > CACHE_TTL_MS) { + await chrome.storage.local.remove(key); + return null; + } + return entry.selectors; +} + +export async function cacheSelectors(domain: string, selectors: SelectorMap): Promise { + const key = `selectors_${domain}`; + await chrome.storage.local.set({ [key]: { selectors, cachedAt: Date.now() } }); +} + +export async function invalidateSelectors(domain: string): Promise { + await chrome.storage.local.remove(`selectors_${domain}`); +} From 5f2298b408ebc0a01a65fac48ec8348fdab038e5 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:31:35 +0100 Subject: [PATCH 40/64] Remove site-specific extractors and simplify registry --- extensions/chrome/src/lib/extractors/ashby.ts | 74 --------- .../chrome/src/lib/extractors/glassdoor.ts | 58 ------- .../chrome/src/lib/extractors/greenhouse.ts | 53 ------ .../chrome/src/lib/extractors/indeed.ts | 88 ---------- extensions/chrome/src/lib/extractors/index.ts | 53 ++---- extensions/chrome/src/lib/extractors/lever.ts | 65 -------- .../chrome/src/lib/extractors/linkedin.ts | 153 ------------------ .../chrome/src/lib/extractors/usajobs.ts | 146 ----------------- 8 files changed, 17 insertions(+), 673 deletions(-) delete mode 100644 extensions/chrome/src/lib/extractors/ashby.ts delete mode 100644 extensions/chrome/src/lib/extractors/glassdoor.ts delete mode 100644 extensions/chrome/src/lib/extractors/greenhouse.ts delete mode 100644 extensions/chrome/src/lib/extractors/indeed.ts delete mode 100644 extensions/chrome/src/lib/extractors/lever.ts delete mode 100644 extensions/chrome/src/lib/extractors/linkedin.ts delete mode 100644 extensions/chrome/src/lib/extractors/usajobs.ts diff --git a/extensions/chrome/src/lib/extractors/ashby.ts b/extensions/chrome/src/lib/extractors/ashby.ts deleted file mode 100644 index 02b9dca..0000000 --- a/extensions/chrome/src/lib/extractors/ashby.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Extractor, ExtractedJob } from "../../types"; -import { inferLocationType, parseSalaryFromText } from "./utils"; - -export const ashbyExtractor: Extractor = { - matches: /^https?:\/\/jobs\.ashbyhq\.com\/[^/]+\/[a-f0-9-]+/, - - extract(): ExtractedJob | null { - const title = - document - .querySelector("h1.ashby-job-posting-brief-title") - ?.textContent?.trim() ?? - document - .querySelector("[data-testid='job-title']") - ?.textContent?.trim(); - - const company = - document - .querySelector(".ashby-job-posting-brief-company-name") - ?.textContent?.trim() ?? - document - .querySelector("[data-testid='company-name']") - ?.textContent?.trim() ?? - document - .querySelector('meta[property="og:site_name"]') - ?.getAttribute("content") ?? - ""; - - if (!title || !company) return null; - - const location = - document - .querySelector(".ashby-job-posting-brief-location") - ?.textContent?.trim() ?? - document - .querySelector("[data-testid='job-location']") - ?.textContent?.trim() ?? - null; - - const compensationText = - document - .querySelector(".ashby-job-posting-compensation") - ?.textContent?.trim() ?? - document - .querySelector("[data-testid='compensation']") - ?.textContent?.trim(); - const salary = parseSalaryFromText(compensationText); - - const descEl = - document.querySelector(".ashby-job-posting-description") ?? - document.querySelector("[data-testid='job-description']"); - const description = descEl?.innerHTML ?? ""; - - const uuidMatch = window.location.pathname.match( - /\/([a-f0-9-]{36})/, - ); - - return { - title, - company, - description, - location, - locationType: inferLocationType(location), - url: window.location.href, - postedAt: null, - jobType: null, - salaryMin: salary.salaryMin, - salaryMax: salary.salaryMax, - currency: salary.currency, - skills: [], - externalId: uuidMatch?.[1] ?? null, - source: "ASHBY", - }; - }, -}; diff --git a/extensions/chrome/src/lib/extractors/glassdoor.ts b/extensions/chrome/src/lib/extractors/glassdoor.ts deleted file mode 100644 index db6e8a1..0000000 --- a/extensions/chrome/src/lib/extractors/glassdoor.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Extractor, ExtractedJob } from "../../types"; -import { inferLocationType, parseSalaryFromText } from "./utils"; - -export const glassdoorExtractor: Extractor = { - matches: /^https?:\/\/(www\.)?glassdoor\.(com|co\.\w+)\/job-listing\//, - - extract(): ExtractedJob | null { - const title = - document - .querySelector("[data-test='job-title']") - ?.textContent?.trim() ?? - document.querySelector(".e1tk4kwz5")?.textContent?.trim(); - - const company = - document - .querySelector("[data-test='employer-name']") - ?.textContent?.trim() ?? - document.querySelector(".e1tk4kwz4")?.textContent?.trim(); - - if (!title || !company) return null; - - const location = - document - .querySelector("[data-test='location']") - ?.textContent?.trim() ?? - document.querySelector(".e1tk4kwz1")?.textContent?.trim() ?? - null; - - const salaryText = - document - .querySelector("[data-test='detailSalary']") - ?.textContent?.trim() ?? - document.querySelector(".e1wijj240")?.textContent?.trim(); - const salary = parseSalaryFromText(salaryText); - - const descEl = - document.querySelector(".jobDescriptionContent") ?? - document.querySelector("[data-test='job-description']"); - const description = descEl?.innerHTML ?? ""; - - return { - title, - company, - description, - location, - locationType: inferLocationType(location), - url: window.location.href, - postedAt: null, - jobType: null, - salaryMin: salary.salaryMin, - salaryMax: salary.salaryMax, - currency: salary.currency, - skills: [], - externalId: null, - source: "CUSTOM", - }; - }, -}; diff --git a/extensions/chrome/src/lib/extractors/greenhouse.ts b/extensions/chrome/src/lib/extractors/greenhouse.ts deleted file mode 100644 index 0aa9438..0000000 --- a/extensions/chrome/src/lib/extractors/greenhouse.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Extractor, ExtractedJob } from "../../types"; -import { inferLocationType } from "./utils"; - -export const greenhouseExtractor: Extractor = { - matches: - /^https?:\/\/(boards|jobs)\.greenhouse\.io\/[^/]+\/(jobs\/\d+|embed\/job_app)/, - - extract(): ExtractedJob | null { - const title = - document - .querySelector(".app-title, #header .company-name + h1") - ?.textContent?.trim() ?? - document.querySelector("h1")?.textContent?.trim(); - - const company = - document - .querySelector(".company-name, #header .company-name") - ?.textContent?.trim() ?? - document - .querySelector('meta[property="og:site_name"]') - ?.getAttribute("content") ?? - ""; - - if (!title || !company) return null; - - const location = - document - .querySelector(".location, .body--metadata--location") - ?.textContent?.trim() ?? null; - - const descEl = document.querySelector("#content, .body--content"); - const description = descEl?.innerHTML ?? ""; - - const idMatch = window.location.pathname.match(/\/jobs\/(\d+)/); - - return { - title, - company, - description, - location, - locationType: inferLocationType(location), - url: window.location.href, - postedAt: null, - jobType: null, - salaryMin: null, - salaryMax: null, - currency: null, - skills: [], - externalId: idMatch?.[1] ?? null, - source: "GREENHOUSE", - }; - }, -}; diff --git a/extensions/chrome/src/lib/extractors/indeed.ts b/extensions/chrome/src/lib/extractors/indeed.ts deleted file mode 100644 index 643cfc6..0000000 --- a/extensions/chrome/src/lib/extractors/indeed.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { Extractor, ExtractedJob } from "../../types"; -import { inferLocationType, parseJobType, parseSalaryFromText } from "./utils"; - -function cleanIndeedUrl(): string { - const url = new URL(window.location.href); - const jk = url.searchParams.get("jk"); - if (jk) { - return `${url.origin}${url.pathname}?jk=${jk}`; - } - return `${url.origin}${url.pathname}`; -} - -export const indeedExtractor: Extractor = { - matches: /^https?:\/\/(www\.)?indeed\.com\/viewjob/, - - extract(): ExtractedJob | null { - const title = - document - .querySelector(".jobsearch-JobInfoHeader-title") - ?.textContent?.trim() ?? - document - .querySelector( - "[data-testid='jobsearch-JobInfoHeader-title'] span", - ) - ?.textContent?.trim(); - - const company = - document - .querySelector("[data-testid='inlineHeader-companyName'] a") - ?.textContent?.trim() ?? - document - .querySelector(".jobsearch-CompanyInfoContainer a") - ?.textContent?.trim(); - - if (!title || !company) return null; - - const location = - document - .querySelector("[data-testid='inlineHeader-companyLocation']") - ?.textContent?.trim() ?? - document - .querySelector( - ".jobsearch-CompanyInfoContainer .companyLocation", - ) - ?.textContent?.trim() ?? - null; - - const salaryText = - document - .querySelector("#salaryInfoAndJobType span") - ?.textContent?.trim() ?? - document - .querySelector( - "[data-testid='attribute_snippet_testid'] span", - ) - ?.textContent?.trim(); - const salary = parseSalaryFromText(salaryText); - - const descEl = - document.querySelector("#jobDescriptionText") ?? - document.querySelector(".jobsearch-JobComponent-description"); - const description = descEl?.innerHTML ?? ""; - - const jobTypeText = - document - .querySelector(".jobsearch-JobMetadataHeader-item") - ?.textContent?.trim() ?? null; - - const jk = new URL(window.location.href).searchParams.get("jk"); - - return { - title, - company, - description, - location, - locationType: inferLocationType(location), - url: cleanIndeedUrl(), - postedAt: null, - jobType: parseJobType(jobTypeText), - salaryMin: salary.salaryMin, - salaryMax: salary.salaryMax, - currency: salary.currency, - skills: [], - externalId: jk ?? null, - source: "INDEED", - }; - }, -}; diff --git a/extensions/chrome/src/lib/extractors/index.ts b/extensions/chrome/src/lib/extractors/index.ts index 3a1457f..99a5351 100644 --- a/extensions/chrome/src/lib/extractors/index.ts +++ b/extensions/chrome/src/lib/extractors/index.ts @@ -1,41 +1,22 @@ -import { linkedinExtractor } from "./linkedin"; -import { greenhouseExtractor } from "./greenhouse"; -import { leverExtractor } from "./lever"; -import { ashbyExtractor } from "./ashby"; -import { usajobsExtractor } from "./usajobs"; -import { indeedExtractor } from "./indeed"; -import { glassdoorExtractor } from "./glassdoor"; -import { collectPageContent } from "./generic"; -import type { Extractor, ExtractionResult } from "../../types"; +import { getCachedSelectors, applySelectors, stripDomSkeleton } from "./generic"; +import type { ExtractionResult } from "../../types"; -const extractors: Extractor[] = [ - linkedinExtractor, - greenhouseExtractor, - leverExtractor, - ashbyExtractor, - usajobsExtractor, - indeedExtractor, - glassdoorExtractor, -]; +export async function extractFromPage(): Promise { + const domain = window.location.hostname; -/** - * Attempt extraction using a site-specific extractor first, then fall back - * to the generic page content collector for AI extraction on the server. - */ -export function extractFromPage(): ExtractionResult { - const url = window.location.href; - - for (const extractor of extractors) { - if (extractor.matches.test(url)) { - const data = extractor.extract(); - if (data) return { type: "structured", data }; - // Extractor matched URL but failed to parse -- fall through to generic - break; - } + const cached = await getCachedSelectors(domain); + if (cached) { + const raw = applySelectors(cached); + if (raw) return { type: "extracted", raw }; } - // Generic fallback: collect raw page content for AI extraction. - // The user clicked the extension, so trust that there's a job on this page. - const content = collectPageContent(); - return { type: "generic", ...content }; + const skeleton = stripDomSkeleton(); + if (!skeleton || skeleton.length < 50) return { type: "none" }; + + return { + type: "needs_identification", + skeleton, + url: window.location.href, + domain, + }; } diff --git a/extensions/chrome/src/lib/extractors/lever.ts b/extensions/chrome/src/lib/extractors/lever.ts deleted file mode 100644 index 7f36dbe..0000000 --- a/extensions/chrome/src/lib/extractors/lever.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Extractor, ExtractedJob } from "../../types"; -import { inferLocationType, parseJobType } from "./utils"; - -export const leverExtractor: Extractor = { - matches: /^https?:\/\/jobs\.lever\.co\/[^/]+\/[a-f0-9-]+/, - - extract(): ExtractedJob | null { - const title = document - .querySelector(".posting-headline h2") - ?.textContent?.trim(); - - const company = - document - .querySelector(".posting-headline .company-name, .main-header-logo img") - ?.getAttribute("alt") ?? - document - .querySelector('meta[property="og:site_name"]') - ?.getAttribute("content") ?? - ""; - - if (!title || !company) return null; - - // Lever puts location and work type in category tags - const categories = document.querySelectorAll( - ".posting-categories .posting-category", - ); - let location: string | null = null; - let commitment: string | null = null; - - categories.forEach((el) => { - const text = el.textContent?.trim() ?? ""; - const label = el.querySelector( - ".sort-by-commitment, .sort-by-location, .sort-by-team", - ); - if (label?.classList.contains("sort-by-location")) location = text; - if (label?.classList.contains("sort-by-commitment")) commitment = text; - }); - - const descEl = document.querySelector( - ".posting-page .content, .posting-description", - ); - const description = descEl?.innerHTML ?? ""; - - const uuidMatch = window.location.pathname.match( - /\/([a-f0-9-]{36})/, - ); - - return { - title, - company, - description, - location, - locationType: inferLocationType(location), - url: window.location.href, - postedAt: null, - jobType: parseJobType(commitment), - salaryMin: null, - salaryMax: null, - currency: null, - skills: [], - externalId: uuidMatch?.[1] ?? null, - source: "LEVER", - }; - }, -}; diff --git a/extensions/chrome/src/lib/extractors/linkedin.ts b/extensions/chrome/src/lib/extractors/linkedin.ts deleted file mode 100644 index 4c2f272..0000000 --- a/extensions/chrome/src/lib/extractors/linkedin.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { Extractor, ExtractedJob } from "../../types"; -import { inferLocationType, parseSalaryFromText } from "./utils"; - -function parseSalaryRange(text: string | undefined): { - salaryMin: number | null; - salaryMax: number | null; - currency: string | null; -} { - return parseSalaryFromText(text); -} - -function extractLinkedInDate(text: string | null): string | null { - if (!text) return null; - const lower = text.toLowerCase().trim(); - - const match = lower.match(/(\d+)\s+(minute|hour|day|week|month)s?\s+ago/); - if (!match) return null; - - const amount = parseInt(match[1], 10); - const unit = match[2]; - const now = new Date(); - - switch (unit) { - case "minute": - now.setMinutes(now.getMinutes() - amount); - break; - case "hour": - now.setHours(now.getHours() - amount); - break; - case "day": - now.setDate(now.getDate() - amount); - break; - case "week": - now.setDate(now.getDate() - amount * 7); - break; - case "month": - now.setMonth(now.getMonth() - amount); - break; - } - - return now.toISOString(); -} - -function extractLinkedInSkills(): string[] { - const skills: string[] = []; - document - .querySelectorAll(".job-details-skill-match-status-list li span") - .forEach((el) => { - const text = el.textContent?.trim(); - if (text) skills.push(text); - }); - return skills; -} - -export const linkedinExtractor: Extractor = { - matches: /^https?:\/\/(www\.)?linkedin\.com\/jobs\/(view|collections|search)\//, - - extract(): ExtractedJob | null { - const title = - document - .querySelector( - ".job-details-jobs-unified-top-card__job-title h1", - ) - ?.textContent?.trim() ?? - document.querySelector(".top-card-layout__title")?.textContent?.trim(); - - const company = - document - .querySelector( - ".job-details-jobs-unified-top-card__company-name a", - ) - ?.textContent?.trim() ?? - document - .querySelector(".topcard__org-name-link") - ?.textContent?.trim(); - - if (!title || !company) return null; - - const location = - document - .querySelector( - ".job-details-jobs-unified-top-card__bullet", - ) - ?.textContent?.trim() ?? - document - .querySelector(".topcard__flavor--bullet") - ?.textContent?.trim() ?? - null; - - const workplaceType = - document - .querySelector( - ".job-details-jobs-unified-top-card__workplace-type", - ) - ?.textContent?.trim() ?? null; - - const locationType = - workplaceType?.toLowerCase().includes("remote") - ? "REMOTE" as const - : workplaceType?.toLowerCase().includes("hybrid") - ? "HYBRID" as const - : workplaceType?.toLowerCase().includes("on-site") - ? "ONSITE" as const - : inferLocationType(location); - - const salaryText = - document - .querySelector( - ".job-details-jobs-unified-top-card__job-insight--highlight span", - ) - ?.textContent?.trim(); - const salary = parseSalaryRange(salaryText); - - const descEl = - document.querySelector(".jobs-description__content") ?? - document.querySelector(".show-more-less-html__markup"); - const description = descEl?.innerHTML ?? ""; - - const postedText = - document - .querySelector( - ".job-details-jobs-unified-top-card__posted-date", - ) - ?.textContent?.trim() ?? null; - const postedAt = extractLinkedInDate(postedText); - - const skills = extractLinkedInSkills(); - - const pathMatch = window.location.pathname.match(/\/jobs\/view\/(\d+)/); - const queryId = new URLSearchParams(window.location.search).get("currentJobId"); - const jobId = pathMatch?.[1] ?? queryId; - const cleanUrl = jobId - ? `https://www.linkedin.com/jobs/view/${jobId}/` - : window.location.origin + window.location.pathname; - - return { - title, - company, - description, - location, - locationType, - url: cleanUrl, - postedAt, - jobType: null, - salaryMin: salary.salaryMin, - salaryMax: salary.salaryMax, - currency: salary.currency, - skills, - externalId: jobId ?? null, - source: "LINKEDIN", - }; - }, -}; diff --git a/extensions/chrome/src/lib/extractors/usajobs.ts b/extensions/chrome/src/lib/extractors/usajobs.ts deleted file mode 100644 index 8e0cde0..0000000 --- a/extensions/chrome/src/lib/extractors/usajobs.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { Extractor, ExtractedJob } from "../../types"; - -function parseUsajobsSalary(text: string | null): { - salaryMin: number | null; - salaryMax: number | null; -} { - if (!text) return { salaryMin: null, salaryMax: null }; - - const match = text.match( - /\$\s*([\d,]+(?:\.\d+)?)\s*[-\u2013to]+\s*\$\s*([\d,]+(?:\.\d+)?)/, - ); - if (!match) return { salaryMin: null, salaryMax: null }; - - return { - salaryMin: parseFloat(match[1].replace(/,/g, "")), - salaryMax: parseFloat(match[2].replace(/,/g, "")), - }; -} - -function parseAppointmentType( - text: string | null, -): "FULL_TIME" | "CONTRACT" | "INTERNSHIP" | null { - if (!text) return null; - const lower = text.toLowerCase(); - if (lower.includes("intern")) return "INTERNSHIP"; - if (lower.includes("temporary") || lower.includes("term")) return "CONTRACT"; - if (lower.includes("permanent")) return "FULL_TIME"; - return null; -} - -function parseUsajobsDate(text: string | null): string | null { - if (!text) return null; - const match = text.match(/(\d{2})\/(\d{2})\/(\d{4})/); - if (!match) return null; - const [, month, day, year] = match; - const date = new Date(`${year}-${month}-${day}T00:00:00Z`); - return isNaN(date.getTime()) ? null : date.toISOString(); -} - -function extractUsajobsSkills(): string[] { - const skills: string[] = []; - const qualSection = - document.querySelector("#qualifications") ?? - document.querySelector(".usajobs-joa-qualifications"); - if (!qualSection) return skills; - - qualSection.querySelectorAll("li").forEach((el) => { - const text = el.textContent?.trim(); - if (text && text.length < 100) skills.push(text); - }); - return skills.slice(0, 20); -} - -export const usajobsExtractor: Extractor = { - matches: /^https?:\/\/(www\.)?usajobs\.gov\/job\//, - - extract(): ExtractedJob | null { - let title = - document.querySelector("#job-title")?.textContent?.trim() ?? - document - .querySelector(".usajobs-joa-banner__title h1") - ?.textContent?.trim(); - - const company = - document.querySelector("#agency-name")?.textContent?.trim() ?? - document - .querySelector(".usajobs-joa-banner__agency") - ?.textContent?.trim() ?? - "U.S. Government"; - - if (!title) return null; - - const grade = - document.querySelector("#grade")?.textContent?.trim() ?? - document - .querySelector(".usajobs-joa-summary__grade") - ?.textContent?.trim(); - if (grade) { - const gsMatch = grade.match(/GS[- ]?\d+/i); - if (gsMatch) title = `${title} (${gsMatch[0].toUpperCase()})`; - } - - const location = - document.querySelector("#locations")?.textContent?.trim() ?? - document - .querySelector(".usajobs-joa-locations") - ?.textContent?.trim() ?? - null; - - const salaryText = - document.querySelector("#salary-range")?.textContent?.trim() ?? - document - .querySelector(".usajobs-joa-summary__salary") - ?.textContent?.trim(); - const salary = parseUsajobsSalary(salaryText ?? null); - - const telework = - document - .querySelector("#telework-eligible, .usajobs-joa-summary__telework") - ?.textContent?.trim() ?? null; - const locationType = telework?.toLowerCase().includes("yes") - ? ("REMOTE" as const) - : null; - - const appointmentText = - document - .querySelector( - "#appointment-type, .usajobs-joa-summary__appointment-type", - ) - ?.textContent?.trim() ?? null; - const jobType = parseAppointmentType(appointmentText); - - const descEl = - document.querySelector("#duties") ?? - document.querySelector(".usajobs-joa-duties") ?? - document.querySelector("#qualifications"); - const description = descEl?.innerHTML ?? ""; - - const openDateText = - document - .querySelector("#opening-date, .usajobs-joa-summary__opening-date") - ?.textContent?.trim() ?? null; - const postedAt = parseUsajobsDate(openDateText); - - const skills = extractUsajobsSkills(); - - const idMatch = window.location.pathname.match(/\/job\/(\d+)/); - - return { - title, - company, - description, - location, - locationType, - url: window.location.href, - postedAt, - jobType, - salaryMin: salary.salaryMin, - salaryMax: salary.salaryMax, - currency: "USD", - skills, - externalId: idMatch?.[1] ?? null, - source: "USAJOBS", - }; - }, -}; From 56da2384f5e276a91470541433a23791165f4fec Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:32:16 +0100 Subject: [PATCH 41/64] Add selector identification and normalization API helpers --- extensions/chrome/src/lib/api.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/extensions/chrome/src/lib/api.ts b/extensions/chrome/src/lib/api.ts index e848837..53e4b49 100644 --- a/extensions/chrome/src/lib/api.ts +++ b/extensions/chrome/src/lib/api.ts @@ -1,3 +1,5 @@ +import type { SelectorMap, ExtractedJob } from "../types"; + const PROD_URL = "https://shortlist.johnmoorman.com"; const DEV_URL = "http://localhost:3000"; @@ -137,4 +139,32 @@ export function extractJob(payload: { input: string; profileId: string }) { }); } +export function identifySelectors(payload: { + skeleton: string; + profileId: string; +}): Promise> { + return apiCall("/api/jobs/extract/identify", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function normalizeExtraction(payload: { + title: string | null; + company: string | null; + location: string | null; + salaryText: string | null; + jobTypeText: string | null; + skillsText: string | null; + descriptionHtml: string; + postedDateText: string | null; + url: string; + profileId: string; +}): Promise> { + return apiCall("/api/jobs/extract/normalize", { + method: "POST", + body: JSON.stringify(payload), + }); +} + export { DEV_URL, PROD_URL }; From 8cc60419a2602c14d8b90eac03f5960b0133b8eb Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:32:49 +0100 Subject: [PATCH 42/64] Update service worker for two-step extraction messages --- .../chrome/src/background/service-worker.ts | 67 ++++++++++++------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/extensions/chrome/src/background/service-worker.ts b/extensions/chrome/src/background/service-worker.ts index 0e2b829..6145ea3 100644 --- a/extensions/chrome/src/background/service-worker.ts +++ b/extensions/chrome/src/background/service-worker.ts @@ -2,14 +2,10 @@ import { checkAuth, fetchProfiles, importJob, - extractJob, + identifySelectors, + normalizeExtraction, } from "../lib/api"; -import type { Message, ExtractedJob, ImportRecord } from "../types"; - -/** - * Background service worker — coordinates between popup, content script, - * and the Shortlist API. - */ +import type { Message, ExtractedJob, RawExtraction, ImportRecord } from "../types"; // ── Import history ────────────────────────────────────────────────────── @@ -37,7 +33,7 @@ chrome.runtime.onMessage.addListener( switch (message.type) { case "GET_AUTH_STATUS": handleAuthStatus(sendResponse); - return true; // keep channel open for async + return true; case "GET_PROFILES": handleGetProfiles(sendResponse); @@ -47,12 +43,15 @@ chrome.runtime.onMessage.addListener( handleImportJob(message.profileId, message.profileName, message.job, sendResponse); return true; - case "EXTRACT_AND_IMPORT": - handleExtractAndImport( + case "IDENTIFY_SELECTORS": + handleIdentifySelectors(message.skeleton, message.profileId, sendResponse); + return true; + + case "NORMALIZE_AND_IMPORT": + handleNormalizeAndImport( + message.raw, message.profileId, message.profileName, - message.html, - message.url, sendResponse, ); return true; @@ -132,40 +131,62 @@ async function handleImportJob( }); } -async function handleExtractAndImport( +async function handleIdentifySelectors( + skeleton: string, + profileId: string, + sendResponse: (response: unknown) => void, +): Promise { + const result = await identifySelectors({ skeleton, profileId }); + + if (result.ok && result.data?.selectors) { + sendResponse({ + type: "SELECTORS_IDENTIFIED", + selectors: result.data.selectors, + }); + } else { + sendResponse({ + type: "IMPORT_RESULT", + ok: false, + error: result.error ?? "Could not identify job fields on this page", + }); + } +} + +async function handleNormalizeAndImport( + raw: RawExtraction, profileId: string, profileName: string, - html: string, - url: string, sendResponse: (response: unknown) => void, ): Promise { - const extractResult = await extractJob({ input: html, profileId }); - if (!extractResult.ok || !extractResult.data) { + const normalizeResult = await normalizeExtraction({ ...raw, profileId }); + if (!normalizeResult.ok || !normalizeResult.data) { sendResponse({ - type: "EXTRACT_AND_IMPORT_RESULT", + type: "IMPORT_RESULT", ok: false, - error: extractResult.error ?? "Extraction failed", + error: normalizeResult.error ?? "Normalization failed", }); return; } - const extracted = extractResult.data; + const extracted = normalizeResult.data; const importResult = await importJob({ profileId, - originalInput: url, + originalInput: raw.url, title: extracted.title, company: extracted.company, description: extracted.description, location: extracted.location, locationType: extracted.locationType, - url: extracted.url ?? url, + url: extracted.url ?? raw.url, postedAt: extracted.postedAt, jobType: extracted.jobType, salaryMin: extracted.salaryMin, salaryMax: extracted.salaryMax, currency: extracted.currency, skills: extracted.skills, + source: extracted.source, + externalId: extracted.externalId, }); const jobId = importResult.data?.job?.id; @@ -182,7 +203,7 @@ async function handleExtractAndImport( } sendResponse({ - type: "EXTRACT_AND_IMPORT_RESULT", + type: importResult.ok ? "IMPORT_COMPLETE" : "IMPORT_RESULT", ok: importResult.ok, jobId, error: importResult.error, From 21a7d698de4d83a55d94cf5126d6d74e7d4a6972 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:33:10 +0100 Subject: [PATCH 43/64] Update content script with async extraction and selector application --- extensions/chrome/src/content/content.ts | 27 +++++++++++++++--------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/extensions/chrome/src/content/content.ts b/extensions/chrome/src/content/content.ts index 738a7ce..f0d1fd7 100644 --- a/extensions/chrome/src/content/content.ts +++ b/extensions/chrome/src/content/content.ts @@ -1,13 +1,7 @@ import { extractFromPage } from "../lib/extractors"; +import { applySelectors, cacheSelectors } from "../lib/extractors/generic"; import type { Message } from "../types"; -/** - * Content script — injected on demand via chrome.scripting.executeScript - * when the user opens the popup, or pre-injected on known job board URLs. - * - * Listens for EXTRACT messages from the popup and returns extraction results. - */ - chrome.runtime.onMessage.addListener( ( message: Message, @@ -15,9 +9,22 @@ chrome.runtime.onMessage.addListener( sendResponse: (response: Message) => void, ) => { if (message.type === "EXTRACT") { - const result = extractFromPage(); - sendResponse({ type: "EXTRACTED", result }); + extractFromPage().then((result) => { + sendResponse({ type: "EXTRACTED", result }); + }); + return true; + } + if (message.type === "APPLY_SELECTORS") { + const raw = applySelectors(message.selectors); + if (raw) { + const domain = window.location.hostname; + cacheSelectors(domain, message.selectors); + } + sendResponse({ + type: "SELECTORS_APPLIED", + result: raw ? { type: "extracted", raw } : { type: "none" }, + }); + return true; } - return true; }, ); From 4817d634b57f4e38cbd84af3b43e0cb70d75394a Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:34:17 +0100 Subject: [PATCH 44/64] Rewrite popup for multi-step extraction with status flow --- extensions/chrome/src/popup/popup.tsx | 322 ++++++++++++++------------ 1 file changed, 178 insertions(+), 144 deletions(-) diff --git a/extensions/chrome/src/popup/popup.tsx b/extensions/chrome/src/popup/popup.tsx index 20e7b23..545c754 100644 --- a/extensions/chrome/src/popup/popup.tsx +++ b/extensions/chrome/src/popup/popup.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; -import type { ExtractionResult, ExtractedJob, ImportRecord, Message } from "../types"; +import type { ExtractionResult, RawExtraction, ImportRecord, SelectorMap, Message } from "../types"; import { getBaseUrl } from "../lib/api"; // ── Types ──────────────────────────────────────────────────────────────── @@ -11,7 +11,15 @@ interface Profile { isActive: boolean; } -type Status = "idle" | "loading" | "extracting" | "importing" | "success" | "error"; +type Status = + | "idle" + | "loading" + | "reading" + | "identifying" + | "extracting" + | "importing" + | "success" + | "error"; // ── Helpers ────────────────────────────────────────────────────────────── @@ -35,23 +43,29 @@ function timeAgo(isoDate: string): string { return `${days}d ago`; } +const STATUS_LABELS: Partial> = { + reading: "Reading page...", + identifying: "Identifying job fields...", + extracting: "Extracting content...", + importing: "Importing...", +}; + // ── App ────────────────────────────────────────────────────────────────── function Popup() { const [authenticated, setAuthenticated] = useState(null); const [profiles, setProfiles] = useState([]); const [selectedProfileId, setSelectedProfileId] = useState(""); - const [extraction, setExtraction] = useState(null); + const [rawExtraction, setRawExtraction] = useState(null); const [status, setStatus] = useState("loading"); const [errorMessage, setErrorMessage] = useState(""); const [importedJobId, setImportedJobId] = useState(null); const [importHistory, setImportHistory] = useState([]); const [baseUrl, setBaseUrl] = useState("https://shortlist.johnmoorman.com"); + const [aiIdentified, setAiIdentified] = useState(false); - // Initialize: check auth, fetch profiles, extract from current tab useEffect(() => { async function init() { - // 0. Resolve base URL for links const url = await getBaseUrl(); setBaseUrl(url); @@ -84,76 +98,142 @@ function Popup() { const profs = profilesResult.profiles ?? []; setProfiles(profs); - // Default to the active profile const active = profs.find((p) => p.isActive); if (active) setSelectedProfileId(active.id); else if (profs.length > 0) setSelectedProfileId(profs[0].id); // 4. Extract from the current tab - setStatus("extracting"); + setStatus("reading"); try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true, }); - if (tab?.id) { - let result: { type: string; result: ExtractionResult } | null = null; + if (!tab?.id) { + setStatus("idle"); + return; + } + + let extraction: ExtractionResult | null = null; - // Try messaging the content script (already injected via manifest) + try { + const result = await sendTabMessage<{ + type: string; + result: ExtractionResult; + }>(tab.id, { type: "EXTRACT" }); + extraction = result?.result ?? null; + } catch { + // Content script not present — fall back to scripting API try { - result = await sendTabMessage<{ - type: string; - result: ExtractionResult; - }>(tab.id, { type: "EXTRACT" }); + const [injected] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => { + const el = + document.querySelector("[role='main'] article") ?? + document.querySelector("[role='main']") ?? + document.querySelector("main") ?? + document.querySelector("article") ?? + document.body; + const clone = el.cloneNode(true) as HTMLElement; + clone.querySelectorAll( + "script, style, nav, footer, header, iframe, noscript, " + + "svg, img, video, audio, canvas, [aria-hidden='true']" + ).forEach((n) => n.remove()); + return { + skeleton: clone.innerHTML.slice(0, 15000), + url: window.location.href, + domain: window.location.hostname, + }; + }, + }); + if (injected?.result) { + const r = injected.result; + if (r.skeleton && r.skeleton.length >= 50) { + extraction = { + type: "needs_identification", + skeleton: r.skeleton, + url: r.url, + domain: r.domain, + }; + } + } } catch { - // Content script not present (page loaded before extension install/refresh). - // Fall back to collecting page content directly via scripting API. + // Page is restricted (chrome://, edge://, etc.) + } + } + + if (!extraction || extraction.type === "none") { + setStatus("idle"); + return; + } + + // 5. Handle extraction result + if (extraction.type === "extracted") { + setRawExtraction(extraction.raw); + setStatus("idle"); + return; + } + + // 6. Needs AI identification + if (extraction.type === "needs_identification") { + const profileId = active?.id ?? profs[0]?.id; + if (!profileId) { + setStatus("error"); + setErrorMessage("No profile found. Create one in Shortlist first."); + return; + } + + setStatus("identifying"); + const identifyResult = await sendMessage<{ + type: string; + selectors?: SelectorMap; + ok?: boolean; + error?: string; + }>({ + type: "IDENTIFY_SELECTORS", + skeleton: extraction.skeleton, + profileId, + }); + + if (!identifyResult.selectors) { + setStatus("error"); + setErrorMessage(identifyResult.error ?? "Could not identify job fields on this page"); + return; + } + + setStatus("extracting"); + setAiIdentified(true); + + // Apply selectors in the content script + if (tab.id) { try { - const [injected] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: () => { - const el = - document.querySelector("[role='main'] article") ?? - document.querySelector("[role='main']") ?? - document.querySelector("main") ?? - document.querySelector("article") ?? - document.body; - const clone = el.cloneNode(true) as HTMLElement; - clone.querySelectorAll( - "script, style, nav, footer, header, iframe, noscript, " + - "svg, img, video, audio, canvas, [aria-hidden='true']" - ).forEach((n) => n.remove()); - return { - url: window.location.href, - html: clone.innerHTML.slice(0, 50000), - title: document.title, - }; - }, + const applyResult = await sendTabMessage<{ + type: string; + result: ExtractionResult; + }>(tab.id, { + type: "APPLY_SELECTORS", + selectors: identifyResult.selectors, }); - if (injected?.result) { - result = { - type: "EXTRACTED", - result: { - type: "generic", - url: injected.result.url, - html: injected.result.html, - title: injected.result.title, - meta: {}, - }, - }; + + if (applyResult?.result?.type === "extracted") { + setRawExtraction(applyResult.result.raw); + setStatus("idle"); + } else { + setStatus("error"); + setErrorMessage("Could not extract job details from this page"); } } catch { - // Page is restricted (chrome://, edge://, etc.) + // Content script not injected — cannot apply selectors + setStatus("error"); + setErrorMessage("Could not extract job details from this page"); } } - - setExtraction(result?.result ?? null); + return; } } catch { - setExtraction(null); + setStatus("error"); + setErrorMessage("Something went wrong reading this page"); } - - setStatus("idle"); } init(); @@ -169,73 +249,40 @@ function Popup() { } async function handleImport() { - if (!selectedProfileId || !extraction) return; + if (!selectedProfileId || !rawExtraction) return; setStatus("importing"); setErrorMessage(""); try { - if (extraction.type === "structured") { - const result = await sendMessage<{ - type: string; - ok: boolean; - jobId?: string; - error?: string; - }>({ - type: "IMPORT_JOB", - profileId: selectedProfileId, - profileName: selectedProfileName, - job: extraction.data, - }); - - if (result.ok) { - setStatus("success"); - setImportedJobId(result.jobId ?? null); - if (result.jobId) { - addToHistory({ - jobId: result.jobId, - title: extraction.data.title, - company: extraction.data.company, - source: extraction.data.source, - importedAt: new Date().toISOString(), - profileName: selectedProfileName, - }); - } - } else { - setStatus("error"); - setErrorMessage(result.error ?? "Import failed"); - } - } else if (extraction.type === "generic") { - const result = await sendMessage<{ - type: string; - ok: boolean; - jobId?: string; - error?: string; - }>({ - type: "EXTRACT_AND_IMPORT", - profileId: selectedProfileId, - profileName: selectedProfileName, - html: extraction.html, - url: extraction.url, - }); - - if (result.ok) { - setStatus("success"); - setImportedJobId(result.jobId ?? null); - if (result.jobId) { - addToHistory({ - jobId: result.jobId, - title: extraction.title || "Imported job", - company: "Via AI extraction", - source: "CUSTOM", - importedAt: new Date().toISOString(), - profileName: selectedProfileName, - }); - } - } else { - setStatus("error"); - setErrorMessage(result.error ?? "Import failed"); + const result = await sendMessage<{ + type: string; + ok?: boolean; + jobId?: string; + error?: string; + }>({ + type: "NORMALIZE_AND_IMPORT", + raw: rawExtraction, + profileId: selectedProfileId, + profileName: selectedProfileName, + }); + + if (result.ok || result.type === "IMPORT_COMPLETE") { + setStatus("success"); + setImportedJobId(result.jobId ?? null); + if (result.jobId) { + addToHistory({ + jobId: result.jobId, + title: rawExtraction.title ?? "Imported job", + company: rawExtraction.company ?? "Unknown company", + source: "CUSTOM", + importedAt: new Date().toISOString(), + profileName: selectedProfileName, + }); } + } else { + setStatus("error"); + setErrorMessage(result.error ?? "Import failed"); } } catch { setStatus("error"); @@ -306,33 +353,34 @@ function Popup() { ); } - + const statusLabel = STATUS_LABELS[status]; return (
{/* Job preview */} - {extraction?.type === "structured" && ( - - )} - {extraction?.type === "generic" && ( + {rawExtraction && status === "idle" && (
-
{extraction.title || document.title || "This page"}
-
- AI will extract job details from this page -
+
{rawExtraction.title || "Untitled"}
+
{rawExtraction.company || "Unknown company"}
+ {rawExtraction.location && ( +
{rawExtraction.location}
+ )} + {aiIdentified && ( +
AI-identified
+ )}
)} - {extraction === null && status === "idle" && ( + {!rawExtraction && status === "idle" && (

Could not read this page. Try reloading it first.

)} - {/* Extracting state */} - {status === "extracting" && ( -
Reading page...
+ {/* Status message */} + {statusLabel && ( +
{statusLabel}
)} {/* Profile selector */} @@ -353,17 +401,13 @@ function Popup() { )} {/* Import button */} - {extraction && status !== "extracting" && ( + {rawExtraction && status === "idle" && ( )} @@ -387,16 +431,6 @@ function Header() { ); } -function JobPreview({ job }: { job: ExtractedJob }) { - return ( -
-
{job.title}
-
{job.company}
- {job.location &&
{job.location}
} -
- ); -} - function ImportHistory({ history, baseUrl }: { history: ImportRecord[]; baseUrl: string }) { if (history.length === 0) return null; From 9b95193b461182b53ef4f675983c4ff21c3be278 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:06:44 +0100 Subject: [PATCH 45/64] Apply selectors via scripting API when content script is unavailable --- extensions/chrome/src/popup/popup.tsx | 65 ++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/extensions/chrome/src/popup/popup.tsx b/extensions/chrome/src/popup/popup.tsx index 545c754..8a2d389 100644 --- a/extensions/chrome/src/popup/popup.tsx +++ b/extensions/chrome/src/popup/popup.tsx @@ -204,8 +204,10 @@ function Popup() { setStatus("extracting"); setAiIdentified(true); - // Apply selectors in the content script + // Apply selectors — try content script first, fall back to scripting API if (tab.id) { + let applied = false; + try { const applyResult = await sendTabMessage<{ type: string; @@ -217,13 +219,64 @@ function Popup() { if (applyResult?.result?.type === "extracted") { setRawExtraction(applyResult.result.raw); - setStatus("idle"); - } else { - setStatus("error"); - setErrorMessage("Could not extract job details from this page"); + applied = true; } } catch { - // Content script not injected — cannot apply selectors + // Content script not injected — try scripting API + } + + if (!applied) { + // Apply selectors directly via scripting API + try { + const sels = identifyResult.selectors; + const [injected] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: (selectors: Record) => { + const q = (sel: string | null) => { + if (!sel) return null; + try { return document.querySelector(sel); } catch { return null; } + }; + const textOf = (sel: string | null) => q(sel)?.textContent?.trim() ?? null; + const title = textOf(selectors.title); + const company = textOf(selectors.company); + if (!title && !company) return null; + let skillsText: string | null = null; + if (selectors.skills) { + const container = q(selectors.skills); + if (container) { + const children = container.querySelectorAll("li, span, a"); + skillsText = children.length > 0 + ? Array.from(children).map(e => e.textContent?.trim()).filter(Boolean).join(", ") + : container.textContent?.trim() ?? null; + } + } + const descEl = q(selectors.description); + return { + title, company, + location: textOf(selectors.location), + salaryText: textOf(selectors.salary), + jobTypeText: textOf(selectors.jobType), + skillsText, + descriptionHtml: descEl?.innerHTML ?? "", + postedDateText: textOf(selectors.postedDate), + url: window.location.href, + }; + }, + args: [sels as Record], + }); + + if (injected?.result) { + setRawExtraction(injected.result as RawExtraction); + applied = true; + } + } catch { + // scripting API also failed + } + } + + if (applied) { + setStatus("idle"); + } else { setStatus("error"); setErrorMessage("Could not extract job details from this page"); } From 333f145fa8f4dc7973def3771820cd9957bd6bba Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:37:02 +0100 Subject: [PATCH 46/64] Make normalize schema lenient and fall back to raw fields on AI failure --- src/app/api/jobs/extract/normalize/route.ts | 62 ++++++++++----------- src/lib/validations.ts | 16 +++--- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/src/app/api/jobs/extract/normalize/route.ts b/src/app/api/jobs/extract/normalize/route.ts index cb50cc5..5181aca 100644 --- a/src/app/api/jobs/extract/normalize/route.ts +++ b/src/app/api/jobs/extract/normalize/route.ts @@ -55,7 +55,8 @@ export async function POST(req: Request) { const body = await req.json().catch(() => null); const parsed = normalizeExtractionSchema.safeParse(body); if (!parsed.success) { - return Response.json({ error: "Invalid request" }, { status: 400 }); + console.error("[/api/jobs/extract/normalize] Validation failed:", parsed.error.flatten()); + return Response.json({ error: "Invalid request", details: parsed.error.flatten().fieldErrors }, { status: 400 }); } const { profileId } = parsed.data; @@ -123,45 +124,42 @@ export async function POST(req: Request) { const text = response.choices[0]?.message?.content ?? ""; - let result: NormalizeResult; + let result: NormalizeResult | null = null; try { const direct = JSON.parse(text.trim()); - if (isValidResult(direct)) { - result = direct; - } else { - throw new Error("Invalid shape"); - } - } catch { - const match = text.match(/\{[\s\S]*\}/); - if (!match) { - console.error("[/api/jobs/extract/normalize] No JSON in response:", text.slice(0, 200)); - return Response.json( - { error: "Could not normalize the extracted data." }, - { status: 422 }, - ); - } + if (isValidResult(direct)) result = direct; + } catch {} + + if (!result) { try { - const extracted = JSON.parse(match[0]); - if (!isValidResult(extracted)) { - console.error("[/api/jobs/extract/normalize] Invalid shape:", text.slice(0, 200)); - return Response.json( - { error: "Could not normalize the extracted data." }, - { status: 422 }, - ); + const match = text.match(/\{[\s\S]*\}/); + if (match) { + const extracted = JSON.parse(match[0]); + if (isValidResult(extracted)) result = extracted; } - result = extracted; - } catch { - console.error("[/api/jobs/extract/normalize] JSON parse failed:", text.slice(0, 200)); - return Response.json( - { error: "Could not normalize the extracted data." }, - { status: 422 }, - ); - } + } catch {} + } + + // If AI normalization failed, fall back to raw extracted values + if (!result) { + console.warn("[/api/jobs/extract/normalize] AI parse failed, using raw fields. Response:", text.slice(0, 300)); + result = { + title: parsed.data.title ?? "Untitled", + company: parsed.data.company ?? "Unknown", + location: parsed.data.location ?? null, + locationType: null, + postedAt: null, + jobType: null, + salaryMin: null, + salaryMax: null, + currency: null, + skills: parsed.data.skillsText ? parsed.data.skillsText.split(/,\s*/).filter(Boolean) : [], + }; } return Response.json({ ...result, - description: descriptionMarkdown, + description: descriptionMarkdown || parsed.data.title || "No description available", url: parsed.data.url, }); } catch (err) { diff --git a/src/lib/validations.ts b/src/lib/validations.ts index d3d8b2f..8c7e2d2 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -195,14 +195,14 @@ export const identifySelectorsSchema = z.object({ // ── Chrome extension: extraction normalization ────────────────────────── export const normalizeExtractionSchema = z.object({ - title: z.string().nullable(), - company: z.string().nullable(), - location: z.string().nullable(), - salaryText: z.string().nullable(), - jobTypeText: z.string().nullable(), - skillsText: z.string().nullable(), - descriptionHtml: z.string().min(1).max(100000), - postedDateText: z.string().nullable(), + title: z.string().nullish(), + company: z.string().nullish(), + location: z.string().nullish(), + salaryText: z.string().nullish(), + jobTypeText: z.string().nullish(), + skillsText: z.string().nullish(), + descriptionHtml: z.string().max(100000).default(""), + postedDateText: z.string().nullish(), url: z.string(), profileId: z.string().cuid(), }); From 150421868b99426637e5eb157ff5e6fdd1141ed5 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:46:31 +0100 Subject: [PATCH 47/64] Delete two-step extraction routes and unused schemas --- src/app/api/jobs/extract/identify/route.ts | 155 ------------------ src/app/api/jobs/extract/normalize/route.ts | 172 -------------------- src/lib/validations.ts | 21 --- 3 files changed, 348 deletions(-) delete mode 100644 src/app/api/jobs/extract/identify/route.ts delete mode 100644 src/app/api/jobs/extract/normalize/route.ts diff --git a/src/app/api/jobs/extract/identify/route.ts b/src/app/api/jobs/extract/identify/route.ts deleted file mode 100644 index 0f17589..0000000 --- a/src/app/api/jobs/extract/identify/route.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { auth } from "@clerk/nextjs/server"; -import { prisma } from "@/lib/prisma"; -import { openrouter } from "@/lib/openrouter"; -import { getModels } from "@/lib/models"; -import { identifySelectorsSchema } from "@/lib/validations"; - -const SYSTEM_PROMPT = `You are a DOM analyst. Given a stripped HTML skeleton of a job listing page, identify CSS selectors that would extract key fields. Return ONLY valid JSON — no markdown fences, no explanation: - -{ - "title": "CSS selector for the job title element, or null", - "company": "CSS selector for the company name element, or null", - "location": "CSS selector for the job location element, or null", - "salary": "CSS selector for the salary/compensation element, or null", - "jobType": "CSS selector for employment type element, or null", - "skills": "CSS selector for skills list container, or null", - "description": "CSS selector for the main job description container, or null", - "postedDate": "CSS selector for the posting date element, or null" -} - -Rules: -- Use the most specific selector possible (prefer IDs > data attributes > semantic classes > tag hierarchy) -- For description, select the CONTAINER element (innerHTML will be extracted) -- For skills, select the LIST CONTAINER (each child will be one skill) -- Return null for any field you cannot identify with confidence -- Selectors must be valid CSS for document.querySelector()`; - -interface SelectorResult { - title: string | null; - company: string | null; - location: string | null; - salary: string | null; - jobType: string | null; - skills: string | null; - description: string | null; - postedDate: string | null; -} - -function isValidResult(parsed: unknown): parsed is SelectorResult { - if (typeof parsed !== "object" || parsed === null) return false; - const p = parsed as Record; - const fields = ["title", "company", "location", "salary", "jobType", "skills", "description", "postedDate"]; - return fields.every((f) => p[f] === null || typeof p[f] === "string"); -} - -export async function POST(req: Request) { - const { userId } = await auth(); - if (!userId) return new Response("Unauthorized", { status: 401 }); - - const body = await req.json().catch(() => null); - const parsed = identifySelectorsSchema.safeParse(body); - if (!parsed.success) { - return Response.json({ error: "Invalid request" }, { status: 400 }); - } - - const { skeleton, profileId } = parsed.data; - - const profile = await prisma.profile.findFirst({ where: { id: profileId, userId } }); - if (!profile) return new Response("Profile not found", { status: 404 }); - - const usage = await prisma.usage.findUnique({ where: { userId } }); - if (usage && usage.currentMonthInputTokens >= usage.monthlyLimitInputTokens) { - return Response.json( - { error: "Monthly AI usage limit reached." }, - { status: 429 }, - ); - } - - const models = getModels(profile); - - try { - const response = await openrouter.chat.completions.create({ - model: models.triage, - max_tokens: 300, - messages: [ - { role: "system", content: SYSTEM_PROMPT }, - { role: "user", content: skeleton }, - ], - }); - - const inputTokens = response.usage?.prompt_tokens ?? 0; - const outputTokens = response.usage?.completion_tokens ?? 0; - - if (inputTokens > 0) { - await prisma.usage.upsert({ - where: { userId }, - create: { - userId, - totalInputTokens: inputTokens, - totalOutputTokens: outputTokens, - currentMonthInputTokens: inputTokens, - currentMonthOutputTokens: outputTokens, - triageCallCount: 1, - triageInputTokens: inputTokens, - triageOutputTokens: outputTokens, - }, - update: { - totalInputTokens: { increment: inputTokens }, - totalOutputTokens: { increment: outputTokens }, - currentMonthInputTokens: { increment: inputTokens }, - currentMonthOutputTokens: { increment: outputTokens }, - triageCallCount: { increment: 1 }, - triageInputTokens: { increment: inputTokens }, - triageOutputTokens: { increment: outputTokens }, - }, - }); - } - - const text = response.choices[0]?.message?.content ?? ""; - - let selectors: SelectorResult; - try { - const direct = JSON.parse(text.trim()); - if (isValidResult(direct)) { - selectors = direct; - } else { - throw new Error("Invalid shape"); - } - } catch { - // Try extracting JSON object from response - const match = text.match(/\{[\s\S]*\}/); - if (!match) { - console.error("[/api/jobs/extract/identify] No JSON in response:", text.slice(0, 200)); - return Response.json( - { error: "Could not identify selectors from this page." }, - { status: 422 }, - ); - } - try { - const extracted = JSON.parse(match[0]); - if (!isValidResult(extracted)) { - console.error("[/api/jobs/extract/identify] Invalid shape:", text.slice(0, 200)); - return Response.json( - { error: "Could not identify selectors from this page." }, - { status: 422 }, - ); - } - selectors = extracted; - } catch { - console.error("[/api/jobs/extract/identify] JSON parse failed:", text.slice(0, 200)); - return Response.json( - { error: "Could not identify selectors from this page." }, - { status: 422 }, - ); - } - } - - return Response.json({ selectors }); - } catch (err) { - console.error("[/api/jobs/extract/identify]", err); - return Response.json( - { error: "Selector identification failed. Please try again." }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/jobs/extract/normalize/route.ts b/src/app/api/jobs/extract/normalize/route.ts deleted file mode 100644 index 5181aca..0000000 --- a/src/app/api/jobs/extract/normalize/route.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { auth } from "@clerk/nextjs/server"; -import { prisma } from "@/lib/prisma"; -import { openrouter } from "@/lib/openrouter"; -import { getModels } from "@/lib/models"; -import { normalizeExtractionSchema } from "@/lib/validations"; -import TurndownService from "turndown"; - -const td = new TurndownService({ headingStyle: "atx", bulletListMarker: "-" }); -td.remove(["script", "style", "noscript", "iframe"]); - -const SYSTEM_PROMPT = `Normalize these raw job listing fields into structured data. Return ONLY valid JSON — no markdown fences, no explanation: - -{ - "title": "cleaned title string", - "company": "cleaned company name", - "location": "cleaned location string or null", - "locationType": "REMOTE" or "HYBRID" or "ONSITE" or null, - "postedAt": "YYYY-MM-DD or null", - "jobType": "FULL_TIME" or "PART_TIME" or "CONTRACT" or "FREELANCE" or "INTERNSHIP" or null, - "salaryMin": integer or null, - "salaryMax": integer or null, - "currency": "USD" or "EUR" or "GBP" etc or null, - "skills": ["skill1", "skill2"] -} - -Rules: -- Parse salary ranges like "$120K - $180K" into salaryMin: 120000, salaryMax: 180000, currency: "USD" -- Convert hourly rates to annual (multiply by 2080) -- Extract individual skills from comma-separated or bullet-pointed lists -- Use null for anything you cannot determine`; - -interface NormalizeResult { - title: string; - company: string; - location: string | null; - locationType: "REMOTE" | "HYBRID" | "ONSITE" | null; - postedAt: string | null; - jobType: "FULL_TIME" | "PART_TIME" | "CONTRACT" | "FREELANCE" | "INTERNSHIP" | null; - salaryMin: number | null; - salaryMax: number | null; - currency: string | null; - skills: string[]; -} - -function isValidResult(parsed: unknown): parsed is NormalizeResult { - if (typeof parsed !== "object" || parsed === null) return false; - const p = parsed as Record; - return typeof p.title === "string" && typeof p.company === "string"; -} - -export async function POST(req: Request) { - const { userId } = await auth(); - if (!userId) return new Response("Unauthorized", { status: 401 }); - - const body = await req.json().catch(() => null); - const parsed = normalizeExtractionSchema.safeParse(body); - if (!parsed.success) { - console.error("[/api/jobs/extract/normalize] Validation failed:", parsed.error.flatten()); - return Response.json({ error: "Invalid request", details: parsed.error.flatten().fieldErrors }, { status: 400 }); - } - - const { profileId } = parsed.data; - - const profile = await prisma.profile.findFirst({ where: { id: profileId, userId } }); - if (!profile) return new Response("Profile not found", { status: 404 }); - - const usage = await prisma.usage.findUnique({ where: { userId } }); - if (usage && usage.currentMonthInputTokens >= usage.monthlyLimitInputTokens) { - return Response.json( - { error: "Monthly AI usage limit reached." }, - { status: 429 }, - ); - } - - const models = getModels(profile); - - // Convert description HTML to markdown (no AI needed for this) - const descriptionMarkdown = td.turndown(parsed.data.descriptionHtml); - - // Build compact user message from extracted fields - const fields = [ - `Title: ${parsed.data.title ?? "unknown"}`, - `Company: ${parsed.data.company ?? "unknown"}`, - `Location: ${parsed.data.location ?? "unknown"}`, - `Salary: ${parsed.data.salaryText ?? "unknown"}`, - `Job Type: ${parsed.data.jobTypeText ?? "unknown"}`, - `Skills: ${parsed.data.skillsText ?? "unknown"}`, - `Posted: ${parsed.data.postedDateText ?? "unknown"}`, - ].join("\n"); - - try { - const response = await openrouter.chat.completions.create({ - model: models.extract, - max_tokens: 400, - messages: [ - { role: "system", content: SYSTEM_PROMPT }, - { role: "user", content: fields }, - ], - }); - - const inputTokens = response.usage?.prompt_tokens ?? 0; - const outputTokens = response.usage?.completion_tokens ?? 0; - - if (inputTokens > 0) { - await prisma.usage.upsert({ - where: { userId }, - create: { - userId, - totalInputTokens: inputTokens, - totalOutputTokens: outputTokens, - currentMonthInputTokens: inputTokens, - currentMonthOutputTokens: outputTokens, - analysisCallCount: 1, - }, - update: { - totalInputTokens: { increment: inputTokens }, - totalOutputTokens: { increment: outputTokens }, - currentMonthInputTokens: { increment: inputTokens }, - currentMonthOutputTokens: { increment: outputTokens }, - analysisCallCount: { increment: 1 }, - }, - }); - } - - const text = response.choices[0]?.message?.content ?? ""; - - let result: NormalizeResult | null = null; - try { - const direct = JSON.parse(text.trim()); - if (isValidResult(direct)) result = direct; - } catch {} - - if (!result) { - try { - const match = text.match(/\{[\s\S]*\}/); - if (match) { - const extracted = JSON.parse(match[0]); - if (isValidResult(extracted)) result = extracted; - } - } catch {} - } - - // If AI normalization failed, fall back to raw extracted values - if (!result) { - console.warn("[/api/jobs/extract/normalize] AI parse failed, using raw fields. Response:", text.slice(0, 300)); - result = { - title: parsed.data.title ?? "Untitled", - company: parsed.data.company ?? "Unknown", - location: parsed.data.location ?? null, - locationType: null, - postedAt: null, - jobType: null, - salaryMin: null, - salaryMax: null, - currency: null, - skills: parsed.data.skillsText ? parsed.data.skillsText.split(/,\s*/).filter(Boolean) : [], - }; - } - - return Response.json({ - ...result, - description: descriptionMarkdown || parsed.data.title || "No description available", - url: parsed.data.url, - }); - } catch (err) { - console.error("[/api/jobs/extract/normalize]", err); - return Response.json( - { error: "Normalization failed. Please try again." }, - { status: 500 }, - ); - } -} diff --git a/src/lib/validations.ts b/src/lib/validations.ts index 8c7e2d2..33a0c0e 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -186,27 +186,6 @@ export const feedbackSchema = z.object({ export const deleteAccountSchema = z.object({ confirmation: z.literal("DELETE"), }); - -// ── Chrome extension: selector identification ─────────────────────────── -export const identifySelectorsSchema = z.object({ - skeleton: z.string().min(1).max(20000), - profileId: z.string().cuid(), -}); - -// ── Chrome extension: extraction normalization ────────────────────────── -export const normalizeExtractionSchema = z.object({ - title: z.string().nullish(), - company: z.string().nullish(), - location: z.string().nullish(), - salaryText: z.string().nullish(), - jobTypeText: z.string().nullish(), - skillsText: z.string().nullish(), - descriptionHtml: z.string().max(100000).default(""), - postedDateText: z.string().nullish(), - url: z.string(), - profileId: z.string().cuid(), -}); - // ── Custom model settings ──────────────────────────────────────────────── export const updateModelSettingsSchema = z.object({ profileId: z.string().cuid(), From 2d597faa7e46ff15c3a6e7f56061d7378c0257ee Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:46:57 +0100 Subject: [PATCH 48/64] Simplify extension types for direct extract-and-import flow --- extensions/chrome/src/types.ts | 97 ++++++++++++---------------------- 1 file changed, 35 insertions(+), 62 deletions(-) diff --git a/extensions/chrome/src/types.ts b/extensions/chrome/src/types.ts index 4dea0b0..f7a1c36 100644 --- a/extensions/chrome/src/types.ts +++ b/extensions/chrome/src/types.ts @@ -9,72 +9,49 @@ export interface ImportRecord { profileName: string; } -// ── Extracted job data ──────────────────────────────────────────────────── +// ── Page content collected by content script ───────────────────────────── -export interface ExtractedJob { +export interface PageContent { + url: string; + html: string; + title: string; +} + +// ── Extract endpoint response ──────────────────────────────────────────── + +export interface ExtractResult { title: string; company: string; description: string; location: string | null; locationType: "REMOTE" | "HYBRID" | "ONSITE" | null; - url: string; + url: string | null; postedAt: string | null; - jobType: - | "FULL_TIME" - | "PART_TIME" - | "CONTRACT" - | "FREELANCE" - | "INTERNSHIP" - | null; + jobType: string | null; salaryMin: number | null; salaryMax: number | null; currency: string | null; skills: string[]; - externalId: string | null; - source: - | "LINKEDIN" - | "GREENHOUSE" - | "LEVER" - | "ASHBY" - | "INDEED" - | "USAJOBS" - | "CUSTOM"; } -// ── Selector map for AI-identified fields ──────────────────────────────── +// ── Import endpoint payload ────────────────────────────────────────────── -export interface SelectorMap { - title: string | null; - company: string | null; - location: string | null; - salary: string | null; - jobType: string | null; - skills: string | null; - description: string | null; - postedDate: string | null; -} - -// ── Raw extraction before normalization ────────────────────────────────── - -export interface RawExtraction { - title: string | null; - company: string | null; - location: string | null; - salaryText: string | null; - jobTypeText: string | null; - skillsText: string | null; - descriptionHtml: string; - postedDateText: string | null; - url: string; +export interface ImportPayload { + originalInput: string; + title: string; + company: string; + description: string; + location?: string | null; + locationType?: string | null; + url?: string | null; + postedAt?: string | null; + jobType?: string | null; + salaryMin?: number | null; + salaryMax?: number | null; + currency?: string | null; + skills?: string[]; } -// ── Extraction result variants ─────────────────────────────────────────── - -export type ExtractionResult = - | { type: "extracted"; raw: RawExtraction } - | { type: "needs_identification"; skeleton: string; url: string; domain: string } - | { type: "none" }; - // ── Chrome message types ───────────────────────────────────────────────── export type Message = @@ -89,20 +66,16 @@ export type Message = profiles: Array<{ id: string; name: string; isActive: boolean }>; } - // Extraction (popup -> content script) - | { type: "EXTRACT" } - | { type: "EXTRACTED"; result: ExtractionResult } + // Page content (popup -> content script) + | { type: "GET_PAGE_CONTENT" } + | { type: "PAGE_CONTENT"; content: PageContent } - // Two-step AI extraction - | { type: "IDENTIFY_SELECTORS"; skeleton: string; profileId: string } - | { type: "SELECTORS_IDENTIFIED"; selectors: SelectorMap } - | { type: "APPLY_SELECTORS"; selectors: SelectorMap } - | { type: "SELECTORS_APPLIED"; result: ExtractionResult } - | { type: "NORMALIZE_AND_IMPORT"; raw: RawExtraction; profileId: string; profileName: string } - | { type: "IMPORT_COMPLETE"; jobId: string } + // Extract (popup -> service worker -> server) + | { type: "EXTRACT_JOB"; html: string; profileId: string } + | { type: "EXTRACT_RESULT"; ok: boolean; data?: ExtractResult; error?: string } - // Import (structured job from extractor) - | { type: "IMPORT_JOB"; profileId: string; profileName: string; job: ExtractedJob } + // Import (popup -> service worker -> server) + | { type: "IMPORT_JOB"; profileId: string; profileName: string; data: ImportPayload } | { type: "IMPORT_RESULT"; ok: boolean; jobId?: string; error?: string } // Import history From 91d995972a4b5834a6b0e0300f29f7414b702424 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:47:30 +0100 Subject: [PATCH 49/64] Replace DOM skeleton extractor with simple HTML collector --- .../chrome/src/lib/extractors/generic.ts | 195 +----------------- extensions/chrome/src/lib/extractors/index.ts | 23 +-- extensions/chrome/src/lib/extractors/utils.ts | 70 ------- 3 files changed, 11 insertions(+), 277 deletions(-) delete mode 100644 extensions/chrome/src/lib/extractors/utils.ts diff --git a/extensions/chrome/src/lib/extractors/generic.ts b/extensions/chrome/src/lib/extractors/generic.ts index 248d902..836fb20 100644 --- a/extensions/chrome/src/lib/extractors/generic.ts +++ b/extensions/chrome/src/lib/extractors/generic.ts @@ -1,101 +1,6 @@ -import type { SelectorMap, RawExtraction } from "../../types"; +const MAX_CONTENT_LENGTH = 50_000; -// ── CSS framework class blocklist ──────────────────────────────────────── - -const FRAMEWORK_PATTERNS = [ - // Tailwind utilities - /^-?(sm|md|lg|xl|2xl):/, - /^(hover|focus|active|disabled|group-|peer-):/, - /^!?-?(bg|text|font|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|w|h|min-w|min-h|max-w|max-h)-/, - /^(flex|grid|col|row|gap|space|place|items|justify|self|order)-/, - /^(border|rounded|shadow|ring|outline)-/, - /^(opacity|transition|transform|duration|ease|delay|animate)-/, - /^(absolute|relative|fixed|sticky|inset|top|right|bottom|left|z)-/, - /^(overflow|truncate|whitespace|break|tracking|leading)-/, - /^(block|inline-block|inline|hidden|invisible|visible|sr-only|not-sr-only)$/, - /^(cursor|pointer-events|select|resize|appearance)-/, - /^(underline|overline|line-through|no-underline|uppercase|lowercase|capitalize|normal-case)$/, - /^(antialiased|subpixel-antialiased)$/, - /^(fill|stroke)-/, - /^(table|clear|float)-/, - /^(accent|caret|scroll)-/, - /^(snap|touch|will-change)-/, - /^(columns|break-|object-|aspect-)/, - /^(decoration|indent|align|content)-/, - /^(backdrop|filter|blur|brightness|contrast|grayscale|invert|saturate|sepia|drop-shadow)-/, - /^(mix-blend|bg-blend)-/, - /^(isolate|isolation)-/, - /^(list|placeholder)-/, - /^(ring|divide|space)-/, - /^(from|via|to)-/, - /^(basis|grow|shrink)-/, - // Bootstrap - /^(col-|row|container|btn|nav-|card|modal|badge|alert|form-|input-)/, - /^(d-|justify-content|align-items|float-|position-|text-center|text-start|text-end)$/, - /^(mb-|mt-|ms-|me-|mx-|my-|pb-|pt-|ps-|pe-|px-|py-|g-|gx-|gy-)\d/, - /^(visually-hidden|stretched-link|clearfix)$/, - // CSS Modules / Styled Components / Emotion (hashed) - /^(css|sc|emotion)-[a-zA-Z0-9]/, - /^_[a-zA-Z]+_[a-f0-9]+/, - /[a-zA-Z]+_[a-f0-9]{5,}$/, - // Generic minified classes (1-2 chars) - /^[a-zA-Z]{1,2}$/, -]; - -const KEEP_ATTRIBUTES = new Set(["id", "data-testid", "data-test", "role", "aria-label", "class"]); - -const NOISE_SELECTOR = - "script, style, nav, footer, header, iframe, noscript, svg, img, video, audio, canvas, " + - "[aria-hidden='true'], [role='navigation'], [role='banner'], [role='contentinfo']"; - -const MAX_SKELETON_LENGTH = 15_000; -const MAX_TEXT_NODE_LENGTH = 30; - -// ── DOM skeleton builder ───────────────────────────────────────────────── - -function isFrameworkClass(cls: string): boolean { - return FRAMEWORK_PATTERNS.some((pattern) => pattern.test(cls)); -} - -function stripAttributes(el: Element): void { - const attrs = Array.from(el.attributes); - for (const attr of attrs) { - if (attr.name === "style") { - el.removeAttribute("style"); - continue; - } - if (attr.name === "class") { - const filtered = attr.value - .split(/\s+/) - .filter((cls) => cls && !isFrameworkClass(cls)) - .join(" "); - if (filtered) { - el.setAttribute("class", filtered); - } else { - el.removeAttribute("class"); - } - continue; - } - if (!KEEP_ATTRIBUTES.has(attr.name)) { - el.removeAttribute(attr.name); - } - } -} - -function truncateTextNodes(node: Node): void { - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? ""; - if (text.length > MAX_TEXT_NODE_LENGTH) { - node.textContent = text.slice(0, MAX_TEXT_NODE_LENGTH) + "..."; - } - return; - } - for (const child of Array.from(node.childNodes)) { - truncateTextNodes(child); - } -} - -export function stripDomSkeleton(): string { +export function collectPageContent(): { url: string; html: string; title: string } { const contentEl = document.querySelector("[role='main'] article") ?? document.querySelector("[role='main']") ?? @@ -104,96 +9,16 @@ export function stripDomSkeleton(): string { document.body; const clone = contentEl.cloneNode(true) as HTMLElement; - - clone.querySelectorAll(NOISE_SELECTOR).forEach((el) => el.remove()); - - const elements = clone.querySelectorAll("*"); - for (const el of elements) { - stripAttributes(el); - } - - truncateTextNodes(clone); - - const html = clone.outerHTML; - return html.slice(0, MAX_SKELETON_LENGTH); -} - -// ── Selector application ───────────────────────────────────────────────── - -export function applySelectors(selectors: SelectorMap): RawExtraction | null { - const query = (sel: string | null) => { - if (!sel) return null; - try { - return document.querySelector(sel); - } catch { - return null; - } - }; - - const textOf = (sel: string | null) => query(sel)?.textContent?.trim() ?? null; - - const title = textOf(selectors.title); - const company = textOf(selectors.company); - - if (!title && !company) return null; - - let skillsText: string | null = null; - if (selectors.skills) { - const container = query(selectors.skills); - if (container) { - const children = container.querySelectorAll("li, span, a"); - if (children.length > 0) { - skillsText = Array.from(children) - .map((el) => el.textContent?.trim()) - .filter(Boolean) - .join(", "); - } else { - skillsText = container.textContent?.trim() ?? null; - } - } - } - - const descriptionEl = query(selectors.description); + clone.querySelectorAll( + "script, style, nav, footer, header, iframe, noscript, " + + "svg, img, video, audio, canvas, " + + "[role='navigation'], [role='banner'], [role='contentinfo'], " + + "[aria-hidden='true']" + ).forEach((el) => el.remove()); return { - title, - company, - location: textOf(selectors.location), - salaryText: textOf(selectors.salary), - jobTypeText: textOf(selectors.jobType), - skillsText, - descriptionHtml: descriptionEl?.innerHTML ?? "", - postedDateText: textOf(selectors.postedDate), url: window.location.href, + html: clone.innerHTML.slice(0, MAX_CONTENT_LENGTH), + title: document.title, }; } - -// ── Selector caching ───────────────────────────────────────────────────── - -const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days - -interface CachedSelectors { - selectors: SelectorMap; - cachedAt: number; -} - -export async function getCachedSelectors(domain: string): Promise { - const key = `selectors_${domain}`; - const { [key]: cached } = await chrome.storage.local.get(key); - if (!cached) return null; - const entry = cached as CachedSelectors; - if (Date.now() - entry.cachedAt > CACHE_TTL_MS) { - await chrome.storage.local.remove(key); - return null; - } - return entry.selectors; -} - -export async function cacheSelectors(domain: string, selectors: SelectorMap): Promise { - const key = `selectors_${domain}`; - await chrome.storage.local.set({ [key]: { selectors, cachedAt: Date.now() } }); -} - -export async function invalidateSelectors(domain: string): Promise { - await chrome.storage.local.remove(`selectors_${domain}`); -} diff --git a/extensions/chrome/src/lib/extractors/index.ts b/extensions/chrome/src/lib/extractors/index.ts index 99a5351..45d43e6 100644 --- a/extensions/chrome/src/lib/extractors/index.ts +++ b/extensions/chrome/src/lib/extractors/index.ts @@ -1,22 +1 @@ -import { getCachedSelectors, applySelectors, stripDomSkeleton } from "./generic"; -import type { ExtractionResult } from "../../types"; - -export async function extractFromPage(): Promise { - const domain = window.location.hostname; - - const cached = await getCachedSelectors(domain); - if (cached) { - const raw = applySelectors(cached); - if (raw) return { type: "extracted", raw }; - } - - const skeleton = stripDomSkeleton(); - if (!skeleton || skeleton.length < 50) return { type: "none" }; - - return { - type: "needs_identification", - skeleton, - url: window.location.href, - domain, - }; -} +export { collectPageContent } from "./generic"; diff --git a/extensions/chrome/src/lib/extractors/utils.ts b/extensions/chrome/src/lib/extractors/utils.ts deleted file mode 100644 index d85c2a8..0000000 --- a/extensions/chrome/src/lib/extractors/utils.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** Infer location type from a free-text location string. */ -export function inferLocationType( - location: string | null, -): "REMOTE" | "HYBRID" | "ONSITE" | null { - if (!location) return null; - const lower = location.toLowerCase(); - if (lower.includes("remote")) return "REMOTE"; - if (lower.includes("hybrid")) return "HYBRID"; - return null; -} - -/** Parse a free-text job type string into a structured enum value. */ -export function parseJobType( - text: string | null, -): - | "FULL_TIME" - | "PART_TIME" - | "CONTRACT" - | "FREELANCE" - | "INTERNSHIP" - | null { - if (!text) return null; - const lower = text.toLowerCase(); - if (lower.includes("full")) return "FULL_TIME"; - if (lower.includes("part")) return "PART_TIME"; - if (lower.includes("contract") || lower.includes("temporary")) - return "CONTRACT"; - if (lower.includes("freelance")) return "FREELANCE"; - if (lower.includes("intern")) return "INTERNSHIP"; - return null; -} - -/** Best-effort salary range extraction from free text. */ -export function parseSalaryFromText(text: string | undefined): { - salaryMin: number | null; - salaryMax: number | null; - currency: string | null; -} { - if (!text) return { salaryMin: null, salaryMax: null, currency: null }; - - const currencyMap: Record = { - $: "USD", - "\u20AC": "EUR", - "\u00A3": "GBP", - EUR: "EUR", - GBP: "GBP", - CHF: "CHF", - USD: "USD", - }; - - // Match patterns: "$120K - $180K", "60,000 - 90,000 EUR", "$120,000-$180,000/yr" - const match = text.match( - /([$\u20AC\u00A3]|EUR|GBP|CHF|USD)?\s*([\d,.]+)\s*[Kk]?\s*[-\u2013to]+\s*[$\u20AC\u00A3]?\s*([\d,.]+)\s*[Kk]?\s*(?:\/?\s*(?:yr|year|annum|pa))?\s*(EUR|GBP|CHF|USD)?/, - ); - if (!match) return { salaryMin: null, salaryMax: null, currency: null }; - - const currencySymbol = match[1] ?? match[4] ?? ""; - const currency = currencyMap[currencySymbol] ?? (currencySymbol || null); - - const normalize = (s: string) => { - const n = parseFloat(s.replace(/,/g, "")); - return text.toLowerCase().includes("k") && n < 1000 ? n * 1000 : n; - }; - - return { - salaryMin: normalize(match[2]), - salaryMax: normalize(match[3]), - currency, - }; -} From 63d302a3f7511f27f8f6a7d8bebe4e7ab00933eb Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:47:53 +0100 Subject: [PATCH 50/64] Simplify extension API client for extract and import --- extensions/chrome/src/lib/api.ts | 77 ++------------------------------ 1 file changed, 4 insertions(+), 73 deletions(-) diff --git a/extensions/chrome/src/lib/api.ts b/extensions/chrome/src/lib/api.ts index 53e4b49..dae3844 100644 --- a/extensions/chrome/src/lib/api.ts +++ b/extensions/chrome/src/lib/api.ts @@ -1,7 +1,6 @@ -import type { SelectorMap, ExtractedJob } from "../types"; +import type { ExtractResult, ImportPayload } from "../types"; const PROD_URL = "https://shortlist.johnmoorman.com"; -const DEV_URL = "http://localhost:3000"; interface ApiResponse { ok: boolean; @@ -79,25 +78,6 @@ interface ProfilesResponse { profiles: Array<{ id: string; name: string; isActive: boolean }>; } -interface ImportResponse { - job: { id: string }; -} - -interface ExtractResponse { - title: string; - company: string; - description: string; - location: string | null; - locationType: "REMOTE" | "HYBRID" | "ONSITE" | null; - url: string | null; - postedAt: string | null; - jobType: string | null; - salaryMin: number | null; - salaryMax: number | null; - currency: string | null; - skills: string[]; -} - export function checkAuth() { return apiCall("/api/extension/status"); } @@ -106,65 +86,16 @@ export function fetchProfiles() { return apiCall("/api/extension/profiles"); } -export function importJob(payload: { - profileId: string; - originalInput: string; - title: string; - company: string; - description: string; - location?: string | null; - locationType?: string | null; - url?: string | null; - postedAt?: string | null; - jobType?: string | null; - salaryMin?: number | null; - salaryMax?: number | null; - currency?: string | null; - skills?: string[]; - source?: string; - externalId?: string | null; -}) { - const { externalId, ...rest } = payload; - const body = externalId != null ? { ...rest, externalId } : rest; - return apiCall("/api/jobs/import", { - method: "POST", - body: JSON.stringify(body), - }); -} - export function extractJob(payload: { input: string; profileId: string }) { - return apiCall("/api/jobs/extract", { + return apiCall("/api/jobs/extract", { method: "POST", body: JSON.stringify(payload), }); } -export function identifySelectors(payload: { - skeleton: string; - profileId: string; -}): Promise> { - return apiCall("/api/jobs/extract/identify", { +export function importJob(payload: ImportPayload & { profileId: string }) { + return apiCall<{ job: { id: string } }>("/api/jobs/import", { method: "POST", body: JSON.stringify(payload), }); } - -export function normalizeExtraction(payload: { - title: string | null; - company: string | null; - location: string | null; - salaryText: string | null; - jobTypeText: string | null; - skillsText: string | null; - descriptionHtml: string; - postedDateText: string | null; - url: string; - profileId: string; -}): Promise> { - return apiCall("/api/jobs/extract/normalize", { - method: "POST", - body: JSON.stringify(payload), - }); -} - -export { DEV_URL, PROD_URL }; From 25a94cb78d2ee72667169bb4dd19a9a0e878e51c Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:48:17 +0100 Subject: [PATCH 51/64] Simplify service worker for direct extract and import --- .../chrome/src/background/service-worker.ts | 137 +++--------------- 1 file changed, 22 insertions(+), 115 deletions(-) diff --git a/extensions/chrome/src/background/service-worker.ts b/extensions/chrome/src/background/service-worker.ts index 6145ea3..c7e7d6d 100644 --- a/extensions/chrome/src/background/service-worker.ts +++ b/extensions/chrome/src/background/service-worker.ts @@ -1,11 +1,10 @@ import { checkAuth, fetchProfiles, + extractJob, importJob, - identifySelectors, - normalizeExtraction, } from "../lib/api"; -import type { Message, ExtractedJob, RawExtraction, ImportRecord } from "../types"; +import type { Message, ImportPayload, ImportRecord } from "../types"; // ── Import history ────────────────────────────────────────────────────── @@ -39,21 +38,12 @@ chrome.runtime.onMessage.addListener( handleGetProfiles(sendResponse); return true; - case "IMPORT_JOB": - handleImportJob(message.profileId, message.profileName, message.job, sendResponse); - return true; - - case "IDENTIFY_SELECTORS": - handleIdentifySelectors(message.skeleton, message.profileId, sendResponse); + case "EXTRACT_JOB": + handleExtractJob(message.html, message.profileId, sendResponse); return true; - case "NORMALIZE_AND_IMPORT": - handleNormalizeAndImport( - message.raw, - message.profileId, - message.profileName, - sendResponse, - ); + case "IMPORT_JOB": + handleImportJob(message.profileId, message.profileName, message.data, sendResponse); return true; case "GET_IMPORT_HISTORY": @@ -85,117 +75,34 @@ async function handleGetProfiles( }); } -async function handleImportJob( +async function handleExtractJob( + html: string, profileId: string, - profileName: string, - job: ExtractedJob, sendResponse: (response: unknown) => void, ): Promise { - const result = await importJob({ - profileId, - originalInput: job.url, - title: job.title, - company: job.company, - description: job.description, - location: job.location, - locationType: job.locationType, - url: job.url, - postedAt: job.postedAt, - jobType: job.jobType, - salaryMin: job.salaryMin, - salaryMax: job.salaryMax, - currency: job.currency, - skills: job.skills, - source: job.source, - externalId: job.externalId, - }); - - const jobId = result.data?.job?.id; - - if (result.ok && jobId) { - await saveImportRecord({ - jobId, - title: job.title, - company: job.company, - source: job.source, - importedAt: new Date().toISOString(), - profileName, - }); - } - + const result = await extractJob({ input: html, profileId }); sendResponse({ - type: "IMPORT_RESULT", + type: "EXTRACT_RESULT", ok: result.ok, - jobId, - error: result.error, + data: result.data ?? undefined, + error: result.error ?? undefined, }); } -async function handleIdentifySelectors( - skeleton: string, - profileId: string, - sendResponse: (response: unknown) => void, -): Promise { - const result = await identifySelectors({ skeleton, profileId }); - - if (result.ok && result.data?.selectors) { - sendResponse({ - type: "SELECTORS_IDENTIFIED", - selectors: result.data.selectors, - }); - } else { - sendResponse({ - type: "IMPORT_RESULT", - ok: false, - error: result.error ?? "Could not identify job fields on this page", - }); - } -} - -async function handleNormalizeAndImport( - raw: RawExtraction, +async function handleImportJob( profileId: string, profileName: string, + data: ImportPayload, sendResponse: (response: unknown) => void, ): Promise { - const normalizeResult = await normalizeExtraction({ ...raw, profileId }); - if (!normalizeResult.ok || !normalizeResult.data) { - sendResponse({ - type: "IMPORT_RESULT", - ok: false, - error: normalizeResult.error ?? "Normalization failed", - }); - return; - } - - const extracted = normalizeResult.data; - - const importResult = await importJob({ - profileId, - originalInput: raw.url, - title: extracted.title, - company: extracted.company, - description: extracted.description, - location: extracted.location, - locationType: extracted.locationType, - url: extracted.url ?? raw.url, - postedAt: extracted.postedAt, - jobType: extracted.jobType, - salaryMin: extracted.salaryMin, - salaryMax: extracted.salaryMax, - currency: extracted.currency, - skills: extracted.skills, - source: extracted.source, - externalId: extracted.externalId, - }); - - const jobId = importResult.data?.job?.id; + const result = await importJob({ profileId, ...data }); + const jobId = result.data?.job?.id; - if (importResult.ok && jobId) { + if (result.ok && jobId) { await saveImportRecord({ jobId, - title: extracted.title, - company: extracted.company, + title: data.title, + company: data.company, source: "CUSTOM", importedAt: new Date().toISOString(), profileName, @@ -203,10 +110,10 @@ async function handleNormalizeAndImport( } sendResponse({ - type: importResult.ok ? "IMPORT_COMPLETE" : "IMPORT_RESULT", - ok: importResult.ok, + type: "IMPORT_RESULT", + ok: result.ok, jobId, - error: importResult.error, + error: result.error ?? undefined, }); } From 1c314c267182261713fddc999ce7b020eb6071d1 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:48:31 +0100 Subject: [PATCH 52/64] Simplify content script to collect page HTML --- extensions/chrome/src/content/content.ts | 30 +++++------------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/extensions/chrome/src/content/content.ts b/extensions/chrome/src/content/content.ts index f0d1fd7..fbf841f 100644 --- a/extensions/chrome/src/content/content.ts +++ b/extensions/chrome/src/content/content.ts @@ -1,30 +1,12 @@ -import { extractFromPage } from "../lib/extractors"; -import { applySelectors, cacheSelectors } from "../lib/extractors/generic"; +import { collectPageContent } from "../lib/extractors"; import type { Message } from "../types"; chrome.runtime.onMessage.addListener( - ( - message: Message, - _sender: chrome.runtime.MessageSender, - sendResponse: (response: Message) => void, - ) => { - if (message.type === "EXTRACT") { - extractFromPage().then((result) => { - sendResponse({ type: "EXTRACTED", result }); - }); - return true; - } - if (message.type === "APPLY_SELECTORS") { - const raw = applySelectors(message.selectors); - if (raw) { - const domain = window.location.hostname; - cacheSelectors(domain, message.selectors); - } - sendResponse({ - type: "SELECTORS_APPLIED", - result: raw ? { type: "extracted", raw } : { type: "none" }, - }); - return true; + (message: Message, _sender, sendResponse) => { + if (message.type === "GET_PAGE_CONTENT") { + const content = collectPageContent(); + sendResponse({ type: "PAGE_CONTENT", content }); } + return true; }, ); From 84c875fa238429f5143c40ba2a738812bd19017e Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:49:33 +0100 Subject: [PATCH 53/64] Rewrite popup for single-step extract then import flow --- extensions/chrome/src/popup/popup.tsx | 344 ++++++++++---------------- 1 file changed, 137 insertions(+), 207 deletions(-) diff --git a/extensions/chrome/src/popup/popup.tsx b/extensions/chrome/src/popup/popup.tsx index 8a2d389..f845751 100644 --- a/extensions/chrome/src/popup/popup.tsx +++ b/extensions/chrome/src/popup/popup.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; -import type { ExtractionResult, RawExtraction, ImportRecord, SelectorMap, Message } from "../types"; +import type { ExtractResult, ImportPayload, ImportRecord, PageContent, Message } from "../types"; import { getBaseUrl } from "../lib/api"; // ── Types ──────────────────────────────────────────────────────────────── @@ -12,11 +12,9 @@ interface Profile { } type Status = - | "idle" | "loading" - | "reading" - | "identifying" | "extracting" + | "idle" | "importing" | "success" | "error"; @@ -27,10 +25,6 @@ function sendMessage(message: Message): Promise { return chrome.runtime.sendMessage(message); } -function sendTabMessage(tabId: number, message: Message): Promise { - return chrome.tabs.sendMessage(tabId, message); -} - function timeAgo(isoDate: string): string { const seconds = Math.floor((Date.now() - new Date(isoDate).getTime()) / 1000); if (seconds < 60) return "just now"; @@ -44,9 +38,7 @@ function timeAgo(isoDate: string): string { } const STATUS_LABELS: Partial> = { - reading: "Reading page...", - identifying: "Identifying job fields...", - extracting: "Extracting content...", + extracting: "Extracting job details...", importing: "Importing...", }; @@ -56,20 +48,23 @@ function Popup() { const [authenticated, setAuthenticated] = useState(null); const [profiles, setProfiles] = useState([]); const [selectedProfileId, setSelectedProfileId] = useState(""); - const [rawExtraction, setRawExtraction] = useState(null); + const [extracted, setExtracted] = useState(null); + const [pageTitle, setPageTitle] = useState(""); + const [pageUrl, setPageUrl] = useState(""); + const [pageHtml, setPageHtml] = useState(""); const [status, setStatus] = useState("loading"); const [errorMessage, setErrorMessage] = useState(""); const [importedJobId, setImportedJobId] = useState(null); const [importHistory, setImportHistory] = useState([]); const [baseUrl, setBaseUrl] = useState("https://shortlist.johnmoorman.com"); - const [aiIdentified, setAiIdentified] = useState(false); useEffect(() => { async function init() { + // 1. Resolve base URL const url = await getBaseUrl(); setBaseUrl(url); - // 1. Check auth + // 2. Check auth const authResult = await sendMessage<{ type: string; authenticated: boolean; @@ -82,14 +77,14 @@ function Popup() { } setAuthenticated(true); - // 2. Load import history + // 3. Load import history const historyResult = await sendMessage<{ type: string; history: ImportRecord[]; }>({ type: "GET_IMPORT_HISTORY" }); setImportHistory(historyResult.history ?? []); - // 3. Fetch profiles + // 4. Fetch profiles const profilesResult = await sendMessage<{ type: string; profiles: Profile[]; @@ -102,190 +97,103 @@ function Popup() { if (active) setSelectedProfileId(active.id); else if (profs.length > 0) setSelectedProfileId(profs[0].id); - // 4. Extract from the current tab - setStatus("reading"); + const profileId = active?.id ?? profs[0]?.id; + if (!profileId) { + setStatus("error"); + setErrorMessage("No profile found. Create one in Shortlist first."); + return; + } + + // 5. Collect page content + const [tab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + if (!tab?.id) { + setStatus("idle"); + return; + } + + let content: PageContent | null = null; + + // Try content script first try { - const [tab] = await chrome.tabs.query({ - active: true, - currentWindow: true, + const result = await chrome.tabs.sendMessage(tab.id, { + type: "GET_PAGE_CONTENT", }); - if (!tab?.id) { - setStatus("idle"); - return; + if (result?.content) { + content = result.content; } + } catch { + // Content script not injected — fall back to scripting API + } - let extraction: ExtractionResult | null = null; - + if (!content) { try { - const result = await sendTabMessage<{ - type: string; - result: ExtractionResult; - }>(tab.id, { type: "EXTRACT" }); - extraction = result?.result ?? null; - } catch { - // Content script not present — fall back to scripting API - try { - const [injected] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: () => { - const el = - document.querySelector("[role='main'] article") ?? - document.querySelector("[role='main']") ?? - document.querySelector("main") ?? - document.querySelector("article") ?? - document.body; - const clone = el.cloneNode(true) as HTMLElement; - clone.querySelectorAll( + const [injected] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => { + const el = + document.querySelector("[role='main'] article") ?? + document.querySelector("[role='main']") ?? + document.querySelector("main") ?? + document.querySelector("article") ?? + document.body; + const clone = el.cloneNode(true) as HTMLElement; + clone + .querySelectorAll( "script, style, nav, footer, header, iframe, noscript, " + - "svg, img, video, audio, canvas, [aria-hidden='true']" - ).forEach((n) => n.remove()); - return { - skeleton: clone.innerHTML.slice(0, 15000), - url: window.location.href, - domain: window.location.hostname, - }; - }, - }); - if (injected?.result) { - const r = injected.result; - if (r.skeleton && r.skeleton.length >= 50) { - extraction = { - type: "needs_identification", - skeleton: r.skeleton, - url: r.url, - domain: r.domain, - }; - } - } - } catch { - // Page is restricted (chrome://, edge://, etc.) + "svg, img, video, audio, canvas, " + + "[role='navigation'], [role='banner'], [role='contentinfo'], " + + "[aria-hidden='true']", + ) + .forEach((n) => n.remove()); + return { + url: window.location.href, + html: clone.innerHTML.slice(0, 50000), + title: document.title, + }; + }, + }); + if (injected?.result) { + content = injected.result; } + } catch { + // Page is restricted (chrome://, edge://, etc.) } + } - if (!extraction || extraction.type === "none") { - setStatus("idle"); - return; - } - - // 5. Handle extraction result - if (extraction.type === "extracted") { - setRawExtraction(extraction.raw); - setStatus("idle"); - return; - } - - // 6. Needs AI identification - if (extraction.type === "needs_identification") { - const profileId = active?.id ?? profs[0]?.id; - if (!profileId) { - setStatus("error"); - setErrorMessage("No profile found. Create one in Shortlist first."); - return; - } - - setStatus("identifying"); - const identifyResult = await sendMessage<{ - type: string; - selectors?: SelectorMap; - ok?: boolean; - error?: string; - }>({ - type: "IDENTIFY_SELECTORS", - skeleton: extraction.skeleton, - profileId, - }); + if (!content || content.html.length < 50) { + setStatus("idle"); + return; + } - if (!identifyResult.selectors) { - setStatus("error"); - setErrorMessage(identifyResult.error ?? "Could not identify job fields on this page"); - return; - } + setPageTitle(content.title); + setPageUrl(content.url); + setPageHtml(content.html); - setStatus("extracting"); - setAiIdentified(true); - - // Apply selectors — try content script first, fall back to scripting API - if (tab.id) { - let applied = false; - - try { - const applyResult = await sendTabMessage<{ - type: string; - result: ExtractionResult; - }>(tab.id, { - type: "APPLY_SELECTORS", - selectors: identifyResult.selectors, - }); - - if (applyResult?.result?.type === "extracted") { - setRawExtraction(applyResult.result.raw); - applied = true; - } - } catch { - // Content script not injected — try scripting API - } + // 6. Send to extract endpoint + setStatus("extracting"); - if (!applied) { - // Apply selectors directly via scripting API - try { - const sels = identifyResult.selectors; - const [injected] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: (selectors: Record) => { - const q = (sel: string | null) => { - if (!sel) return null; - try { return document.querySelector(sel); } catch { return null; } - }; - const textOf = (sel: string | null) => q(sel)?.textContent?.trim() ?? null; - const title = textOf(selectors.title); - const company = textOf(selectors.company); - if (!title && !company) return null; - let skillsText: string | null = null; - if (selectors.skills) { - const container = q(selectors.skills); - if (container) { - const children = container.querySelectorAll("li, span, a"); - skillsText = children.length > 0 - ? Array.from(children).map(e => e.textContent?.trim()).filter(Boolean).join(", ") - : container.textContent?.trim() ?? null; - } - } - const descEl = q(selectors.description); - return { - title, company, - location: textOf(selectors.location), - salaryText: textOf(selectors.salary), - jobTypeText: textOf(selectors.jobType), - skillsText, - descriptionHtml: descEl?.innerHTML ?? "", - postedDateText: textOf(selectors.postedDate), - url: window.location.href, - }; - }, - args: [sels as Record], - }); - - if (injected?.result) { - setRawExtraction(injected.result as RawExtraction); - applied = true; - } - } catch { - // scripting API also failed - } - } + const extractResult = await sendMessage<{ + type: string; + ok?: boolean; + data?: ExtractResult; + error?: string; + }>({ + type: "EXTRACT_JOB", + html: content.html, + profileId, + }); - if (applied) { - setStatus("idle"); - } else { - setStatus("error"); - setErrorMessage("Could not extract job details from this page"); - } - } - return; - } - } catch { + if (extractResult.ok && extractResult.data) { + setExtracted(extractResult.data); + setStatus("idle"); + } else { setStatus("error"); - setErrorMessage("Something went wrong reading this page"); + setErrorMessage( + extractResult.error ?? "Could not extract job details from this page.", + ); } } @@ -302,11 +210,27 @@ function Popup() { } async function handleImport() { - if (!selectedProfileId || !rawExtraction) return; + if (!selectedProfileId || !extracted) return; setStatus("importing"); setErrorMessage(""); + const data: ImportPayload = { + originalInput: pageUrl || extracted.url || pageHtml, + title: extracted.title, + company: extracted.company, + description: extracted.description, + location: extracted.location, + locationType: extracted.locationType, + url: extracted.url || pageUrl || null, + postedAt: extracted.postedAt, + jobType: extracted.jobType, + salaryMin: extracted.salaryMin, + salaryMax: extracted.salaryMax, + currency: extracted.currency, + skills: extracted.skills, + }; + try { const result = await sendMessage<{ type: string; @@ -314,20 +238,20 @@ function Popup() { jobId?: string; error?: string; }>({ - type: "NORMALIZE_AND_IMPORT", - raw: rawExtraction, + type: "IMPORT_JOB", profileId: selectedProfileId, profileName: selectedProfileName, + data, }); - if (result.ok || result.type === "IMPORT_COMPLETE") { + if (result.ok) { setStatus("success"); setImportedJobId(result.jobId ?? null); if (result.jobId) { addToHistory({ jobId: result.jobId, - title: rawExtraction.title ?? "Imported job", - company: rawExtraction.company ?? "Unknown company", + title: extracted.title, + company: extracted.company, source: "CUSTOM", importedAt: new Date().toISOString(), profileName: selectedProfileName, @@ -412,28 +336,34 @@ function Popup() {
+ {/* Extracting status with page title */} + {status === "extracting" && ( +
+ {pageTitle + ? `Extracting from: ${pageTitle.slice(0, 60)}${pageTitle.length > 60 ? "..." : ""}` + : "Extracting job details..."} +
+ )} + {/* Job preview */} - {rawExtraction && status === "idle" && ( + {extracted && status === "idle" && (
-
{rawExtraction.title || "Untitled"}
-
{rawExtraction.company || "Unknown company"}
- {rawExtraction.location && ( -
{rawExtraction.location}
- )} - {aiIdentified && ( -
AI-identified
+
{extracted.title}
+
{extracted.company}
+ {extracted.location && ( +
{extracted.location}
)}
)} - {!rawExtraction && status === "idle" && ( + {!extracted && status === "idle" && (
-

Could not read this page. Try reloading it first.

+

No job listing found on this page. Try navigating to a job posting.

)} - {/* Status message */} - {statusLabel && ( -
{statusLabel}
+ {/* Importing status */} + {status === "importing" && ( +
Importing...
)} {/* Profile selector */} @@ -442,7 +372,7 @@ function Popup() { className="profile-select" value={selectedProfileId} onChange={(e) => setSelectedProfileId(e.target.value)} - disabled={status === "importing"} + disabled={status === "importing" || status === "extracting"} > {profiles.map((p) => (
+ +
+ ); + } + + if (status === "duplicate") { + return ( +
+
+
+ This job has already been imported. +
+ +
+ )} + {!extracted && status === "idle" && (
-

No job listing found on this page. Try navigating to a job posting.

+

No job listing found on this page.

+
)} - {/* Importing status */} {status === "importing" && (
Importing...
)} - {/* Profile selector */} - {profiles.length > 1 && ( - - )} - - {/* Import button */} - {extracted && status === "idle" && ( - - )} - - {/* Error message */} {status === "error" && ( -
{errorMessage}
+ <> +
{errorMessage}
+ + )} From f8f63cf257fc46fcdce91b1e729c0f32edbb96de Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:22:01 +0100 Subject: [PATCH 55/64] Return 409 for duplicate imports instead of silently upserting --- src/app/api/jobs/import/route.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/app/api/jobs/import/route.ts b/src/app/api/jobs/import/route.ts index 98c4396..163f93e 100644 --- a/src/app/api/jobs/import/route.ts +++ b/src/app/api/jobs/import/route.ts @@ -72,14 +72,25 @@ export async function POST(req: Request) { update: {}, }); - const job = await prisma.job.upsert({ - where: { profileId_jobPoolId: { profileId, jobPoolId: poolEntry.id } }, - create: { profileId, jobPoolId: poolEntry.id, feedStatus: "NEW" }, - update: {}, + // Check if this job already exists for this profile + const existing = await prisma.job.findUnique({ + where: { profileId_jobPoolId: { profileId, jobPoolId: poolEntry.id } }, + select: { id: true }, + }); + + if (existing) { + return Response.json( + { error: "You've already imported this job listing.", job: { id: existing.id } }, + { status: 409 }, + ); + } + + const job = await prisma.job.create({ + data: { profileId, jobPoolId: poolEntry.id, feedStatus: "NEW" }, include: { jobPool: true, application: { select: { status: true } } }, }); - // Fire analysis — fire-and-forget, reuse same host-based pattern as requestAnalysis + // Fire analysis — fire-and-forget const h = await headers(); const host = h.get("host") ?? ""; const proto = host.startsWith("localhost") || host.startsWith("127.") ? "http" : "https"; From 67a347ace2845952bf987f95caf17413f423befc Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:29:52 +0100 Subject: [PATCH 56/64] Replace extension icon with cyan checkmark matching app branding --- extensions/chrome/icons/icon128.png | Bin 1716 -> 2285 bytes extensions/chrome/icons/icon128.svg | 6 +++--- extensions/chrome/icons/icon16.png | Bin 441 -> 552 bytes extensions/chrome/icons/icon16.svg | 6 +++--- extensions/chrome/icons/icon48.png | Bin 856 -> 1053 bytes extensions/chrome/icons/icon48.svg | 6 +++--- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/extensions/chrome/icons/icon128.png b/extensions/chrome/icons/icon128.png index c14ef605237ae1dfe354aea324d63a413b60c8e7..d338b857381c52a0aa616023e0d073bcb03fccdf 100644 GIT binary patch delta 2143 zcmV-l2%z`04eb$-Pk#a{P)t-s|NsB~{{P?X@VD09n8eU}vcg@WwMv$&IEm4{Q3O+W~aCte3l%1muIKB`uzRe>+gQG#3X^5)_>^i#@^*tov{yfkVcfM zw$o<#|IgO;N&e#`ul%>no{QdrWv%>xU{$HcDTcES_`1(+q zu1lA!^7r}a@_+T}^Y!8G@iB;?>-6?7ho9*3_3-xiPnxb;ptGvZ*dTtG9+TuWwr^4Lh#opwPzs!uh%7wYd&En{Bt-jvu@JyJk zfwsjZftoamq3rbbWu~|&gPh~<@|3~OX{fr_=@r`~Cfbx5iYQ zuveb4xjEP0L6N9nqqdX5%|nx@A%B^*)ZKu##bKnj?e+Itp|nVqtHj;p)8*@rzRN0v zoocDNzS-fI6^JC0R{}1778)64>l!@(00s9+L_t(|+P$0oLli|A$LDwxj*$yN;SxMT zM7sqNq=+~YlH(HS1cI~-Op16>Q#6A#GkYTfLI_Kfq@ohSr1$#s+PxQ;xtpE0o!#%R z^UQp{w>!7bJUg=hNXV6vnwFliEi=oV4PmxBD|6fS^t7B5m&{my!ty+lyCV-0m6yMB zSAnhYLT~P)kfGg0#e3|b`%+6DW6`53-J4UU9KWx8KOa4+{kaE};ve^GBKU|ZDpgHN zSJe~3^oR~tE3r^>NDgnPw%9WM$*1J;hU%WSgs)G3#u^{OhCsbMzwueCcteMqB=Lpi z%_{g1YR^esgd;6~YVd}F5>JKJY;}AHL(dE1j~1!K8)|D8;rlvN^C5hpke?qurk)RB zXBRKO`=A|s2oJr;$L~384DyL-(}PFk1F^wbN;_#Ctsh5jP+Xk zc6<2{o;*r5P-HhB!T^o`s{MSZRh#pVgd8vc`})oJg+V8O_z<2lTeQFC3?IUBGQUxL z=`AVkG>Kn-+$lbU1Eiaf?i?S&*OU2gG&pG>G?--IO(*#fW+d`!oaI9}g!5g)P8-O_ z4OH1f@sEVVBYtw*C}u#i{$=P)bnvsp#$(ts$QJ&&`0(Uw@Rr?wf%BWgj}Y5WZt?fo z@eoE<9${jCfOcUsAYcQ33|V`1kwY%V+siJg=MRUGJ50U>@KQV@RI2B{eHoqOCVxce zjkUj{oIgH+^4~QJyAo|LJs;D5gFn$@K2^(~MERFLV?<9Wp8}tw z{Q4cN@M%4Y86+aC^z_Xz<16%f_y&7eZlMVPw0E#Ol)AvILOzxN`0nkX%0$rHjq<Qe*el<~*0wf;@MPV?#tBHjdmYXM{^;`1i}Oda4$HGF{tfT{i9I4D)Y7fS%B+(5;9DZvDQDg+j3&9vHXOC9K5gs)D>`y$oA+31+J=ygk-PcQ-C z1}L|{A0Ks&<^WFs=f(K?Euc)o5y0U9uK>;o^7Tphm3brb)4n!yAe8{n{vgx5@it&O zkV^oF?tpL18%NN=iFYCUE*et2B+Q53(vp#XA21v&VY~BpWwtsysOk6?-9dA!WCB1G z0BMv~?H=YJaUMt~07Ua3mDWskAfEsbc|i)bo#Mbk0zlLcT=e4H)PZOPK$gBN2l@06 z69>W-09ng=@12Yoq63KvfGDFr-^B_dIFP9Th^o@N7cire99T^NTZ-%@GY`l?Z4P&T zfF*!d3rAgAA|tRSLGm>~#a#iA^j&0#Vva~t2mBQPNzq-4Jv<9z69;k$Ag&MNZ zW;r)3uxlWn0OF&iYz_7kZy&^g<=1d<838_wm2fs`U}YG@NkcmnR`8Ra4?qjrWhBNNY)t)!8K{`FWn?#1e4Qz%PW% zR&U!e!eq4&PZBjbc8sTa6cEL+HdCXEWIot)#~J>*>Gu~0lpba_(*qAMRh0vO(?I(b zP}F9>frm6cXkW9Nzri@-T{dGce~kGN(rpW$l(1DBX7Tm4ww(u?yV-mIdK7P`Z>e{X zhY!GS>-O+#IS;o35T3DvKgNB$U#~ef)boEA_`pGb9t0FW6SapzpLpnM6#BZ3r3d!~ z@wziDZ%Up6FaWm46LPPwvw-#?yKvaK2%mBbqt;a;9qd}z`q&(~E@VvX;gu!J`> z^oKlty~#4((4RAM_*IGjo{9b{Srnyfb(PBShWh{hM+Bc$enBbT(2DoUH9org-{KYJ z_=tS}r4O*^Ep3g~{&y^$n3}FIdAD1-JUzv=fAei{@@8rAV`lMvfb+Dr z_}tz6?CkxjtoL$t@^g0b@$vqjq4uDn_VV)n#m4%^$NKyG|DK@sprQ8o`2LNL^^TDB zj*<0?j`g9V_wDZeXKL}Qt@r-^|8#cp-QN7Qw)mEq_I`l#Zhvv|Z*uanv-rr#`qb6? z-rxM_=>6*J{o&&L*xCEf(EGf-`GAA;aC7phtM}jF{My?5e1G%3zWMq3|LN)dwz&9u zee;~2_VxAto1OM(Yw=}h@xj9Rq^9?5Z}Pyw`ID9Pe}eSx?)}=^{G+7znVa_J=KZm< z_>z?Mf`#;ef`9ahi}b(1`QYIE{QUoPck^j$@syVJ#mD-Qll9Ke`*nEpv$Xii%ldJ2 z^3l@!%gp+}!TDro@u{l!tgZNniuAg>`S$ky*Vp@ZdGpQA`_a++f`s(w>HT|s^LTsn zczW~t`u~B0^zH5a<>mc_hxFLl{HCY()71NFZSr+@^MAs_`pnJxr>XaUf%Cb#`Gtt| zqowzTiS)3t`1$$%$;$fK+Wh0>{g9IN&CdFmn)dYc{=UEYgogC5vG{Cm^7r@ty1e0xo|GJ2XTf@P{Hi zw_FGWR~$h>D-|_MD;G2=bHhR_(=xP0ZLv*T$;!-%%JyBd^}kHg^bHJp-Z1lSoiq1G zIA^{e<_vGSGlLLWs+d@HoJNafDy=47osg(Pkt!*9gIv*Mr6eZ_#;2yq_RD|6&P2K{ zJb$BxReZ2C`jB`-2Fv(h84YBLsP*nEr?fC7I|017|>{LcWpy0~Q1<}JF?vT{d7My1HTwOBya)@m+f zf4ePzJ2OC4O|4*$VaLv0OaOKD5xXp0c|)Vz0A~|gvzlcFI2~)}n|8|#XjwZSwa^`o z8sJ(dAMKGBVBWhk8 z9qoUC7BCXJ8s$daOaXtmBWwcZ#?G(=^qvi018@W9*a7f)fqQ<48ysf_z}**wRziki zaUDl78Q} zog$;@kQCkI!UeY&0x)(v!U!hHn_$uQhseq>DW0wJLC_H&VQL13;6r% zx - - S + + + diff --git a/extensions/chrome/icons/icon16.png b/extensions/chrome/icons/icon16.png index 9e477728fd47c07ac387659f3db2ac465b63b766..903c6148f58b95ded99ea42b7c951d5a5595d69c 100644 GIT binary patch delta 407 zcmV;I0cifY1E>U$P=C5mOjJex|NqqH>vpifH;bbfdX;yvz}4sM`1|~Et-cU-kq&f_ z5q6PtuD|*G{7aXtOqi{3t-W-wzh0uXRh_Zc=R zwZu4#qZxaZ6?l_9kEZhX`IN!V8he(Hzs%9(>L7lYF^Hh*^HKGu%-0WfkQ;oK9etP1 zp6|2;O+6a*4`3!lG5es{r>*P-{lv1lzOtlkG{(ueV5nh?M<1jPMNOy{QbFG zN0tBp00Cl4M??UK1szC{y%*R1_7D z0*c~&A1KQEzm^Rk^8LtUcew=y;c~U!kg=u6ZhttQ&X+eVC_s}ly58=O z{HGXnPR{dHluZE4YB468TJOL9q;!mB^Wr^Ai+decc%Fz8+P~#y4;(x4nD~>30WnCa B*HHif delta 297 zcmV+^0oMMg1i1r{P=9_+AjU^8VV}{ECeA zjE?pH|Nqq0`>n6|(b4;Vg7luC_L-aZ{r&&M#rku0@^yIg$H@Br{{Qs!{(F7%kCFAW zwD_f`_qMnAm6!FfviPQ`_xJby$;$d?YVl-e@o#eSW@+(sb`|ohuK3oRGJF6400Cl4 zM??&Pvr%J{AORtN3pp-#0r+qL003}FL_t&-(_N3-4uU`oL}%%;fK<7zq6o@G(f|KJ zW1`Y}-sU7TO#u_okH7+k6d{}z4)n)Im?%y*LZrOiWw{Uqm%8HOs`hGQe`t;;DTrD= v3(@tLD-UvX?9 - - S + + + diff --git a/extensions/chrome/icons/icon48.png b/extensions/chrome/icons/icon48.png index 9f9511782d4a163c3b324d6fdb66b5a2c3567a15..fe89948be74ef863e10b03fecfbace9425e00dc5 100644 GIT binary patch delta 900 zcmV-~1AF|~2Av3yP=7>FOjJex|NrRn^{UR;cCf%unyxU2pdNjf6L*rU&e-ks_m{)Z zKai&mbdW%ir z>h7`9+Zub8veVq8%heKgk`Z>1oyO9h$I`RY+}-Q%8+?}8>3{C|{QNwRrH;PKk-*L8 z@$|;t<$AKhHHxAsgq`H?@|wlaA%B_J>FxCR`azMX4|R~p-{w-Auj%sjyV&6U{{CR2 zwyn_F5OtALoUks3pNP81oW{`~ewcBsz7%(pO_{B}+2Iv-6?>uD?T*sE4}9o5j(4v%-(Q%aXv& zJ&&ff)ZL%Q)1b)H-0ScA{r!2e!I{L+?)CTT^Yt@{p@bFnqmzXJE`Jsp7WGsItN;K4 z4oO5oR7l5_mg`^IFcgN50s?wKO9i~b>`;ab@PdkXDLN76hL?HQ>4v(w>Hq)Tgde48 zlcx3k)TGZ_(v!C*0NB;n@AC%+g291+-xulw7lj9h(4t5*EWw9I6yy|BhaK?Icmjn| zl4Bx%d;%RcImPqS34fF*mA3OAW>6~aqm|E~tD=_mXzm7W*5v21?mS}RlfmaEyzq$g zqg_MZ8;|(8yT387hD4!b7;(WSi$!D2rB2=yIln;CSL|NLXc7nI{NfUr23#hCAt_&8 zA;@aP7_I>nDl%SKr!lVf#+41|m+`?(!f$OeGdlPrd`;)7sDFZAVu({dX_?xAU1$Ca z<@c;}0jN9j`;<@Ha$JUnS%GGgt=RS!;lDO)PdQ)=d4O-`^ZYr&f9Jm217=ArLL3^7 zqg66mDRb*A!*TEW1WX4F5G*b7FBV|Rs{No2opyuMGn&c;K_zzBjq5qiX@D*+MWs_A zgq$O53lOU-*neP4sU1?swLL(lNcbRvoP+2_$m1=!6-9yLrV7Py0n@M#~uwPyczm213-W%UGTBX-!JZ`q{w3l}Oz#b5fu6*U`okv#2 ze#p(HWPJ7kIQ@~-sy!zXbj}Xz}Fb{idh)sHyjBZt{zc^|Q42$;$fD)BDTJ`n+JpZ_x`M| z_-1MGZE*6;&HCTq{Bm{j-QE1?=>3qA_2A(Aw6^%LviR%k{lCHa{{H{!>;2u{{O#@i z?e6{a^#0Dz`!~0^`K+z@y1e=7>iuwY@_>T$>FNEivG{Cm@`8o*g^2Wifb*%U_vPjN zrKk6cjrFLi_fq~)*prpGVM#y+`Xmy?l7a@8dG0N6YT#t+$gMO7hVjKc$KnLG>{>}iX^?wn&1z3Ya!-030G3%lzakzBtCUTzF|e*O z)(yU-O$FeHf#!;2U~P%{Z0}@=0E@eFq(4I@qyRpsSI2p) - - S + + + From 20d2afaf08e7970f180872104fb43d73186a9ef3 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:33:16 +0100 Subject: [PATCH 57/64] Re-render extension icons at high density for crisp display --- extensions/chrome/icons/icon128.png | Bin 2285 -> 5453 bytes extensions/chrome/icons/icon16.png | Bin 552 -> 825 bytes extensions/chrome/icons/icon48.png | Bin 1053 -> 2228 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/extensions/chrome/icons/icon128.png b/extensions/chrome/icons/icon128.png index d338b857381c52a0aa616023e0d073bcb03fccdf..891bd3a8940b7ee19a5b7d961220054222928807 100644 GIT binary patch delta 5398 zcmV+x73u2j5zQ))83+ad006^2Vat&rE`I<9VoOIv00000008+zyMF)x010qNS#tmY zmH+?%mH+{tH`=SA2}oP&aeOg6p2YhK~#9!?VWpcl;xerKi_vI zlN$*^4OlLMLW|mRM}Y~7)C);$UFzv|ZO@*yTf5p;z(iT=y1J)ly%GppwVvAAvw!FG za<-RMtL37#Sd;_=7Ddb2qJoN`faV4vnPlGI{xKO%APF;*nRzE+KIi0*Wb)4YywC6V zJn!>-pWpL)#1NpQdNqK;SUyk+jFT}DF%e_Di7~)P8N*Es6Xan85WcRj2m>L5Ho*}y z8o>@p>;t_G_6D%m;BA=>%ICD2nSV9kzr^cDA29N|0EtjkRf|b-%pDF)Ff7#|?*0bUjMlEJGo-ZV!|7}m04sn=B#H-p9j zimF$E;YW-HW`h>Un1gJhnN9;7rR8aiu4;1)Ym~9u#1`Ps7~4SmfzFnirGH*mSxjWK z0~A%S0&svcWdsdfiL4BmAe1w(Z%-bEA?U#L7P3tkYd|ju-ZSAgXK>GuaSl+hdt%5%osujElbbmH`Fmp(2ofqD` z62I%rPU{_wSBHUiTE+uzMi31c>*8&;=x?R{ElFmq7g$+@p zV&GDVBE+`bU`1PdUGkkEsV%a&Y857dF&1$T@KvSwHFF9O#((&=!E(VnhD%FL^2DR0 z8K5I4@X9vU-E0I6!grN-zho>A)kB7I;@* zLk~rM&H-kDwhG?Q4VEXx2?p!{#Z|S@63&wNiO>bvpLvX8Gl4t{>-fLQ;T4C^oXPjysn&6 z2aPskI;almaNgWS32Oxj57G=5V64Gvi8)?Z$*G3WJi%%iV{sDib`$CWiX*!I2w)|! zSm_DI%hH|5xND+eqks*$!Sc3H0=+=o4$x)%T%p?~eSce7>wAno(>4EoJ~vn%j(0o| zmje`4t-=Z9Ksfvlz#T|KI(TC+Afc}5_q$G^cI3{EZ#)ol>y4^>GP?-)1=2XLA3n>+ znDLI_8iVJh*n>~w8U)Jh6TlC$#?L$?#vy(n@rih@i|G|mRJ95qM{qmvHL|b$Ol5)r z7#nhf<$rD{h84t<_5j6It3U#ArHOB4ji2Ed-vT}-ebjLK<>aq`$S7m1(5-@Eub(eV z|40-|_I=>2SSD!wIzU(St`Floz=hdJp2314}-R|5v3 z!la6<@so?0VP?2pKhfWlix5-*Ly4&2^K$%*9zKlG z!-tWVt+O-PnJz-WKbqMuC5Pq}J=5Cwd%HrW65v{tHGZ;i%_zw6 zv;6WoTt9z0`GJq~TTCo1;o^zoIRDJkx%ZhDY47YLiEC^D;95z40Bbt_!yZatwDk*B z083Ht4w5B4%#47~Vd=uzEL}L8yqpuOHGjwFFn#Pu-Z2E$G&En!4{UeZ+;8J`u*(0_5? z{E-6`SJx7DU4So0obGky4R#_^JGg4fB>uf}J|+1ccc|iY#9~*z$oftf-Fg7S=hGOl z3U5m~_=!&SE}cA{yRW*Kkwvl3q+dVONW-zV)F>}Tg=6mM?vpV-@jGcxAuTr+;Tbr<*kaSLt9)c|^9K4?tT&fRALvwwi`UR2yr zAndw~Eh*yu#g`B~drVx{KCyE*cdXe&^RZCs^WBQ^h*{BJo#N_R%?<<{SCe34`%puAjSmPW2_^`lyUH|`NXqprF%s4Hms~Q7>&m9# z_r-3+-G>^edTawb-#wVF9QVg_IxrQ4(`g_vooxRfGx6yA$Fr}og@2nLe~$V+`%^jH z>mNxbraLAapHv8(pN;m8jYRbQD2G~Fx%KxOdG@bwd1w5O`E=N^LPut$m`(7qB!h*4 z==)8_+PP!RCZ5>2JK0OU2|kP_0B1PBSws$6%F+)5(f3tbw)05cPF%ChFg_ro2z0iCF%h$@{@)Kh_5Ii< zOPyhtN9tZ?^>f<@g~J(Rd;o(SgNc4a<1?sS8OMP1{cXFr`{~WJb|gK}bHGH3W8)pL z_=M|Ih(V+8Cyd58pxE8|6k;&x`w3$<(vcXR-SyX#L8Vk-tG*vM zW=IV43-Z$~0!P)fsNY3z@AW8okn8*L5h-K7pEPg%iq5GE0y&H*h$v|dt!=c1I&kpL z2?nI^H)MdmpBMo@X-w(v(cZX|677-xe)jp?dd1wh^!X-ZCV%;Xk8d5>8#k7zqf5ExnJpX*b)?M+qWb>dO`XJ@m1UIV$DaDI@mM=| zuGz#BJ73QXeLn#lh$Q_@CDCsG^Pic>4HwPmVf-lhfgEnQcm}sEinKsC>HFpTE>}d~ zUl5nRe>Bv=J(;HO$4}THvVAz^6a;ek^32Ko?t2UPe1Cj<{`9ms!HMzvMKNF7-Wg`~ zhHX4j_i{$*`w0+o433fF)@Hzn{5&R=o;*%IT~3g2{C?QwkxegC``mUyVRtYaAFyMN zj3b#A3zTXni0b<_iR$~$Z01-7>ih9x#u3M`##Bdx31~Rh#_NX~V!kF-PS9Q7pEo4+ z{Wy@Ce1EyY@-Ip{C)M`~g~Ncj{M-rT_+p!5`y7WE<3>>s@UwaEfdMb6M)m#s7GJ`Z zpFSt9?|SC7y;ME6f%lq^X1>Z7JFob1gB6!bOiSfm6v;Nq8;6@H2;?y9tWhynVzQhd zs_*~ca~JWIndipUg6sF}XUXrLW$%YgL&^9EHh=o^%F1T~DL>{-5*@C~i*FquKgZ9E zvGF;<%yFYA2;{JN@BX-*Agb@*wrC#L&YcpM5qG|OkQ;ydEdTf32SdvET`$xpe7W<= z&q7>D8cz6>L?|3)%icttU}W4*5Y_jWEu78w7R(|Lhs3-4PyaNuf0ep+svMkk2s``49CXZhuG$j^yg_}tgn!m|JQ6B}QDXUG{Jz@Q0u z*cS*clF-$pF)d6woZusU|AOiaRW9r&5Aa9(+bE6ozj zO?L@W;RI2M_p-?osJVI}qld>H@!lHh;6K*a^RvybU<_&FgLu+0J>|;@Rvce?q%-#;}+UF!$U*m>jyJ^09paO=kH1! zxCgNtVt9;0u&h z;23QtP4QwJ(4B$w1b)Y1>gZC&mVXq*)qOv{b2qpCej^R7?U^0IoiqgSJIS5^;S1HR zBe$#^P=VkJq(Knsw|E0^0>wFH8>0IDNw5&2M(|oo&9aZ<0flV`d&}$MLC@F0!!Nu< zs56}Odkm)d{bXWjFKD~~B9uL~F@l}<3I;`ft`U=#3e^mhj!9Di^C3uCM? zBSm>i<0lnPFzDj-NYrHP#@g$=s$guo8X~!a`z^x^0`@28;M`4iKpuwE@2{&=izTYpVji7sh_U4*pJ%2trAw))UG1qBA zJZi>8%Fwd|Kk5nAZ|0T#hcarjX+~kR3m!Gm)LR~iUTdc7$ng<~-w3-jle5V7yWRX0 z9PRh48~}S3_#)7FeCvlkP7cL!k?S-V_9M_dGWh&pNjN}TM<@H6jzsppHJBsNc ziAJ}NgXnslN44C4Q-5^ixE8agGChM#bP>qu`iXZ@Xs9B13fVftII?0{um98ML^{U} zVWw7t)iQhE>xan_A7=JGrqw`ePG{fI(0w|<2P>CHx>B?Scr?{Jh_fV}M_qHbVDMq> z_xk*&exP3KVrC1{LvkFqrDj>b|9Da@Reyj<`w;g4&0arQmi{rL z*^Ij}_9=!Fh)?POOSbVx?>Qr$?cd9D2~<{a{y?lem#MC z<>UuKZ+C`$jemm6$WWR16y&JE@*E325tg6s%e^Nft_a{z{W^Seu0*iQpb*(Sp;IfU zF2gV*45|gKcJYT>YL>=zecU17?e*&jlr0J)wu8nArg~ior!I!juYlWtqwyLa65In2 zs#{NPu)GblRqz=gtvideKv$U0(})`-I~ePr?|2gKzJGlJjD5foGwQNJVfu*Fwd%l@ z7`6{f{4JdDE}*u$_2dQ1F>x4d8{z_?(Oy^1(8myLGq?`;izJ#8==!4!Xd762{W|i3 zi;(ob&~}Vj!04<-kUGq?9r!lrB}p{LG2-uy0q+BBuUk(bSc%L%0NP9><=i;0D{9CQ zdBxTN*MCX8B%A@Y{3uD;2pXzeOQ5V0$qs@x8BE4(Vs=eFIYjau>1o0Bg1;b=Vth#A zPTix9bzrShF3l@$@T5GRH@V@}D}Qlu5LN<|8~P?#P-IquiUDC@&?Z@(p`QY4 z4H^P2e7JI{r$zJJ0iq1M`2ifa4U?}}umJQ8gG+(aGNzDm(se}khZxU+eh#())EHUB zS>ko^fDCegD8<#ak(sc_15E?_f>5QwWTp9_>x3~20e?eUV`h&7I}mMR;H!7ZCV%4` zpgYA?wFn#&qYQ$;l^FAZ@fnl1zyKKrA(`GL@_t_f_5#@ZfZLqLQGL8*v;%afxM~#! zej-69Gr<-h=E^h)aT;kn1wDefpaz58z!tELGPW7o4|FCHpXUXG#sRuhRJ{stC3A{F z6M(5EW`La^`Q1tdMTT;EIZURH$A6B-wDtTf@;0Ia*%5=onD!!jRmKj)tA_TNXu_}~ z8P>hW!k~13<0-7J!GwI~EQnXD7xj{tWRU00000PbVXQnQm4{Q3O+W~aCte3l%1muIKB`uzRe>+gQG#3X^5)_>^i z#@^*tov{yfkVcfMw$o<#|IgO;N&e#`ul%>no{QdrWv%>xU z{$HcDTcES_`1(+qu1lA!^7r}a@_+T}^Y!8G@iB;?>-6?7ho9*3_3-xiPnxb;ptGvZ z*dTtG9+TuWwr^4Lh#opwPzs!uh%7wYd z&En{Bt-jvu@JyJkfwsjZftoamq3rbbWu~|&gPh~<@|3~OX{fr_=@r`~Cfbx5iYQuveb4xqsK+L6N9nqqdX5%|nx@A%B^*)ZKu##bKnj?e+Itp|nVq ztHj;p)8*@rzRN0voocDNzS-fI6^JAN0004EOGiWihy@);00009a7bBm000id000id z0mpBsWB>pF2XskIMF;8w7#12CW$PL}000H|NklGlhuY5lrJ*xe=2bAI;_iG~fh$<>o zO-fhQ6TYA|GXBY@<;zM}+1e@Pw)Af%k_%d_;zhWm}pNWk1 zTKjf;`4FBwN;ObqHy^?PjsL3se5h5M^N)laFaZ1d&G>~uC-@MaGF!C2<_sUgax%YB zeCaJI?KFvBe}CL5K7<3Ln~?4tAHvs@`EN8hX&^M1WZ+FF`4DC#@@t&sLpX%B zP=oS6xIqbRi-h#5`Trusf8xz^p<(mH_zf?V!p;(A$mj zzu@Xq1LlIx#>1b}M+WGLeECjd+x;7c`pfdqi56R3G9FOmQ-1wob) z140P^)i@|s!52#asN6usdnv&LfGPwQY0b3SZhuQ1=w5`cPRRQr)xg>4n78P4OPxGQoJP0hu_kYksmM|EMdFzcV)IZJE-aS7TrN}t7HN| z6n_9|lveE?<{)t%NGAY9^B|ShOm!fi01$aW3bmc$z(N8*)DK+r;@s4MXazu)zAOj% z^bivV!W95n%X;sfj2NN=i3)%yqdwoo3L-d=sQ`$o(z_Qhqmmq0O#oYp>?Jb~$U$um zcYq~;Rtra6S|TH`CPDHwK*e1Fkn~+-h<{>^NK*&=6#z-mU5hzk!D|K4@RFo4>(0;$1dlFMofG`4Q4>3!jv*RU2mU^|iL02b;Uud;oeBZ>Mjm zcaVn z_XY8~Jj?3%t6X0;Bx+?+)rU^Gvfn?RRReWgDC(qYaP=8{L@1L-Q zH#GEzJbt~>zXiGjjM_w#kJ(IuAZ8k3u~Rhaku`Q%5#qgJ2w{Q Yy`0OP1pN<5sr=k2)xb{x0FxdSFC}V&8~^|S diff --git a/extensions/chrome/icons/icon16.png b/extensions/chrome/icons/icon16.png index 903c6148f58b95ded99ea42b7c951d5a5595d69c..9f864f98e07ea3b61318d407f9179e3e21da11e9 100644 GIT binary patch delta 713 zcmZ3%vXgCse?6mnfKP}k0|NtJNk3ol1n#mP?!w8u#S{37`*})xx$`Fi*}Nqa_<*Rm zA4Hb)^8yh_5JZASCP29mS)ekYY!OI3FAM=iK-z&^uo@7z1f+|v9%w6&1rmiA4N(Uq z!G?m(0&50|z!1n>5F2DfKTlCVPjNqg>4el}J0j+9N?*S7*~ibBD|c}h^+(R%^!WX! zmL120$|eCVQ>ve-SU;1ysLyxC+WT)lOgeB@q@sRu{njJo{@wjhAm1 zoVYAmHPx_V!I3+U58ZhjK5x^xM=#c0xG7&d{ow6K-+%qyaqXUJ<7{9^7<4RX-hNE4 zZ64TV{lHMXa{Fl*kg_QW@(Tvi5Ww!S?9s1>o*a@B-%XNSzh07UBAe&iH=awEK73gJ z>(|4FOFiGb@nD<429%PR@Jmb zv(@LXf0KQVNa~r_9$6b&wl;mK}YU z&g1CQiz!y$KT}_d6asx!jHbo9(|YB=pF2XskIMF;8w7#120 ze=*KL0001^NqJ0-kxM^j&i6t#^+E~Gsd81UFE^-H0seAnaq6@>H9*rk*3h)qpUv9y4 zMs^4x;(QU|a<$%&v8Bjve>k4bmn%|&F{@hLH2?qr diff --git a/extensions/chrome/icons/icon48.png b/extensions/chrome/icons/icon48.png index fe89948be74ef863e10b03fecfbace9425e00dc5..aa49685359d86a5fe775d7fa8c8e4ca4a7eb284c 100644 GIT binary patch delta 2139 zcmV-h2&DI&2(%H983+ad00374`G=7qE`I<9VoOIv00000008+zyMF)x010qNS#tmY zmH+?%mH+{tH`=SA2-BGKNL!DC~a{?a_m+57DM|7)+a z_SzRQNTe12lElVjHK#YF0@K0f0ds*|p)4RBNQhLw1vD5|2V4Lufz!Z8;}(AsxLP*V z8ee*LP&_nX;w7aRcrbE+BA^H{6Mrm2$OC`@vhK}y`D<7RxGp#c95!?axCFEZc5WCR z0cpjhfH@`}0X-#Sm5FQ(!U)}AW~f&1XJ8*j72r1RicLtrou_QvjpKm9Siw5bI>WMc zx4e4`0aOF*G2=JFuDEh&+PQx40#b`h2^sQ;eBkGjEe1J5%)F-#*WjeE&3}eY3A4ss zxBosowuKJ?}LL#?Ne^e-S{c`xFJLeER2jx70ZGJJ?dK$Ag*FW>J5dx+>J zAhoy@rL>`-_dCEZB;7OeU4Ia7Fke`0OGD^v%90{l%R5CxeLA(I6k%Xm1iX&OzSqoq zpax?tvNCf;;N=YfbVd{dGmSIY_J2qojBJB#W*XNub~*s5CA)DvCW&=G!9C$;*6r5| z0*L~NXB=Fmg}>FX03pm(9`Lk=euj#TJdhaYB{9y6MV|Gl2f}#9q<_2)@AssZ>_)-? zHi4Gi3-Yc(K~6R=t}I~nqf@D?4^Vw05H@_5sYd{^bc|L^M}7Hz^MWvAnqVb8@~@F0 zZ!DNHfzmZgcqnspM*)>JS7>Q#@8d0DunO@S*oBA-*>b}sjkNQ)X3Ux}mR+k0JITiY z!6bzdAl59BjY+%K~J6i8L=F7nnKH zWg=BIysk9R()M(XbWymrcv&@$EUeI1!S;_7Ja}_>k95oyB!6R`Ow?Fx|oQ zmEk6DX2g3uY$%+=)A_UTI^hha-F5kG`8jqUuOJu-4TyZWU9!Ajqhs-ZGb7}NXGf2t zm^(8w9Eo)c^M6=hFdLuO6CIH^DlV}7=oy;Z+M^>fK+}x!O7g`*BGnhqq6a4eaPnWZ z1X_drcG{5+Jo(te{N!u%Ns5n)mK~`2l&y!#XlQB0aRwJagc7{5T5x^g95xip#(+P5 zaGtG)PEp@{`_4P^n^k!^{H$mp>4}|oI`;9u*>vDNu73ua@i=#}#8x<&yVYO4WM3SM zADl?M*MraN;d}EQ;rZo{Gb+i4>-Ld!MN)*N)9z>cip6B6Bt^^4d{IZqTPLWw(Rg>{ z0mE)NKz)A|Z??8k_E~M0Z@}TZb022g@KwefRj)WtTmz7CIN)o3(zf4Q8Jz96x zgt5Hxt)=9S&xp43>9rfI|I;xls{ezd*n_~GAn~!sx4{3nP~M#aktAAz?VSFijxouJ z-9*gHx{r*MB+h((nWoktQ^$_sm2WO(_N4LA_kS& zL)>vp`x7xHIgz^hCbq9w%vW832wL zG&+WmaR{S&C`8V7nmTtnG4JU4giw|+Nf)WM}H#O7st)kHbRl~V_0BTZSW1mxxmh6Bcap4 z33<)|dm-Y3sb&xZG0ad6*cRXl>Wq3(0fC(xFd$>X)iT6pLk;(uatF+40Ds$@ z&=NX@Ah2^|w5;1=K6v^Fp_yv{&dX?t94^G)WBxahZN;>=J zcM+8sO$PI&=z;Y}qrqUS(5n({fj*92``k`xEk8p10)GgoKvsh>M<{*dzH7sb8lfM7 z_Cnv}Zz}-8?&QmV!Ud}Wy$_lqaeqI@T&zeqbNw z{1cfQ7^njvXundjr#3djU*hD$7TPuUM z-l*bI3M83jm1|0A8X)lw R{0~3bQ+P{-8(@>{12H&*?1}&Y delta 955 zcmV;s14R6^5uFH-83+Ra003az3AK?SE`I?;P)t-s|NsB!^7X3D*mkhMP@1kVh@c*Q zmlJoAtIpW%_4k*<&p(i-4s?(}k*Ao%(B<&+cd@`1d6XD>m3Xni`uzQt!p|9dm4>;; z`1}0Y>h3y@q&tqK+v@JI(%Tw)ma@~_q|4P3c9Icxk)6iUp2yO&)7;(b?;CuU*?;No z`TYDmkEM>j%aOp%=kfH$-sO6-!ZnJbDTJNm@A8_(&>?@B*y-)``1(PSs1J3J$lvBt zo3H8e^}E>M{r>)7qqeQk*${P+Q=G6aho6YL$ehN}AAXo|t-cg@lTDeezS-dwc$25h z*UaMQ*XZr2&DTPbs4s_~kH5^O%zxG>gPh{-@*sYg_WJupl&P-J+UxZ8bFRNblchtw8iJ^oQ^`ig) z00Cl4M??UK1szBL000SaNLh0L01m?d01m?e$8V@)00007bV*G`2kHYD7JnKR^;8F} z0006GNkltEV16o!ui0(wA81-!!SP=*Zff{J)4IuYiEmwDIehPt`w|Nq^D zAEjxNruF^Qq|aN@leZ@T*wxqX^9Kfk!GVC^7wQ8Sg$IYwqDV9>!G}i_|V!c5(nk{;u4q!Tqc7d zDPLY8$ZEtGt^pJ(GG1AyF|PK;l?~{Z@xe{PZ*4O(I`|}fP3Nkpf`4CPh*Lgknc9I} zXZ{Q2_pEaPs5|oeluz4oT!w~Ofo7Af*!C9Tzcy@7IbaNVfN$pW{5isZ=f2wmW=SnV z92$p9M8fG#dY zrBfkN_#lFugXl)c<1M)rMS3~LYQyvLpc z-}yvAwN7gJR=(z7xPNDxNp(-KUtSHrjiYDY8{alsrPpRWZnp2Vmv;Za9uSYNeC6q# zM^?su$jzo?eD(o2{gKqFJtsP-|Hd7DL`mjAtirQk(_=egjQjcW8r1%xsf`2r-i|0?z;d From ca9336c14dcd6021561cde585a513a8ef9e01084 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:39:19 +0100 Subject: [PATCH 58/64] Add dark mode to popup matching app palette via OS preference --- extensions/chrome/src/popup/popup.css | 52 +++++++++++++++++++-------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/extensions/chrome/src/popup/popup.css b/extensions/chrome/src/popup/popup.css index 912a859..73e4068 100644 --- a/extensions/chrome/src/popup/popup.css +++ b/extensions/chrome/src/popup/popup.css @@ -8,18 +8,36 @@ } :root { - --bg: #ffffff; - --bg-secondary: #f9fafb; - --text: #111827; - --text-muted: #6b7280; - --border: #e5e7eb; - --primary: #6366f1; - --primary-hover: #4f46e5; + --bg: #eef0f6; + --bg-secondary: #ffffff; + --text: #0f1221; + --text-muted: #5c6080; + --border: #d8dae8; + --primary: #0e7490; + --primary-hover: #0c5e73; --success: #10b981; --error: #ef4444; + --duplicate: #92400e; + --duplicate-bg: #fffbeb; --radius: 8px; } +@media (prefers-color-scheme: dark) { + :root { + --bg: #0a0a0a; + --bg-secondary: #111111; + --text: #ebebeb; + --text-muted: #787878; + --border: #222222; + --primary: #22d3ee; + --primary-hover: #06b6d4; + --success: #10b981; + --error: #f87171; + --duplicate: #fbbf24; + --duplicate-bg: #1a1500; + } +} + body { width: 360px; font-family: @@ -120,7 +138,13 @@ body { .btn-primary { background: var(--primary); - color: white; + color: #ffffff; +} + +@media (prefers-color-scheme: dark) { + .btn-primary { + color: #0c1a1f; + } } .btn-primary:hover:not(:disabled) { @@ -168,13 +192,13 @@ body { } .status-success { - background: #ecfdf5; - color: #065f46; + background: color-mix(in srgb, var(--success) 15%, var(--bg)); + color: var(--success); } .status-error { - background: #fef2f2; - color: #991b1b; + background: color-mix(in srgb, var(--error) 15%, var(--bg)); + color: var(--error); } .status-loading { @@ -182,8 +206,8 @@ body { } .status-duplicate { - background: #fffbeb; - color: #92400e; + background: var(--duplicate-bg); + color: var(--duplicate); } .job-preview .salary { From 7d3ccd2104cd596dcef4478652ed87f9f5b8ffb6 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:43:14 +0100 Subject: [PATCH 59/64] Add checkmark logo to popup header matching app branding --- extensions/chrome/src/popup/popup.css | 8 ++++++++ extensions/chrome/src/popup/popup.tsx | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/extensions/chrome/src/popup/popup.css b/extensions/chrome/src/popup/popup.css index 73e4068..a18aa42 100644 --- a/extensions/chrome/src/popup/popup.css +++ b/extensions/chrome/src/popup/popup.css @@ -15,6 +15,7 @@ --border: #d8dae8; --primary: #0e7490; --primary-hover: #0c5e73; + --primary-fg: #ffffff; --success: #10b981; --error: #ef4444; --duplicate: #92400e; @@ -31,6 +32,7 @@ --border: #222222; --primary: #22d3ee; --primary-hover: #06b6d4; + --primary-fg: #0c1a1f; --success: #10b981; --error: #f87171; --duplicate: #fbbf24; @@ -65,6 +67,12 @@ body { border-bottom: 1px solid var(--border); } +.popup-logo { + width: 22px; + height: 22px; + flex-shrink: 0; +} + .popup-header h1 { font-size: 16px; font-weight: 600; diff --git a/extensions/chrome/src/popup/popup.tsx b/extensions/chrome/src/popup/popup.tsx index 131f300..1e91229 100644 --- a/extensions/chrome/src/popup/popup.tsx +++ b/extensions/chrome/src/popup/popup.tsx @@ -425,6 +425,17 @@ function Popup() { function Header() { return (
+

Shortlist

); From 91beb7a51c40a7ceafa02c10a8855fcd85a164af Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:44:37 +0100 Subject: [PATCH 60/64] Reduce logo size and nudge down to align with header text --- extensions/chrome/src/popup/popup.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/chrome/src/popup/popup.css b/extensions/chrome/src/popup/popup.css index a18aa42..8c18c2b 100644 --- a/extensions/chrome/src/popup/popup.css +++ b/extensions/chrome/src/popup/popup.css @@ -68,9 +68,11 @@ body { } .popup-logo { - width: 22px; - height: 22px; + width: 20px; + height: 20px; flex-shrink: 0; + position: relative; + bottom: 1px; } .popup-header h1 { From a20722ca97d484821dbec65a7917ab48b5bfc190 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:44:40 +0100 Subject: [PATCH 61/64] Prepare extension v1.0.0 for Chrome Web Store submission --- extensions/chrome/src/lib/api.ts | 13 +------------ extensions/chrome/src/manifest.json | 6 ++---- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/extensions/chrome/src/lib/api.ts b/extensions/chrome/src/lib/api.ts index 0bbe181..40583f8 100644 --- a/extensions/chrome/src/lib/api.ts +++ b/extensions/chrome/src/lib/api.ts @@ -1,7 +1,6 @@ import type { ExtractResult, ImportPayload } from "../types"; const PROD_URL = "https://shortlist.johnmoorman.com"; -const DEV_URL = "http://localhost:3000"; interface ApiResponse { ok: boolean; @@ -10,22 +9,12 @@ interface ApiResponse { error: string | null; } -/** Resolve the API base URL. Checks storage override first, then auto-detects localhost. */ +/** Resolve the API base URL. Defaults to production; override via chrome.storage.local for dev. */ export async function getBaseUrl(): Promise { try { const result = await chrome.storage.local.get("apiBaseUrl"); if (result.apiBaseUrl) return result.apiBaseUrl as string; } catch {} - - // Auto-detect: if localhost is reachable, use it (dev mode) - try { - const res = await fetch(`${DEV_URL}/api/extension/status`, { - credentials: "include", - signal: AbortSignal.timeout(1000), - }); - if (res.ok || res.status === 401) return DEV_URL; - } catch {} - return PROD_URL; } diff --git a/extensions/chrome/src/manifest.json b/extensions/chrome/src/manifest.json index 1581a4c..0d8acad 100644 --- a/extensions/chrome/src/manifest.json +++ b/extensions/chrome/src/manifest.json @@ -1,13 +1,11 @@ { "manifest_version": 3, "name": "Shortlist — Job Importer", - "version": "0.1.0", + "version": "1.0.0", "description": "Import job listings into your Shortlist feed with one click", "permissions": ["activeTab", "storage", "scripting"], "host_permissions": [ - "https://shortlist.johnmoorman.com/*", - "http://localhost:3000/*", - "http://localhost:3001/*" + "https://shortlist.johnmoorman.com/*" ], "content_scripts": [ { From 520722b354a1c47e9fb1345e3e55ec2638ff4bd9 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:19:36 +0100 Subject: [PATCH 62/64] Remove content script injection in favor of scripting API only --- extensions/chrome/src/manifest.json | 7 --- extensions/chrome/src/popup/popup.tsx | 61 ++++++++++++--------------- 2 files changed, 27 insertions(+), 41 deletions(-) diff --git a/extensions/chrome/src/manifest.json b/extensions/chrome/src/manifest.json index 0d8acad..d0155bf 100644 --- a/extensions/chrome/src/manifest.json +++ b/extensions/chrome/src/manifest.json @@ -7,13 +7,6 @@ "host_permissions": [ "https://shortlist.johnmoorman.com/*" ], - "content_scripts": [ - { - "matches": [""], - "js": ["src/content/content.ts"], - "run_at": "document_idle" - } - ], "action": { "default_popup": "src/popup/popup.html", "default_icon": { diff --git a/extensions/chrome/src/popup/popup.tsx b/extensions/chrome/src/popup/popup.tsx index 1e91229..1910ed3 100644 --- a/extensions/chrome/src/popup/popup.tsx +++ b/extensions/chrome/src/popup/popup.tsx @@ -108,41 +108,34 @@ function Popup() { let content: PageContent | null = null; try { - const result = await chrome.tabs.sendMessage(tab.id, { - type: "GET_PAGE_CONTENT", + const [injected] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => { + const el = + document.querySelector("[role='main'] article") ?? + document.querySelector("[role='main']") ?? + document.querySelector("main") ?? + document.querySelector("article") ?? + document.body; + const clone = el.cloneNode(true) as HTMLElement; + clone + .querySelectorAll( + "script, style, nav, footer, header, iframe, noscript, " + + "svg, img, video, audio, canvas, " + + "[role='navigation'], [role='banner'], [role='contentinfo'], " + + "[aria-hidden='true']", + ) + .forEach((n) => n.remove()); + return { + url: window.location.href, + html: clone.innerHTML.slice(0, 50000), + title: document.title, + }; + }, }); - if (result?.content) content = result.content; - } catch {} - - if (!content) { - try { - const [injected] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: () => { - const el = - document.querySelector("[role='main'] article") ?? - document.querySelector("[role='main']") ?? - document.querySelector("main") ?? - document.querySelector("article") ?? - document.body; - const clone = el.cloneNode(true) as HTMLElement; - clone - .querySelectorAll( - "script, style, nav, footer, header, iframe, noscript, " + - "svg, img, video, audio, canvas, " + - "[role='navigation'], [role='banner'], [role='contentinfo'], " + - "[aria-hidden='true']", - ) - .forEach((n) => n.remove()); - return { - url: window.location.href, - html: clone.innerHTML.slice(0, 50000), - title: document.title, - }; - }, - }); - if (injected?.result) content = injected.result; - } catch {} + if (injected?.result) content = injected.result; + } catch { + // Page is restricted (chrome://, about:, etc.) } if (!content || content.html.length < 50) { From cd3d59dc5e0a4618a17d25f7655d76a7377ccac1 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:26:22 +0100 Subject: [PATCH 63/64] Skip onboarding redirect for Chrome extension API requests --- src/middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/middleware.ts b/src/middleware.ts index f6cf1c5..39d0cfd 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -107,7 +107,7 @@ export default clerkMiddleware(async (auth, req) => { console.log(`[middleware] userId: ${userId}, onboarded: ${!!onboarded}, isOnboardingRoute: ${isOnboardingRoute}, path: ${req.nextUrl.pathname}`); } - if (!onboarded && !isOnboardingRoute) { + if (!onboarded && !isOnboardingRoute && !corsOrigin) { // Cookie is missing — check DB to avoid trapping users on new devices. // Prisma cannot run in Edge Runtime, so we call a thin internal API route. try { From 67be5af34f729d0de496cc5e889fa63f81dc94d8 Mon Sep 17 00:00:00 2001 From: John Moorman <45500240+mojoro@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:39:44 +0100 Subject: [PATCH 64/64] Add Chrome extension privacy policy as a static page --- extensions/chrome/package-lock.json | 596 ++++++++++++++++++++++++++++ public/privacy-extension.html | 63 +++ 2 files changed, 659 insertions(+) create mode 100644 public/privacy-extension.html diff --git a/extensions/chrome/package-lock.json b/extensions/chrome/package-lock.json index cac7e40..9696e0e 100644 --- a/extensions/chrome/package-lock.json +++ b/extensions/chrome/package-lock.json @@ -16,10 +16,300 @@ "@types/chrome": "^0.0.287", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", "typescript": "^5.7.0", "vite": "^6.0.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@crxjs/vite-plugin": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.4.0.tgz", @@ -490,6 +780,38 @@ "node": ">=18" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -497,6 +819,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -535,6 +868,13 @@ "node": ">= 8" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/pluginutils": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", @@ -899,6 +1239,51 @@ "win32" ] }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/chrome": { "version": "0.0.287", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", @@ -961,6 +1346,37 @@ "@types/react": "^19.2.0" } }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@webcomponents/custom-elements": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz", @@ -994,6 +1410,19 @@ "node": ">=0.4.0" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1014,6 +1443,61 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -1135,6 +1619,13 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.322", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.322.tgz", + "integrity": "sha512-vFU34OcrvMcH66T+dYC3G4nURmgfDVewMIu6Q2urXpumAPSMmzvcn04KVVV8Opikq8Vs5nUbO/8laNhNRqSzYw==", + "dev": true, + "license": "ISC" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -1197,6 +1688,16 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -1274,6 +1775,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1337,6 +1848,13 @@ "node": ">=0.12.0" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1350,6 +1868,19 @@ "node": ">=6" } }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -1363,6 +1894,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1434,6 +1975,13 @@ "he": "1.2.0" } }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -1622,6 +2170,16 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1724,6 +2282,37 @@ "node": ">= 10.0.0" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -1874,6 +2463,13 @@ "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" } } } diff --git a/public/privacy-extension.html b/public/privacy-extension.html new file mode 100644 index 0000000..fac0045 --- /dev/null +++ b/public/privacy-extension.html @@ -0,0 +1,63 @@ + + + + + + Shortlist Job Importer — Privacy Policy + + + +

Shortlist Job Importer — Privacy Policy

+

Last updated: March 25, 2026

+ +

What the extension does

+

The Shortlist Job Importer Chrome extension helps you import job listings from any website into your Shortlist account. When you click the extension icon on a job posting page, it reads the page content, extracts job details, and saves them to your Shortlist feed.

+ +

Data we collect

+
    +
  • Page content. When you click the extension icon, it reads the HTML content of the current page to extract job listing details (title, company, location, description, etc.). This content is sent to the Shortlist server for AI-powered extraction and stored alongside the imported job listing in your account so that extraction can be re-processed if our parsing improves.
  • +
  • Import history. The last 5 imported jobs are stored locally in your browser's extension storage for display in the popup. This data never leaves your device.
  • +
+ +

Data we do NOT collect

+
    +
  • We do not collect browsing history or track which pages you visit.
  • +
  • We do not read page content unless you explicitly click the extension icon.
  • +
  • We do not store or transmit any credentials. Authentication uses your existing Shortlist session cookie.
  • +
  • We do not sell or share your data for advertising, profiling, or marketing purposes.
  • +
+ +

How we use your data

+

Page content is sent to the Shortlist server to extract structured job listing fields (title, company, description, etc.). To perform this extraction, a cleaned and truncated version of the page content is sent to a third-party AI service (currently OpenRouter/Anthropic) for processing. The AI service receives only the text needed for extraction — no account credentials, personal information, or browsing history are included in these requests.

+

Both the extracted data and the original page content are saved to your Shortlist account. The original content is retained so that job details can be re-extracted if our parsing logic improves. Your data is stored securely and is only accessible to you through your Shortlist account.

+ +

Third-party services

+

Shortlist uses the following third-party services to process your data:

+
+ +

Authentication

+

The extension authenticates with the Shortlist API using your existing browser session cookie. No passwords, API keys, or tokens are stored in the extension.

+ +

Permissions

+
    +
  • activeTab: Read the current page when you click the extension icon.
  • +
  • scripting: Inject a content script to extract page content.
  • +
  • storage: Store your recent import history and preferences locally.
  • +
  • Host permission (shortlist.johnmoorman.com): Communicate with the Shortlist API.
  • +
+ +

Contact

+

For questions about this privacy policy, contact: john@johnmoorman.com

+ +