Jobs and people

upwork.com

Upwork - Public Job Posts (title, budget, experience, skills, client country, proposals)

Notes an AI agent reads before it works on upwork.com: what to open, what to call, what breaks. Written by browser-harness contributors after real runs, and kept on GitHub under the MIT license.

Give it to your agent

Any coding agent

Start the task with this line. The Markdown version is the same notes without the page around them.

Read https://browser-use.com/skills/upwork.md, then <your task>

Browser Harness

Save the files into your workspace and turn domain skills on. The code below calls its helpers, such as http_get and js. What Browser Harness is.

d="${BH_AGENT_WORKSPACE:-$HOME/.config/browser-harness/agent-workspace}/domain-skills/upwork"
mkdir -p "$d" && cd "$d"
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/upwork/scraping.md
export BH_DOMAIN_SKILLS=1

Field-tested against www.upwork.com on 2026-10-23 using a Browser Use cloud browser.

robots.txt (checked 2026-10-23) allows /freelance-jobs/ including /freelance-jobs/apply/ (except /freelance-jobs/fiction-writing/) and /o/jobs/browse/?q= for all user agents, and disallows /nx/, /api* and /*?page=*.

Quick summary

  • Public, logged-out job listings live at /freelance-jobs/<skill-slug>/ (e.g. /freelance-jobs/python/). Each page embeds 30 jobs in Nuxt's __NUXT_DATA__ JSON, plus the open-job total for that skill (python: 1,631; web-scraping: 291 on 2026-10-23). The 30 are a recent window sorted oldest-first, not the 30 newest (see Gotchas), and there is no page 2.
  • Each job links to a public detail page /freelance-jobs/apply/<slug>_~<id>/ whose __NUXT_DATA__ has the fields the listing lacks: client country (buyer.location.country), exact proposals count (job.clientActivity.totalApplicants), hourly range, full skill list.
  • Plain HTTP is blocked (status 403, Cloudflare challenge page); this skill uses a normal browser session and reads __NUXT_DATA__ from each loaded page.
  • Scale = many skill pages, not deep pagination. The hub /freelance-jobs/ lists 482 skill pages.
  • Stay on /freelance-jobs/; /nx/ (the logged-in search UI) is robots-disallowed.

Field run: 2 skill pages (web-scraping, python) -> 60 cards -> 57 unique jobs, 56 with detail data, in about 5.5 minutes at 2 s pacing. The one miss is a private listing that returns 403 (detail_error set, country/proposals null).

What works

0. Shared helpers (run before block 1 or 2, in the same script)

Browser-harness script (new_tab, goto_url, js are pre-imported). open_page navigates one tab, waits until it shows that URL's finished document, and returns (http_status, decoded __NUXT_DATA__). A challenge page or a 429 raises Blocked: stop and back off, nothing is retried.

import json, time
from urllib.parse import quote, urlsplit
 
BASE = "https://www.upwork.com"
WRAPPERS = ("ShallowReactive", "Reactive", "Ref", "ShallowRef")
STATE = ("JSON.stringify([location.pathname, document.readyState, document.title,"
         " (performance.getEntriesByType('navigation')[0] || {}).responseStatus || 0,"
         " !!document.getElementById('__NUXT_DATA__')])")
 
class Blocked(Exception):
    """Challenge page, 429, or repeated 403s: stop the run and back off. Never retried."""
 
def text(v):
    return v if isinstance(v, str) and v else None
 
def num(v):
    return v if isinstance(v, (int, float)) and not isinstance(v, bool) else None
 
def dget(d, *keys):
    for k in keys:
        d = d.get(k) if isinstance(d, dict) else None
    return d
 
def decode_nuxt(raw):
    """Decode Nuxt 3 __NUXT_DATA__ (devalue format: one flat array, dict/list values are indexes into it)."""
    flat, memo = json.loads(raw), {}
    def ref(i):
        if not isinstance(i, int) or isinstance(i, bool) or not 0 <= i < len(flat):   # -1 undefined, -2 null
            return None
        if i not in memo:
            v = flat[i]
            if isinstance(v, list):
                memo[i] = ref(v[1]) if len(v) == 2 and v[0] in WRAPPERS else [ref(x) for x in v]
            elif isinstance(v, dict):
                memo[i] = {k: ref(x) for k, x in v.items()}
            else:
                memo[i] = v
        return memo[i]
    return ref(0)
 
_tab = {"open": False}
 
def open_page(url, timeout=30):
    """Navigate, wait for THIS url's complete document, return (http_status, payload or None)."""
    if _tab["open"]:
        goto_url(url)
    else:
        new_tab(url)
        _tab["open"] = True
    want = urlsplit(url).path.rstrip("/")
    deadline = time.time() + timeout
    while time.time() < deadline:
        time.sleep(0.5)
        try:
            path, ready, title, status, has_data = json.loads(js(STATE))
        except (TimeoutError, RuntimeError) as e:   # js() can time out while a large page parses
            if isinstance(e, TimeoutError) or "timed out" in str(e):
                continue
            raise
        if path.rstrip("/") != want or ready != "complete":
            continue
        if status == 429 or "Just a moment" in title or (status == 403 and not has_data):
            raise Blocked(f"HTTP {status} {title!r} at {url}")   # challenge or rate limit: stop
        raw = js("(document.getElementById('__NUXT_DATA__') || {}).textContent || ''") if has_data else ""
        return status, decode_nuxt(raw) if isinstance(raw, str) and raw else None
    raise RuntimeError(f"{url} did not load in {timeout} s; tab is at {js('location.href')}")

1. Scrape 2+ skill pages and enrich every job from its detail page

Run block 0 first in the same script. A full run writes upwork_jobs.jsonl. If Blocked stops the run, the jobs read so far go to upwork_jobs.partial.jsonl instead, the block prints a PARTIAL: line and re-raises Blocked, so a stopped run never looks like a full one. Any other error (a skill page that cannot be read) raises before anything is written.

TIER = {1: "Entry level", 2: "Intermediate", 3: "Expert"}   # 0 = not specified
JOB_TYPE = {1: "Fixed-price", 2: "Hourly"}
 
def skill_page(slug):
    """One /freelance-jobs/<slug>/ page: its 30 jobs + the category's open-job total. Raises on failure."""
    url = f"{BASE}/freelance-jobs/{quote(slug, safe='')}/"
    status, payload = open_page(url)
    if status != 200:
        raise RuntimeError(f"HTTP {status} on {url}")
    for v in (dget(payload, "data") or {}).values():
        if isinstance(v, dict) and isinstance(v.get("jobs"), list) and "totalJobs" in v:
            return v["jobs"], num(v["totalJobs"])
    raise RuntimeError(f"no job list in __NUXT_DATA__ on {url}")
 
def job_detail(path):
    """Returns (jobDetails dict, error string or None). A miss is explicit, never silent."""
    try:
        status, payload = open_page(BASE + quote(path, safe="/%"))
    except RuntimeError as e:   # page did not finish loading; Blocked is not caught here
        return {}, f"did not load: {e}"
    if status != 200:
        return {}, f"HTTP {status}"
    jd = dget(payload, "vuex", "jobDetails")
    return (jd, None) if isinstance(dget(jd, "job"), dict) else ({}, "no jobDetails in page")
 
def iso_utc(v):
    s = text(v)   # detail uses "...Z"; the card fallback uses "+0000"
    return s[:-5] + "Z" if s and s.endswith("+0000") else s
 
def to_record(card, jd, err):
    job, buyer = dget(jd, "job") or {}, dget(jd, "buyer") or {}
    jtype = num(job.get("type")) or num(card.get("type"))
    tier = num(job.get("contractorTier"))
    tier = num(card.get("contractorTier")) if tier is None else tier
    fixed = num(dget(job, "budget", "amount"))
    fixed = num(dget(card, "amount", "amount")) if fixed is None else fixed
    skills = [dget(s, "prefLabel") for s in card.get("ontologySkills") or []]
    skills += [dget(s, "name") for s in dget(jd, "sands", "additionalSkills") or []]
    return {
        "id": text(card.get("ciphertext")),
        "title": text(job.get("title")) or text(card.get("title")),
        "posted_on": iso_utc(job.get("postedOn")) or iso_utc(card.get("createdOn")),
        "posted_ago": text(card.get("ago")),
        "job_type": JOB_TYPE.get(jtype),
        "fixed_budget": fixed if jtype == 1 else None,
        "hourly_min": num(dget(job, "extendedBudgetInfo", "hourlyBudgetMin")) if jtype == 2 else None,
        "hourly_max": num(dget(job, "extendedBudgetInfo", "hourlyBudgetMax")) if jtype == 2 else None,
        "experience_level": TIER.get(tier),
        "skills": list(dict.fromkeys(s for s in skills if text(s))),
        "client_country": text(dget(buyer, "location", "country")),
        "proposals": num(dget(job, "clientActivity", "totalApplicants")),
        "workload": text(job.get("workload")) or text(card.get("engagement")),
        "duration": text(job.get("durationLabel")) or text(card.get("durationLabel")),
        "url": BASE + card["url"],
        "detail_error": err,
    }
 
SKILLS = ["web-scraping", "python"]      # each slug = 1 listing page + up to 30 detail pages
rows, seen, streak, stopped = [], set(), 0, None
try:
    for slug in SKILLS:
        cards, total = skill_page(slug)  # raises if the listing cannot be read
        print(f"{slug}: {len(cards)} cards of {total} open jobs (no page 2 exists; the rest are not reachable logged out)")
        time.sleep(2)
        for card in cards:
            if not isinstance(card, dict) or not text(card.get("url")):
                print("  card without url, skipped:", card)
                continue
            key = text(card.get("ciphertext")) or card["url"]   # never drop a card just because its id is missing
            if key in seen:
                continue
            seen.add(key)
            jd, err = job_detail(card["url"])
            if err:
                print("  detail failed:", err, card["url"])
            rows.append(to_record(card, jd, err))
            streak = streak + 1 if err == "HTTP 403" else 0
            if streak >= 2:              # one 403 = private listing; two in a row = treat as a block
                raise Blocked(f"2 detail pages in a row returned 403 (last: {card['url']})")
            time.sleep(2)                # polite pacing: ~1 page per 2-4 s
except Blocked as e:
    stopped = e
    print("STOPPED, back off before retrying:", e)
 
OUT = "upwork_jobs.partial.jsonl" if stopped else "upwork_jobs.jsonl"   # a stopped run never writes the full-run name
with open(OUT, "w") as f:
    for r in rows:
        f.write(json.dumps(r) + "\n")
print(len(rows), "jobs saved to", OUT + ";", sum(r["detail_error"] is not None for r in rows), "without detail data")
print(json.dumps(rows[:2], indent=1))
print("missing country:", sum(r["client_country"] is None for r in rows),
      "missing proposals:", sum(r["proposals"] is None for r in rows))
if stopped:
    print(f"PARTIAL: the run stopped early; {len(rows)} jobs in {OUT} are not all of {SKILLS}")
    raise stopped

Observed output (trimmed):

web-scraping: 30 cards of 291 open jobs (no page 2 exists; the rest are not reachable logged out)
python: 30 cards of 1631 open jobs (no page 2 exists; the rest are not reachable logged out)
  detail failed: HTTP 403 /freelance-jobs/apply/Port-Viva-com-payment-module-Odoo-and-add-ISV-key_~022113260749740632003/
57 jobs saved to upwork_jobs.jsonl; 1 without detail data
[
 {
  "id": "~022112614550818987209",
  "title": "E-commerce Product Monitoring System",
  "posted_on": "2026-10-20T18:38:54.416Z",
  "posted_ago": "3 days ago",
  "job_type": "Fixed-price",
  "fixed_budget": 100,
  "hourly_min": null,
  "hourly_max": null,
  "experience_level": "Intermediate",
  "skills": ["Selenium", "Data Scraping", "HTML", "Python", "Web Scraping", "Web Scraping Framework", "Playwright", "Data Extraction", "AI Agents", "pandas"],
  "client_country": "IND",
  "proposals": 26,
  "workload": null,
  "duration": "1 to 3 months",
  "url": "https://www.upwork.com/freelance-jobs/apply/commerce-Product-Monitoring-System_~022112614550818987209/",
  "detail_error": null
 },
 ...
]
missing country: 1 missing proposals: 1

The stop path was checked locally with this block's code and a stubbed open_page that raised Blocked on the 3rd detail page: 2 jobs went to upwork_jobs.partial.jsonl, no upwork_jobs.jsonl was written, and the block printed PARTIAL: and re-raised Blocked.

Distribution: 31 fixed-price / 26 hourly; Intermediate 34, Expert 19, Entry level 3, unspecified 1; proposals ranged 0 to 660.

There is no public keyword search with pagination under /freelance-jobs/. The hub page /freelance-jobs/ embeds the 482 skill pages it links to (id, url, title). Match your keyword against them (capped at max_slugs, default 5), then feed the slugs to block 1. Run block 0 first in the same script.

ROBOTS_DISALLOWED = {"fiction-writing"}   # robots.txt: Disallow: /freelance-jobs/fiction-writing/
 
status, payload = open_page(f"{BASE}/freelance-jobs/")
skill_pages = None
for v in (dget(payload, "data") or {}).values():
    if isinstance(v, dict) and "categories" in v and isinstance(v.get("jobs"), list):
        skill_pages = [p for p in v["jobs"] if isinstance(p, dict) and text(p.get("id"))]
if not skill_pages:
    raise RuntimeError(f"HTTP {status}: skill list not found in __NUXT_DATA__ on {BASE}/freelance-jobs/")
print(len(skill_pages), "skill listing pages")
 
def find_skill_slugs(keyword, max_slugs=5):
    """Capped: each slug costs block 1 up to 31 page loads (~2 min at 2 s pacing)."""
    kw = keyword.lower().strip()
    return [p["id"] for p in skill_pages
            if p["id"] not in ROBOTS_DISALLOWED
            and (kw in p["id"].replace("-", " ") or kw in (text(p.get("title")) or "").lower())][:max_slugs]
 
for kw in ["scraping", "react", "data entry", "python", "design"]:
    print(kw, "->", find_skill_slugs(kw))

Observed output:

482 skill listing pages
scraping -> ['data-scraping', 'web-scraping']
react -> ['react-js', 'react-native']
data entry -> ['data-entry']
python -> ['python-script', 'python']
design -> ['visual-design', 'packaging-design', 'social-media-design', 'book-design', 'adobe-indesign']

Opening https://www.upwork.com/o/jobs/browse/?q=<keyword> (URL-encode the keyword with quote(kw, safe='')) in the browser redirected to the matching skill page for known skills (shopify -> /freelance-jobs/shopify/, data entry -> /freelance-jobs/data-entry/, react native -> /freelance-jobs/react-native/); a nonsense query (zzqxv) showed a Cloudflare "Worker threw exception" page instead.

Fields

Block 1 returns one dict per job with exactly these keys. Listing card = item in the skill page's data[...].jobs. Detail = vuex.jobDetails on the job page.

Output fieldSourceNotes
idcard ciphertexte.g. ~022112614550818987209; used to dedupe across skill pages (falls back to url if missing)
titledetail job.title / card title
posted_ondetail job.postedOn (fallback card createdOn)ISO UTC; the card's +0000 suffix is rewritten to Z. Card has renewedOn (null unless renewed)
posted_agocard agoe.g. "1 day ago", "9 hours ago"
job_typetype1 = Fixed-price, 2 = Hourly
fixed_budgetdetail job.budget.amount / card amount.amountUSD, fixed-price only (null for hourly; card amount.amount is 0 there)
hourly_min, hourly_maxdetail job.extendedBudgetInfo.hourlyBudgetMin/MaxHourly only; null when the job sets no range. Not read from the card
experience_levelcontractorTier1 Entry level, 2 Intermediate, 3 Expert, 0 not specified -> null (labels checked against the rendered page)
skillscard ontologySkills[].prefLabel + detail sands.additionalSkills[].nameDetail adds skills the page hides behind "+ N more"
client_countrydetail buyer.location.countryNot on listing cards. Mixed formats, see Gotchas
proposalsdetail job.clientActivity.totalApplicantsExact integer. The page shows a bucket such as "20 to 50"
workloaddetail job.workload / card engagementnull on fixed-price jobs
durationdurationLabele.g. "1 to 3 months"
urlhttps://www.upwork.com + card url/freelance-jobs/apply/<slug>_~<ciphertext>/
detail_errornull when the detail page was read; else why not ("HTTP 403", "no jobDetails in page", "did not load: ..."). Country, proposals and hourly range are null then

Also present but not returned: detail job.clientActivity has totalHired, totalInvitedToInterview, invitationsSent, unansweredInvites, lastBuyerActivity; buyer.stats has totalCharges.amount (client spend), totalAssignments, score; plus job.category.name, job.description. Card maxAmount.amount is the max hourly rate (-1 when unset).

Gotchas

  • 30 jobs per skill page, oldest-first, no page 2. Cards come in ascending createdOn order (one swap per page seen). The 27 python jobs not also on web-scraping were all posted 2026-10-22 04:58-15:06 UTC although the category has 1,631 open jobs; web-scraping (291) spanned 2026-10-20 18:38 to 2026-10-23 09:03. So a big category is not shown as its 30 newest jobs. /freelance-jobs/python/2/ returned 404, and ?page= is robots-disallowed. To get more jobs, scrape more skill slugs and dedupe on id (web-scraping and python shared 3 jobs).
  • "Load more jobs" only reveals what is already embedded. The rendered python page showed 10 cards; one click on "Load more jobs" showed 20 and the button disappeared. No new job request was made and all shown ids were among the 30 in __NUXT_DATA__. The data-entry page rendered all 30 with no button. Read the payload, not the DOM.
  • Client country formats are mixed in the source, and the page renders them as-is: full names ("United States", "India") and 3-letter codes ("USA", "IND", "CAN", "GBR") appeared in the same run. Normalize if you group by country.
  • JSON-LD is misleading for country. The detail page's JobPosting JSON-LD had jobLocation.address.addressCountry: "US" for a client located in India. Use buyer.location.country.
  • Private listings return 403 with a normal Upwork page. One job (...Port-Viva-com-payment-module-Odoo-and-add-ISV-key_~022113260749740632003/) loaded in the browser with navigation status 403, a __NUXT_DATA__ payload and the text "This job is a private listing." Block 1 keeps its card-level fields, sets detail_error: "HTTP 403" and moves on. A 403 page without __NUXT_DATA__, a "Just a moment" title, a 429, or two 403s in a row raise Blocked and stop the run: block 1 saves what it has to upwork_jobs.partial.jsonl, prints PARTIAL: and re-raises.
  • No consent banner or login wall appeared on /freelance-jobs/ pages in the cloud browser.
  • The HTTP status comes from performance.getEntriesByType('navigation')[0].responseStatus (Chrome 109+).
  • js() calls time out after 5 s, so do not run slow multi-request fetch() loops inside the page; navigate instead.
  • Pages are large (~1.0-1.4 MB HTML each). Keep 2-3 s between page loads; the 60-page runs at that pace saw no 429.

What does not work

  • Plain HTTP (urllib with a truthful client User-Agent): blocked (status 403, Cloudflare challenge page titled "Challenge - Upwork") on 2026-10-23 for /freelance-jobs/ and /freelance-jobs/python/. Not used; this skill uses a normal browser session.
  • /nx/search/jobs/?q=... (the real search UI): disallowed by robots.txt; not used.
  • /sitemap.xml: plain HTTP is blocked (status 403); not used.
  • ?page=N on skill pages: disallowed by robots.txt (/*?page=*); not used.
  • Getting more than 30 jobs from one skill page: not possible logged out. There is no page 2, and "Load more jobs" does not fetch beyond the 30 embedded jobs.
  • Freelancer profiles: out of scope here (/freelancers/public/api/ and /search/profiles/* are disallowed by robots.txt).

scraping.md · updated Oct 23, 2026 · 2,521 words

Sites change. These notes describe what worked when they were last updated, not a guarantee they still do. If something broke, the fix is a pull request to browser-harness.