Python job extraction workflow showing access checks, HTML validation and spreadsheet export

An Upwork scraper can turn accessible job-page data into structured listings for review, spreadsheets and alerts. The practical workflow is to establish permission, fetch a limited sample, validate the embedded data and export only the fields actually present.

Our quick run on October 17, 2026 extracted 73 unique jobs across three public category pages, but also encountered blocks and missing fields. This tutorial explains that small snapshot, its Python extraction logic and the limits that matter before scheduling anything.

Public visibility is not permission to automate. Upwork’s automation policy distinguishes approved API use from website scraping; an API key does not authorize the latter. Use the parsing example with HTML you are authorized to process.


Key Facts

  • Our quick run on October 17, 2026 was a small snapshot, not a benchmark.
  • Three category pages yielded 90 job tiles and 73 unique jobs.
  • All 73 unique jobs contained a title, URL, posting date, type, duration and snippet.
  • Budget information appeared in 57 of the 73 unique jobs.
  • Plain Python requests received HTTP 403 on both tested routes.
  • Residential routing did not consistently produce accessible pages.

What can an Upwork scraper collect?

An Upwork scraper can collect fields embedded in accessible listing pages, but it cannot reconstruct information absent from the response. Our category-page sample contained useful job data without visiting every linked detail page.

Observed fields included title, ciphertext job ID, detail URL, posting timestamp, relative age, contract type, duration, workload, skills and description. The extraction code keeps only the first 200 description characters as a snippet.

Fixed-price amounts and hourly upper rates need different meanings. An hourly maximum is not a complete rate range, and a missing amount is not zero. Client country, client spending, payment verification and proposal counts were absent from our logged-out sample.

The three category totals were 293 for web scraping, 536 for data scraping and 1,626 for Python. Those totals described the categories; they were not the number of records downloaded. For broader schema planning, see our guide to scraping job postings.


How do you build an Upwork scraper in Python?

Separate fetching from parsing, and validate an authorized HTML sample before collecting additional pages. Use the 4-step extraction check: access, fetch, validate, export.

Check each step before collecting more pages

Step 1: Access. Check permission, terms and crawler rules. Upwork’s robots.txt restricts general-crawler access to /nx/ (as of October 2026), so the tutorial excludes the logged-out search route. An unlisted category path is not affirmative authorization.

Step 2: Fetch. Our Linux environment used Python 3.12.3 and curl_cffi 0.15.0. For a matching environment, create a virtual environment with python3 -m venv .venv, activate it with source .venv/bin/activate, and install python -m pip install curl_cffi==0.15.0. Consult the official Python fetching documentation and curl_cffi documentation when reviewing client behavior.

The measured category URLs were https://www.upwork.com/freelance-jobs/web-scraping/, https://www.upwork.com/freelance-jobs/data-scraping/ and https://www.upwork.com/freelance-jobs/python/. These identify the experiment, not a permission grant or a promise of continued access.

Step 3: Validate. In successful responses, the job state lived in the script element named __NUXT_DATA__, not window.__NUXT__. Its flat JSON list used references to other entries. The following extraction portion is copied unchanged from the tested upwork_scraper.py; it contains no proxy configuration.

import csv, json, os, random, re, time
BASE = "https://www.upwork.com"
NUXT_DATA = re.compile(r'<script[^>]*id="__NUXT_DATA__"[^>]*>(.*?)</script>', re.S)
def load_payload(html):
    """Nuxt 3 stores page state as one flat JSON list; ints point to other items."""
    flat = json.loads(NUXT_DATA.search(html).group(1))

    def resolve(i):
        if i < 0:                       # -1 undefined, -2 null, etc.
            return None
        x = flat[i]
        if isinstance(x, dict):
            return {k: resolve(v) for k, v in x.items()}
        if isinstance(x, list):
            if x and x[0] in ("Reactive", "ShallowReactive", "Ref", "ShallowRef"):
                return resolve(x[1])     # wrapper: ["Reactive", index]
            return [resolve(v) for v in x]
        return x

    for i, item in enumerate(flat):     # the object that holds the job list
        if isinstance(item, dict) and "jobs" in item and "totalJobs" in item:
            return resolve(i)
    return None


def parse_jobs(state, category):
    rows = []
    for job in state.get("jobs") or []:
        hourly = job.get("type") == 2
        max_rate = (job.get("maxAmount") or {}).get("amount")
        fixed = (job.get("amount") or {}).get("amount")
        rows.append({
            "category": category,
            "job_id": job.get("ciphertext"),
            "title": job.get("title"),
            "url": BASE + job["url"] if job.get("url") else None,
            "posted": job.get("createdOn"),
            "posted_ago": job.get("ago"),
            "job_type": "hourly" if hourly else "fixed",
            "budget": (f"up to ${max_rate}/hr" if max_rate else None) if hourly
                      else (f"${fixed}" if fixed else None),
            "duration": job.get("durationLabel"),
            "workload": job.get("engagement"),
            "skills": "; ".join(s["prefLabel"] for s in job.get("ontologySkills") or []),
            "snippet": (job.get("description") or "")[:200].replace("\n", " "),
        })
    return rows

Place the three blocks together, load authorized HTML into a string named html, then call load_payload(html) followed by parse_jobs(state, category). This is the parsing component, not a standalone network collector. Check for the script element first: this parser assumes its presence and can raise an exception on malformed or changed data.

Step 4: Export. Validate IDs and URLs, deduplicate, then serialize the rows. The tested collector wrote upwork_jobs.csv and upwork_jobs.json. Its output contained 73 unique jobs; no individual listing is invented here to illustrate a successful row.

The resolver is specific to the observed payload, not a general decoder. It collapses negative references to None and assumes the observed type mapping. A missing payload, unexpected structure or parsing exception should stop export and preserve the failure for inspection. Rendering is a separate diagnostic topic covered in our Playwright scraping guide.


What did our quick run measure?

Our quick run on October 17, 2026 was a small snapshot, not a benchmark. It involved about 17 requests to Upwork.com from a Linux server, with 4-9 second pauses, logged-out pages, no cookies and no challenge solving.

Source: our October 17 run log and the upwork_jobs.csv and upwork_jobs.json outputs from upwork_scraper.py. No revision hash, per-request elapsed milliseconds or exact response-byte ledger was recorded. The table therefore reports rounded body sizes and grouped observations, not six invented request records.

URL or path Client and route HTTP Challenge or block Elapsed ms Response body Records Fields populated or missing
/nx/search/jobs/?q=web%20scraping and /freelance-jobs/web-scraping/ requests, direct server egress 403 Cloudflare block Unrecorded About 4 KB 0 No job fields
Same search and category paths requests, residential 403 Cloudflare block Unrecorded About 4 KB 0 No job fields
Same search and category paths curl_cffi chrome, direct server egress 403 JavaScript challenge Unrecorded About 336 KB 0 No job fields
/nx/search/jobs/?q=web%20scraping curl_cffi chrome, residential 200 No challenge in successful response Unrecorded About 1,313 KB 10 Per-field counts unrecorded
/freelance-jobs/web-scraping/ curl_cffi chrome, residential 200 No challenge in successful response Unrecorded About 1,125 KB 30 See aggregate completeness below

The three-category script run included one challenge retry and yielded 90 tiles, then 73 unique jobs. Title, URL, posted, type, duration and snippet were populated in 73 records; skills in 70, budget in 57 and workload in 50. The 16 missing budgets were hourly jobs without a displayed rate.

Across seven residential category attempts in the wider experiment, three returned challenges. Direct traffic used datacenter-server egress, not a purchased datacenter proxy. These observations cannot establish a general success rate or isolate which request characteristic caused a different response.


How should pagination and storage work?

Follow only observed, authorized next links and impose a request cap. Our experiment did not establish working category pagination: ?page=2 returned challenges twice.

Treat the measured category response as 30 tiles, not complete category coverage. Overlapping categories produced duplicates, so adding category totals together would overstate both coverage and unique jobs.

Use the job ID as the primary key and a normalized URL as a fallback. Reject records missing both. The tested collector deduplicated across categories in memory; it did not maintain a persistent history between executions or remove every possible duplicate within one batch.

Keep JSON as the authoritative export when null values matter. CSV blanks need an agreed interpretation, and the tested parser emits empty strings for absent skills or descriptions. Preserve the posting timestamp separately from relative age, and store collection time alongside future runs. Never overwrite a healthy dataset with an empty result caused by a block.


Which tools can export jobs to spreadsheets?

Choose an extraction tool by its inspectable output and access requirements. A spreadsheet-friendly result still needs reliable IDs, explicit missing values and a known collection time.

Option Use X when Avoid X when
Python Use Python when you need inspectable extraction. Avoid Python when you cannot maintain parsing.
Actor/extension Use an actor/extension when its documented output fits. Avoid an actor/extension when it requires inappropriate account access.
Official API Use the official API when your use case and scopes are approved. Avoid the official API when you assume unrestricted marketplace access.
Proxy Use a proxy when authorized traffic needs alternative routing. Avoid a proxy when you expect permission or guaranteed access.

For an apify Upwork scraper evaluation, inspect the actor vendor’s documentation, sample output and access assumptions. A marketplace listing is not Upwork endorsement. Apply the same review to GitHub repositories, extensions and visual scraping tools; a recent commit alone does not demonstrate working extraction.

For a Google Sheets or Airtable destination, prepare a CSV with stable column names and map IDs and URLs as text during ingestion. Review date conversion and text beginning with formula characters. For an n8n handoff, define a JSON record contract and reject incomplete batches before the downstream workflow runs. These are integration requirements, not claims that a particular connector was tested.


How can you automate job alerts?

Schedule alerts only after establishing a permitted source and persistent deduplication. A daily Python job should compare incoming IDs against stored history and notify only on genuinely new records.

Keep extraction and notification separate. Record the last successful collection, prevent overlapping executions, and report collection failures independently from an empty set of new jobs. Parsing messages in your own mailbox can be another input, with appropriate mailbox authorization and minimal retention.

Upwork discontinued RSS support after August 20, 2024, according to its RSS deprecation notice. Our single legacy-feed request returned HTTP 410 with a page-not-found message. Old RSS setup instructions therefore do not describe a working path from this experiment.

The eligibility limits for official instant job alerts include Freelancer Plus and a proposal to at least one active job (as of October 2026). Check those options before building an external polling system.


Why do requests return blocks or empty data?

Classify the response before changing selectors: a block page and a valid page without matching jobs are different results. HTTP status alone does not identify every failure.

Our plain Python requests received a small Cloudflare block page on both routes. The other client received a larger challenge on direct egress, while residential attempts produced both challenges and content. This does not prove a particular fingerprint, velocity threshold or internal detection architecture caused the difference.

Inspect status, title, content type, body size and the expected payload. If HTML is legitimate but data is absent, investigate structure or rendering. If a challenge appears, record it and stop rather than interpreting it as zero jobs.

For general diagnosis, Cloudflare documents error 1015 as rate limiting (as of October 2026). Its 1020 documentation is another reference when that exact code appears. Neither code was identified in our recorded matrix. Our 403 troubleshooting guide explains the broader diagnostic process.


When should you use proxies?

Use proxies for authorized routing requirements, with costs measured against validated records. A different network route cannot grant permission or guarantee access.

Rotating sessions change the exit IP between requests; sticky sessions retain an exit for a session. Neither repairs missing fields or broken parsing. DataImpulse is proxy infrastructure, not a scraping API, managed unblocker or VPN.

At $1 per GB, the body-only estimate is mean_body_bytes * 1000 / 1e9 * $1. Using approximately 1.125 million decoded body bytes gives about $1.13 per 1,000 category responses. This is an estimate from rounded sizes, excluding headers, retries and browser assets.

Local recompression suggested 120-150 KB per successful category body, or roughly $0.12-$0.15 per 1,000 responses. Actual transferred bytes were not measured, so that is not a billing observation. Category overlap and missing fields also prevent converting page cost directly into a guaranteed cost per thousand unique, usable jobs.


What official API and export alternatives exist?

Upwork has an official developer platform, but access depends on approval and the intended use. Website extraction and API integration are separate workflows.

The developer overview identifies marketplace search among its API areas. Start with the key application, OAuth 2.0 authentication guidance and GraphQL documentation. Confirm the precise query and approved scopes before implementing job search; job creation is a different operation.

The API limits help page states 10 requests per second per IP (as of October 2026). We could not reconcile that value with the GraphQL documentation during verification, so do not treat it as a universal operating allowance. Confirm applicable limits and retention rules for your integration; see our explanation of API rate limiting.

This experiment verified locally generated CSV and JSON, not a native marketplace export or proposals download. Do not design a workflow around an assumed export button, notification endpoint or unrestricted freelancer scope.


What legal and contractual risks apply?

Separate law, contract and crawler instructions before collecting data. A publicly readable page does not settle all three questions.

Upwork’s Terms of Use restrict scraping without written permission. Its robots.txt expresses crawler restrictions, but the absence of a matching exclusion is not a license to collect or redistribute content. Account consequences and legal liability are also different questions.

The Ninth Circuit’s 2022 hiQ v. LinkedIn opinion addressed public-page access under the CFAA in a preliminary-injunction context. It did not establish universal permission to scrape or eliminate contractual claims.

The EU Database Directive can restrict substantial extraction and certain repeated extraction. Where personal data is involved, GDPR obligations can apply, including a lawful basis and storage limits. Minimize incidental personal information, control access and document retention. A commercial collection project deserves jurisdiction-specific review before expansion. Our web scraping legality guide gives broader context. Ultimately, the reader decides, check local law


Frequently Asked Questions

How can I scrape public Upwork job listings with Python?

Establish permission first, then validate an authorized HTML response before parsing it. In our October 17 snapshot, the extraction functions resolved embedded __NUXT_DATA__ and converted job objects into rows. The parser is specific to that observed structure and needs validation before reuse.

Does Upwork provide an official job-search API?

The official developer resources linked above are the starting point for evaluating marketplace search. Confirm the approved operation and scopes for your application before building around it. An API approval must not be interpreted as permission to collect website pages.

Can I export Upwork jobs to CSV or Google Sheets?

Our tested collector generated CSV and JSON from the extracted records. For a spreadsheet workflow, retain stable job IDs, review date conversion and define what blank cells mean. This result does not establish the availability of a native Upwork export.

Can I still monitor Upwork jobs through RSS?

Do not build a new workflow around the legacy feed tested here: our request returned HTTP 410. The official deprecation notice linked above explains its retirement. Evaluate eligible official alerts or another explicitly permitted input.

Why does my scraper receive a 403 or CAPTCHA?

A denial or challenge means the response is unsuitable for normal job parsing. Our experiment encountered blocks on both direct and residential routes, without establishing the precise cause. Preserve the diagnostic result and review access conditions before attempting further collection.

Why are client information and budgets missing?

Those fields were not consistently present in the logged-out data we examined. Sixteen hourly jobs had no rate, while client country, spending, verification and proposal counts were absent from the sample. Keep these values unknown rather than inventing replacements.

Does residential routing guarantee successful extraction?

No. Three of seven residential category attempts in our wider experiment returned challenges. Routing also cannot correct an obsolete parser, establish permission or fill missing fields.


Estimate routing costs after validating your workflow

If your authorized collection needs residential routing, DataImpulse offers residential proxies from $1 per GB with rotating or sticky sessions. Start with a small permitted sample, measure actual transferred bytes and count validated unique records before increasing volume.

Last updated: October 17, 2026.


Share article: