The feature is one keystroke
You’re on a LinkedIn job page. You press Alt+Shift+J. A panel slides in, already showing the job title and company it scraped from the page, and you click “Add to board.” Two seconds later that placeholder card quietly becomes a fully structured job — salary, location, responsibilities, required skills — without the page reloading.
That’s the whole feature. Everything technically interesting about it is in service of making those two seconds feel instant and never lie to you. This post is the pipeline behind the keystroke.
The extension is a Manifest V3 build under packages/extension/, written with WXT (Vite under the hood) and React 19. The keyboard shortcut is a real manifest command:
// wxt.config.ts
commands: {
'toggle-overlay': {
suggested_key: { default: 'Alt+Shift+J' },
description: 'Toggle the RoleReady overlay',
},
},
permissions: ['activeTab', 'storage', 'scripting', 'tabs'],
activeTab instead of broad host permissions matters: the extension only reads the page when you invoke it, which is both a smaller attack surface and an easier Chrome Web Store review.
Extraction is a waterfall, not a scraper
The naive version of this is a pile of CSS selectors per site that rots the moment LinkedIn ships a redesign. I do use site-specific selectors, but they’re the last resort, not the first. Capture is a waterfall that prefers structured data:
- JSON-LD. Most real job boards embed a
<script type="application/ld+json">with aJobPostingschema. That’s machine-authored, stable, and standardized. I walk every LD+JSON block, recursing into@grapharrays, and pull the firstJobPostingnode. - Site parser. If there’s no usable JSON-LD, a per-site parser (
linkedin,indeed,glassdoor,ziprecruiter, …) reads known selectors with fallbacks down toog:titleand the page title. - Fallback parser. For anything unrecognized — a random Greenhouse or Lever board — a generic parser reads
og:title,og:site_name, and derives a company from the hostname.
The page detection that decides whether to even mount uses the same hierarchy:
function isLikelyJobPage(doc: Document, url: string): boolean {
if (hasJsonLdJobPosting(doc)) return true; // structured data wins
if (matchesKnownJobBoard(url)) return true; // linkedin/indeed/glassdoor…
return matchesAtsPlatform(url); // greenhouse/lever/workday/ashby…
}
The LinkedIn parser is the most defensive, because LinkedIn is the most hostile:
extractTitle(doc: Document): string | null {
const domTitle = readFirstText(doc, [
'h1.top-card-layout__title',
'h1.jobs-unified-top-card__job-title',
"h1[class*='job-title']",
'h1',
]);
if (domTitle && !looksGenericLinkedInText(domTitle)) return sanitizeTitle(domTitle);
const metaTitle = readMetaContent(doc, 'meta[property="og:title"]');
if (metaTitle && !looksGenericLinkedInText(metaTitle)) return cleanLinkedInTitle(metaTitle);
return null;
}
Note the looksGenericLinkedInText guard. Half the battle on LinkedIn is recognizing that the thing you scraped is actually their “Sign in to view” boilerplate, not a job title, and falling through to the next source instead of confidently saving garbage.
The client doesn’t trust its own parse
Here’s the design decision I’d repeat. The client-side parser is only for the instant preview. The real extraction happens on the server, against an LLM, on the full page content. So the extension grabs the whole DOM, converts it to markdown with Turndown, captures the JSON-LD string, and ships all of it:
const response = await sendMessage({
type: 'CAPTURE_JOB',
capture: {
markdown: capture.markdown, // full page, HTML → markdown
jsonLd: capture.jsonLd, // stringified JobPosting, if present
url: capture.url,
sourceType: capture.sourceType,
},
boardId: selectedBoardIdRef.current,
});
There are size guards on both ends — 10MB of HTML, 3MB of markdown — and a sparse-page rule: if both the markdown and the JSON-LD come back nearly empty, the capture appends the source URL so the backend always has something to extract from rather than failing on a blank.
This split — fast local preview, authoritative server extraction — is why the UI can be both instant and correct. The card you see immediately is a best-effort guess; the card you have a moment later is the real thing. The UI literally walks the states saving → saved_processing → saved_complete, polling GET /api/jobs/{id}/extraction-status every couple seconds until the server says it’s done, then swapping the card in place. No reload, and no pretending it’s finished when it isn’t.
Auth is just cookies, on purpose
The extension has no token store, no OAuth dance, no login form of its own. Every request it makes to the backend is:
const response = await fetch(`${baseUrl}${pathname}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init.headers },
credentials: 'include', // send the app's session cookie
});
Because you’re already logged into the web app in the same browser profile, credentials: 'include' carries the Better Auth session cookie along with the request. The backend validates it the same way it validates any web request:
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user?.id) return { userId: session.user.id, authType: 'session' };
return null;
The cost of this simplicity is CORS. The capture endpoints have to explicitly echo the extension’s origin and set Access-Control-Allow-Credentials: true, because credentialed cross-origin requests won’t accept a wildcard origin. In production the allowed origins are pinned to the real app domain plus known extension IDs; in dev, unpacked extension IDs change on every load, so dev is more permissive. That asymmetry is worth being deliberate about — it’s exactly the kind of thing that’s invisible until a store-published build silently can’t authenticate.
The two genuinely hard problems
Shadow DOM. The overlay is injected into a shadow root so the host page’s CSS can’t bleed into it and vice versa. The price: Tailwind doesn’t work across a shadow boundary, so the entire UI is hand-written CSS custom properties scoped to :host. And node.contains() lies across the boundary — to detect a click outside the panel I had to switch to event.composedPath(), which is the only thing that sees through the shadow root.
Cross-frame autofill. Filling an application form on Greenhouse or Workday means touching DOM inside iframes the top frame can’t reach. The solution is a frame agent that runs in every frame, with the background service worker as a message broker. Each frame scans itself, reports a serialized summary keyed by a frame token, and the background fans a fill request out and waits for every expected token to report back before finalizing. Element references never cross the boundary — only serializable field descriptors do, because you can’t postMessage a DOM node.
What I’d tell anyone building one
Prefer structured data to selectors, and treat your own client-side parse as a guess you’ll verify server-side. The combination is what lets the extension feel instant without ever being wrong for long. Selectors are a maintenance treadmill; JSON-LD is a contract the site already agreed to. Lean on the contract, and keep the scraper as the fallback it deserves to be.
