The problem
Every freelancer has the same question at some point: what skills are actually in demand right now? Job board search filters answer "does this skill exist as a tag," not "how often does it show up relative to everything else, and is that changing." Getting a real answer means reading a lot of job postings and keeping a mental tally — which nobody does consistently, because it's tedious and the data evaporates the moment you close the tab.
I built the Upwork Skill Analyzer to solve this for myself: a Chrome extension that turns ordinary browsing into a standing dataset, with a dashboard that updates itself as the data grows.
Passive collection, not a separate scraping step
The core design decision was that data collection shouldn't be its own chore. The extension runs as a Manifest V3 content script injected on Upwork job pages and feed pages, and it reads the skill tags that are already rendered in the DOM whenever you happen to view a job — no separate "run the scraper" step, no extra tab, no workflow change. Browse Upwork the way you normally would; the extension quietly extracts and stores skills in the background.
"content_scripts": [
{
"matches": [
"https://www.upwork.com/nx/find-work/*",
"https://www.upwork.com/nx/jobs/*",
"https://www.upwork.com/jobs/*"
],
"js": [
"content/extractor.js",
"content/feed-observer.js",
"content/scroll.js",
"content/content-script.js"
],
"run_at": "document_idle"
}
]
Splitting the content script into small single-purpose files (extractor.js for DOM parsing, feed-observer.js for detecting new job cards as the feed loads, scroll.js for scroll utilities) kept each piece testable in isolation and meant the extraction logic didn't have to know anything about page navigation timing.
A selector strategy that expects the site to change
Upwork's front end uses generated, versioned class names (air3-token, air3-card-section) that shift over time as they ship redesigns. A single hardcoded selector is a one-release liability, so extraction uses a prioritized list with graceful fallback instead of a single query:
var SKILL_SELECTORS = [
'a[data-test="attr-item"].air3-token',
'[data-test="token-container"] a.air3-token',
'a.air3-token[data-test="attr-item"]',
'[data-test="attr-item"]',
'.air3-token-wrap a.air3-token',
].join(", ");
var FALLBACK_SELECTORS =
'.air3-token, .air3-badge, [data-test="token-container"] a';
window.__upworkAnalyzer.extractSkills = function (container) {
var root = container || document;
var elements = root.querySelectorAll(SKILL_SELECTORS);
if (elements.length === 0) {
elements = root.querySelectorAll(FALLBACK_SELECTORS);
}
return Array.from(elements)
.map(function (el) { return el.textContent.trim(); })
.filter(Boolean);
};
The data-test attributes are prioritized over class names because they're QA hooks — less likely to be touched by a purely visual redesign. When Upwork ships a CSS refactor, the fallback list is what keeps the extension collecting data instead of silently returning nothing.
Service worker as message broker, not a black box
Manifest V3 killed persistent background pages, which means the service worker can be killed and restarted between events at any time. State can't live in a module-level variable — everything durable goes through chrome.storage.local, and the service worker's job is to be a stateless router between content scripts, the popup, and the dashboard:
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
handleMessage(msg, sender).then(sendResponse);
return true; // keep the channel open for the async response
});
async function handleMessage(msg, sender) {
switch (msg.type) {
case "SKILLS_EXTRACTED": {
const stored = await storeSkills(msg.jobId, msg.skills);
if (stored) await runAnalysis();
await updateBadge();
return { ok: true, skillCount: msg.skills.length };
}
// ...
}
}
storeSkills dedupes by job ID before appending, so revisiting the same posting twice doesn't inflate the counts — important for a tool whose entire value proposition is that the numbers are trustworthy.
Analysis is cheap and cached, not recomputed on render
Rather than have the dashboard recompute frequency counts from raw data on every open (fine at first, wasteful as the dataset grows into the thousands), analysis runs once whenever new skills are stored and the result is cached as its own storage key:
export function analyzeSkills(rawSkills) {
const counts = {};
for (const skill of rawSkills) {
const key = skill.toLowerCase().trim().replace(/,$/, "");
if (key) counts[key] = (counts[key] || 0) + 1;
}
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
return {
generatedAt: new Date().toISOString(),
totalEntries: rawSkills.length,
uniqueSkills: sorted.length,
skills: sorted.map(([name, count]) => ({
name, count, percentage: parseFloat(((count / rawSkills.length) * 100).toFixed(2)),
})),
};
}
The dashboard page never talks to the service worker to ask "what's the current state" — it just listens for chrome.storage.onChanged and re-renders when skills_analysis updates:
chrome.storage.onChanged.addListener((changes, area) => {
if (area === "local" && changes.skills_analysis) {
currentData = changes.skills_analysis.newValue;
if (currentData) { buildLayout(); render(); }
}
});
That gives a genuinely live dashboard — open it in one tab, keep browsing job postings in another, and the chart and table update on their own — without polling, and without the dashboard needing to know anything about how the data got there.
Data portability was a first-class requirement
Skill data an extension collects but locks inside chrome.storage.local isn't very useful. Export produces a JSON snapshot (raw skills, dedup map, computed analysis); import accepts either that JSON or a plain newline-delimited skill.txt, so data collected before the dashboard existed — or exported from one machine — can be merged back in:
| Format | Use case |
|---|---|
| JSON export | Full fidelity backup/restore, cross-machine migration |
skill.txt import | Migrating older/manual skill logs into the structured store |
What this demonstrates
This project is a small surface with a lot of the same constraints as much larger systems: state that can vanish at any moment (MV3 service worker lifecycle), an upstream data source that changes shape without notice (a third-party site's DOM), and a UI that needs to reflect state changes without the user manually refreshing. The extension handles all three with patterns that scale down cleanly rather than clever one-offs — a stateless message router backed by durable storage, a selector strategy with explicit fallback tiers, and a reactive UI driven by a storage-change listener instead of polling. That's the same shape of thinking I bring to backend services and dashboards that are orders of magnitude bigger.