Over two weeks I passed on five Upwork jobs that looked fine in the search results. Decent client spend, plenty of reviews, not many proposals, posted recently. Every reason to skip them was on the job page itself. One client had spent $27K and had over 300 reviews, and their average hourly rate paid was $15.34. Their recent history showed $5 and $10 fixed-price contracts, again and again.
So I built jobsignal, a small Chrome extension that reads the Upwork job page you open and labels those things. It doesn't give the job a score. It quotes the sentence from the page, so the call stays yours.
The code turned out to be the easy part. The Web Store forms were not. More on that below, and I'd like to hear how you handled them.
What it flags
Thirteen signals. Five come from the job cards in search: payment not verified, number of proposals, when it was posted, the budget, and 30+ hrs/week. Eight only show up on the job page: a low average hourly rate paid, repeated tiny fixed-price contracts, a request for proof of past work, a required video call or time zone overlap, how many screening questions there are, a high Connects cost, a suspicious scope, and a low hire rate.
Four of the cutoffs can be changed in settings, like the average-rate one ($20/hr by default). The hire-rate (30%) and Connects (20) cutoffs are fixed in code for now.
One permission
The manifest asks for storage and nothing else. No host permissions, no background service worker, no tabs, no scripting. Content scripts run on three Upwork paths and nowhere else.
I didn't want "sends nothing" to be a line in a README, so the build script checks it. If a rule breaks, there's no zip:
if (JSON.stringify(m.permissions) !== '["storage"]') fail('permissions must be exactly ["storage"]');
if (m.host_permissions || m.background || m.optional_permissions) fail('no host_permissions/background/optional_permissions');
const bad = /\b(fetch|XMLHttpRequest|sendBeacon|WebSocket|importScripts|eval)\s*\(|\bnew Function\b|https?:\/\/(?!www\.upwork\.com|danorie\.com)[^'"\s)]+/;
Every file in src/ is run against that pattern, so a URL outside the allow list fails the build as well.
What it keeps lives in chrome.storage.local. That's your settings and a daily counter, plus the jobs you've opened (with a note if you pressed Skip on one). Those are removed 30 days after you last opened them. Opening the same job again doesn't count twice. There's no service worker, so the 30-day cleanup only happens the next time the extension runs. If you never open Upwork again, that list stays put until you do.
Parsing a page I don't control
The tests run against eight pages I opened and saved myself, most of them job pages. The rule I kept relearning: don't widen a regex because a phrasing might exist somewhere. Add it when a saved page actually shows it.
The bug I didn't see coming was sentence cutting. A quote like "overlap with U.S. Central Time" kept getting chopped at "U.S." Now there's a short list of abbreviations (e.g., i.e., approx.) plus a rule for dotted initials like U.S., so none of them end a sentence.
There are 55 tests. Besides the saved pages, they cover card parsing and edge values, plus small DOM changes and broken storage entries.
The hard part wasn't the code
The privacy form. My first instinct was "collects no data." Then I read the Chrome Web Store FAQ: data you only handle on the user's device still has to be disclosed. So the honest answer is two ticks: web history (the jobs you opened, kept locally for 30 days) and website content (the page text it reads). The first line of the privacy policy went from "does not collect" to "does not transmit, sell, or share any data; everything it handles stays on your device." The public listing doesn't say that yet. As I write this, its privacy section still says the extension won't collect or use your data. Fixing that is a dashboard change, and it isn't live yet.
The publish limit. When I hit submit, the dashboard said I already had the maximum of four published extensions. I asked for a higher limit and got turned down right away: my published extensions needed more engagement first. I unpublished one of my older ones to make room.
Review. I submitted in the morning and it was live that evening. One line in the reviewer notes mattered: if the reviewer isn't logged in to Upwork, client details are hidden and no labels show up.
After launch. Once it was live, I compared the public listing with what I'd submitted, field by field. The summary at the top isn't the short description field from the dashboard. It comes from description in the manifest, and mine still had older wording that didn't match the privacy policy. The fix is a new version, which is now live.
What I can't promise
It's a set of regexes looking for known phrasings. If Upwork rewords a field, it will miss it until I update it.
It isn't affiliated with Upwork, and you should know what Upwork's help center says: "If an extension reads or changes Upwork pages or automates actions, it could trigger a warning, restriction, or block." jobsignal sends no requests and takes no actions, but it does read the page and add labels to it. Decide with that in mind.
You also need to be logged in. Logged out, Upwork hides the client details, so there's nothing to flag.
A question
If you've shipped an extension that never touches the network, what did you tick on the privacy form? I went back and forth on it, and I'm curious whether you drew the line in the same place.
jobsignal on the Chrome Web Store: https://chromewebstore.google.com/detail/cnbafbiajaacmpkedboekbfkkoiicmjg
Sources
- Upwork Help, "Use bots and other automation properly": https://support.upwork.com/hc/en-us/articles/43342677368467-Use-bots-and-other-automation-properly
This post was drafted with AI help. The numbers and facts come from my own logs and repo.

Top comments (2)
the build check is the part i'd steal. "sends nothing" as a readme line rots; as a failing build it stays true. and the faq is annoyingly right: handling is handling even when nothing leaves the device.
That's the reason it sits in the pack step: no zip unless it passes. The same script also fails if the manifest's content-script matches drift from the three Upwork paths, so a wider match can't ship quietly either.
It's still a regex, though. It scans every file in src/ for seven call patterns (fetch, XMLHttpRequest, sendBeacon, WebSocket, importScripts, eval, new Function) and for any URL outside two allowed hosts, but a URL assembled from pieces at runtime would get past it. Fine here. The check exists to catch my own slips, not an attacker.
I saw you wrote a cookie policy for a product that sets no cookies, which sounds like the same problem from the other side. Where did you draw the line: what the product handles, or only what it stores?