DEV Community

Cover image for A Job-Post Scorer That Never Touches Upwork: Pass, Weak or Skip With Reasons You Can Read, in Plain JavaScript
Abid Ali
Abid Ali

Posted on

A Job-Post Scorer That Never Touches Upwork: Pass, Weak or Skip With Reasons You Can Read, in Plain JavaScript

Every few weeks someone in a freelancing group asks which Upwork auto-bidding tool is "safe". The honest answer is that the useful half of those tools doesn't need to be connected to anything, and the risky half is exactly the part people are paying for: the bit that clicks Apply and spends Connects while you're asleep.

On Digital Dialogue I wrote a longer piece on what an Upwork auto bidding bot should and shouldn't automate. The short version: Upwork's help page treats scripts, extensions and services that send requests, collect data or act faster than a person as bots, and auto-submit is the step that puts an account at risk. Scoring a job post and drafting notes, done away from the site, is a different thing.

So this post builds the safe half, as a developer would: a small, tested JavaScript module that scores job posts you copied by hand into a JSON file. It never opens Upwork, never needs a password, and never sends anything. Its output isn't a mysterious number from 0 to 100. It's one of three words, pass, weak or skip, with every reason written out, so you can disagree with it.

It's plain ES modules and Node's built-in test runner. No dependencies.

What the scorer is (and isn't)

The rules come straight from the checklist a careful freelancer already runs in their head:

  • Client signals. Is the payment method verified? Has this client hired before? What's their hire rate?
  • Skills. Do the skills in the post match work you've actually delivered?
  • Money. After the platform fee and the Connects for this one bid, what does the job leave per hour, and is that above your floor?
  • Competition. How many proposals are already in?
  • Phrases. Does the post ask you to move to WhatsApp, or do an unpaid test?

What it isn't: a feed reader, a scraper, a browser extension or a "watcher". You read the post on Upwork like a person, type the handful of fields into a file, and run a script on your own laptop. That's the whole interface, and it's deliberate.

The input: a job you typed yourself

One job is a plain object. Money is in integer cents, because a fee calculator that works in floating point eventually tells you something is worth $8.359999.

{
  "title": "Landing page in Next.js",
  "type": "fixed",
  "budgetCents": 15000,
  "estimatedHours": 16,
  "paymentVerified": true,
  "hires": 2,
  "hireRatePct": 50,
  "proposals": "20 to 50",
  "connects": 8,
  "skills": ["Next.js", "Tailwind CSS"],
  "description": "Simple landing page. Quick and easy for an expert."
}
Enter fullscreen mode Exit fullscreen mode

Two fields are yours rather than the client's: estimatedHours (your estimate of the work) and the settings you score against. proposals is kept as the band Upwork shows ("Less than 5", "5 to 10", "50+"), and the code reads the lower bound.

The settings: your floor, your fee, your Connect price

export const DEFAULTS = {
  mySkills: [],
  floorHourlyCents: 1500,     // your own floor, e.g. $15.00/h (illustrative)
  feeBps: 1000,               // freelancer fee in basis points; 1000 = 10% (example, check your contract)
  connectPriceCents: 15,      // price per Connect at checkout (example; use the price you actually pay)
  maxConnects: 20,
  maxProposals: 20,
  minHireRatePct: 40,
  skipPhrases: ['whatsapp', 'telegram', 'outside upwork', 'off platform', 'pay after delivery', 'skype me'],
  weakPhrases: ['unpaid test', 'free sample', 'test task', 'long term for low', 'quick and easy'],
};
Enter fullscreen mode Exit fullscreen mode

Every number here is an example, and I've labelled them that way in the code on purpose. The freelancer fee isn't one fixed percentage for everyone, and the price of a Connect is whatever checkout shows you. Put your own figures in a settings.json; the defaults only exist so the tests have something to run against.

The percentage is stored as basis points (1% = 100 bps). That keeps the fee an integer too, and 10000 is the only divisor you'll ever need.

The money rule: take-home per hour, after the fee and this bid

This is the rule that does the most work, so it gets the most care. Two small helpers first:

export function takeHomeCents(grossCents, feeBps) {
  // fee rounded half-up to the cent; take-home never goes negative
  const fee = Math.floor((grossCents * feeBps + 5000) / 10000);
  return Math.max(0, grossCents - fee);
}

export function connectsCostCents(connects, priceCents) {
  return Math.max(0, Math.round(connects)) * priceCents;
}
Enter fullscreen mode Exit fullscreen mode

(x + 5000) / 10000 floored is half-up rounding in integer space. takeHomeCents(1005, 1000) gives a fee of 100.5 cents, rounded to 101, and a take-home of 904. Whether your platform rounds the same way is a question for its payment history, not for this post. The point is that the rounding is written down in one place where you can change it.

The Connects cost is counted against the job because the bid is a real cost of this job. DD's guide uses an example of $0.15 per Connect, where a 16-Connect proposal costs $2.40, and that exact figure is one of the tests below.

For a fixed-price job, the rule turns the budget into a rough hourly figure:

const net = takeHomeCents(job.budgetCents, cfg.feeBps) - bidCost;
const perHour = Math.floor(net / job.estimatedHours);
if (perHour < cfg.floorHourlyCents)
  add('rate', 'skip', `${usd(job.budgetCents)} over ${job.estimatedHours} h leaves about ${usd(perHour)}/h after the fee and Connects, under your floor.`);
else if (perHour * 10 < cfg.floorHourlyCents * 11)
  add('rate', 'weak', `About ${usd(perHour)}/h after costs: within 10% of your floor, no room for a revision round.`);
Enter fullscreen mode Exit fullscreen mode

The "within 10%" check is written as perHour * 10 < floor * 11 so it stays in integers. A job that clears your floor by a few cents isn't a good job. It's a job where one extra round of changes puts you underwater, and the reason text says exactly that.

For hourly posts the rule checks the top of the client's range after the fee. If even the best case is under your floor, it's a skip. And if there's no budget at all, or a fixed budget but no hour estimate from you yet, the result is weak with a reason telling you to estimate first. Scoring a job you haven't sized is just guessing with extra steps.

The other rules

Client signals:

if (!job.paymentVerified && !(job.hires > 0)) add('client', 'skip', 'Payment not verified and no past hires.');
else if (!job.paymentVerified) add('client', 'weak', 'Payment not verified (has past hires).');
if (typeof job.hireRatePct === 'number' && job.hireRatePct < cfg.minHireRatePct)
  add('hire-rate', 'weak', `Hire rate ${job.hireRatePct}% is under your ${cfg.minHireRatePct}% line.`);
Enter fullscreen mode Exit fullscreen mode

No verification and no history is a skip; either one alone is a reason to look harder, not a reason to stop. The 40% hire-rate line in the defaults is a placeholder for your own judgement, not a known good threshold.

Skills need normalising, because people type "Next.js", "NextJS" and "next js" for the same thing:

export function normalizeSkill(s) {
  return String(s).toLowerCase().replace(/\.js\b/g, 'js').replace(/[^a-z0-9+#]/g, '');
}
Enter fullscreen mode Exit fullscreen mode

+ and # survive so C++ and C# don't collapse into "c". No overlap at all with your mySkills list is a skip. Fewer than half matching is weak.

Proposal bands keep the lower bound, since that's the number you're sure of:

export function parseProposalBand(band) {
  if (typeof band === 'number') return band;
  const s = String(band ?? '').toLowerCase().trim();
  if (!s) return null;
  if (/less than/.test(s)) return 0;
  const m = s.match(/(\d+)/);
  return m ? Number(m[1]) : null;
}
Enter fullscreen mode Exit fullscreen mode

Phrases are matched with word boundaries, so "telegrams of support" doesn't trip the telegram rule, and runs of whitespace in a phrase match any whitespace in the post:

function hasPhrase(text, phrase) {
  const esc = phrase.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
  return new RegExp(`(^|[^a-z0-9])${esc}([^a-z0-9]|$)`).test(text);
}
Enter fullscreen mode Exit fullscreen mode

Asking to move the conversation off the platform is a skip in the defaults. An unpaid test or "quick and easy" is only weak: sometimes a small paid test is perfectly reasonable, and the phrase is a prompt to read that part of the post again.

Verdicts without a hidden score

Each rule pushes { id, level, text } into a list. The verdict is simply the worst level present:

const LEVELS = { ok: 0, weak: 1, skip: 2 };

const worst = reasons.reduce((m, r) => Math.max(m, LEVELS[r.level]), 0);
const verdict = worst === 2 ? 'skip' : worst === 1 ? 'weak' : 'pass';
return { verdict, reasons, bidCostCents: bidCost };
Enter fullscreen mode Exit fullscreen mode

I tried a weighted score first and threw it away. A number like 63 invites you to argue with the weights instead of with the facts. "Skip: $150 over 16 h leaves about $8.36/h after the fee and Connects" is something you can check in ten seconds, and if your estimate of 16 hours was wrong, you know exactly which input to change.

A tiny CLI

// node triage-cli.mjs jobs.json [settings.json]
import { readFileSync } from 'node:fs';
import { scoreJob, formatResult } from './triage.mjs';
const [jobsPath, settingsPath] = process.argv.slice(2);
const jobs = JSON.parse(readFileSync(jobsPath, 'utf8'));
const settings = settingsPath ? JSON.parse(readFileSync(settingsPath, 'utf8')) : {};
const order = { pass: 0, weak: 1, skip: 2 };
const scored = jobs.map((job) => ({ job, result: scoreJob(job, settings) }))
  .sort((a, b) => order[a.result.verdict] - order[b.result.verdict]);
for (const { job, result } of scored) console.log(formatResult(job, result) + '\n');
Enter fullscreen mode Exit fullscreen mode

With three made-up jobs (illustrative, not real posts) the output looks like this:

PASS  Five-page WordPress site for a dental clinic  (bid $1.80)
      - nothing flagged

SKIP  Landing page in Next.js  (bid $1.20)
      - [skip] $150.00 over 16 h leaves about $8.36/h after the fee and Connects, under your floor.
      - [weak] 20+ proposals already.
      - [weak] Post mentions "quick and easy".

SKIP  Fix React Native build  (bid $1.50)
      - [skip] Payment not verified and no past hires.
      - [skip] Post mentions "whatsapp".
Enter fullscreen mode Exit fullscreen mode

Passes first, then weak, then skip. You read the passes properly and write those proposals by hand. That's the order DD's guide recommends too: score, draft only the passes, then open the job on Upwork, paste your edited text, answer the questions and click Submit yourself.

Tests

The test file uses node:test and node:assert/strict. A few of them:

import test from 'node:test';
import assert from 'node:assert/strict';
import { scoreJob, parseProposalBand, takeHomeCents, connectsCostCents } from './triage.mjs';

const me = { mySkills: ['WordPress', 'PHP', 'Next.js', 'React'], floorHourlyCents: 1500, feeBps: 1000, connectPriceCents: 15 };
const good = { title: 'Clinic site', type: 'fixed', budgetCents: 60000, estimatedHours: 24, paymentVerified: true, hires: 6,
  hireRatePct: 70, proposals: '10 to 15', connects: 12, skills: ['WordPress', 'PHP'], description: 'Five pages.' };

test('a clean post passes with no reasons', () => {
  const r = scoreJob(good, me);
  assert.equal(r.verdict, 'pass');
  assert.deepEqual(r.reasons, []);
  assert.equal(r.bidCostCents, 180);
});

test('take-home uses integer cents and half-up fee rounding', () => {
  assert.equal(takeHomeCents(10000, 1000), 9000);
  assert.equal(takeHomeCents(1005, 1000), 904);   // fee 100.5 -> 101
  assert.equal(connectsCostCents(16, 15), 240);   // the $2.40 example
});

test('close to the floor is weak, not skip', () => {
  // $285 - 10% fee - $1.80 Connects = $254.70 over 16 h = $15.91/h: above a $15 floor, but within 10% of it
  const r = scoreJob({ ...good, budgetCents: 28500, estimatedHours: 16 }, me);
  assert.equal(r.verdict, 'weak');
  assert.equal(r.reasons[0].id, 'rate');
});

test('unverified payment with no hires, or off-platform contact, is a skip', () => {
  assert.equal(scoreJob({ ...good, paymentVerified: false, hires: 0 }, me).verdict, 'skip');
  assert.equal(scoreJob({ ...good, paymentVerified: false, hires: 3 }, me).verdict, 'weak');
  assert.equal(scoreJob({ ...good, description: 'Add me on WhatsApp\nthanks' }, me).verdict, 'skip');
  // word boundaries: "telegrams" is not "telegram"
  assert.equal(scoreJob({ ...good, description: 'telegrams of support' }, me).verdict, 'pass');
});

test('proposal bands keep the lower bound', () => {
  assert.equal(parseProposalBand('Less than 5'), 0);
  assert.equal(parseProposalBand('5 to 10'), 5);
  assert.equal(parseProposalBand('50+'), 50);
});
Enter fullscreen mode Exit fullscreen mode

The full file has 11 tests, covering hourly ranges, partial skill overlap, missing budgets and the formatter as well. Run them with:

node --test
Enter fullscreen mode Exit fullscreen mode

Things I deliberately left out

Fetching jobs. It's tempting to "just" pull the feed. That's the part DD's article and Upwork's own help page are clear about: background polling, timed refreshes and scraping are patterns that get accounts flagged. If you want alerts, use the saved searches Upwork gives you, and type the five or six fields you need. It takes a minute per post, and that minute is also when you actually read it.

Writing the proposal. A scorer can tell you a post is worth your time. It can't tell the client what you did on a similar project, because only you know that. Keep a small bank of real project notes and write the first two lines for the client in front of you.

A single magic threshold. Your floor, your fee and your Connect price change. They live in a settings file, not in the code, and the reasons always print the numbers they used.

Spending decisions across a week. One bid's cost is in the per-hour maths, but whether you can afford twenty bids this week is a budgeting question. The free Upwork Connects calculator on DD works out the dollar cost of a batch of proposals and your cost per reply, interview and contract, which is the right next number to look at once the scorer has trimmed the list.

Wrapping up

The safest automation for a freelancer is the kind that never logs in. A JSON file, a few honest rules, integer cents and a verdict that shows its working: that's enough to stop you spending Connects on posts you'd have skipped if you'd done the maths. Then you open the passes, read them properly, and send them yourself.


I'm Abid Ali, a freelance full-stack developer in Karachi working with TypeScript, Next.js, React and Node.js. I write practical freelancing guides and build small free calculators on Digital Dialogue.

Top comments (0)