DEV Community

Cover image for How to Scrape Work.ua Job Listings and Salaries with Python
ReventaData
ReventaData

Posted on Originally published at apify.com

How to Scrape Work.ua Job Listings and Salaries with Python

By the end of this tutorial, you will have a short Python script that turns a Work.ua job search into structured rows: vacancy title, employer, city or remote, salary range in hryvnia as integers, required experience and posting date.

Work.ua is a Ukrainian job board. It sits behind a Cloudflare challenge: in testing on 2026-10-21, plain, datacenter and residential requests got an HTTP 403 challenge page on 73 of 75 test pages, while Apify Unblocker returned real pages.

In this guide, we use Python and the Apify Actor Work.ua Job Scraper (reventadata/workua-scraper). You will configure a search, run the Actor and filter rows by salary, date and experience. The sample row shown below is a sample from a capture dated 2026-10-21, parsed with the Actor's own parser.

⏱️ Time estimate: one sitting: install one package, set one token, run three short Python blocks.


Prerequisites

To follow this tutorial, you will need:

  1. Python 3.11+ (apify-client 3.3.0 requires it).
  2. The Apify API client for Python.
  3. An Apify account and its API token, which sits on the API & Integrations page in Apify Console.

Install the client:

pip install "apify-client~=3.0"
Enter fullscreen mode Exit fullscreen mode

The code reads the token from an environment variable:

export APIFY_API_TOKEN="your_apify_token_here"
Enter fullscreen mode Exit fullscreen mode

What you'll achieve

You will turn a Work.ua search into one row per vacancy. Each row carries the vacancy id and url, title, company (null when the employer hides its name), location (Київ, Вся Україна or Дистанційно for remote), the card's conditions line, experienceYearsMin, salary fields and postedDate.

Note: The Actor reads vacancy ads only. Per its README, no CVs, phone numbers or e-mail addresses are collected; phone-reveal and resume pages are never requested.

Example use case: Suppose you want remote Python vacancies that show a salary of at least 50,000 UAH. You would set searchQueries: ["python"], remoteOnly: true, category: "it" and minSalary: 50000, and every saved row would carry a numeric hryvnia range that reaches that figure.


Step 1: Access Work.ua Job Scraper

Go to the Work.ua Job Scraper page on Apify Store. The README lists every input and output field. In this tutorial we drive the Actor from Python, so everything after this step happens in code.


Step 2: Configure the search

The main input is searchQueries: one Work.ua search per term, in Ukrainian or English (for example python or водій). Three optional inputs narrow where the search runs:

  • city: a work.ua city slug such as kyiv, lviv, odesa, dnipro or kharkiv.
  • remoteOnly: search Work.ua's remote-work listing instead of a city.
  • category: one of 28 work.ua category slugs, such as it, sales, retail or accounting.

Leave searchQueries empty and set a city, a category or remoteOnly to list every vacancy there. Each result page holds up to 14 vacancies; maxPages (1 to 500, default 10) bounds how many pages each query walks, and maxItems (default 100) caps the rows saved across all queries.

Here is the setup. It searches python, reads one result page and saves at most 20 rows:

import os
from apify_client import ApifyClient

# 1. Initialize ApifyClient with the token from the environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])

# 2. Define the search: one keyword, one result page, at most 20 rows
run_input = {
    "searchQueries": ["python"],
    "maxPages": 1,
    "maxItems": 20,
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: write multi-word terms with spaces, not hyphens. The README notes that work.ua can read a hyphen inside a term as a city or category prefix.

No proxy setting is needed in run_input: when none is given, the Actor uses Apify Unblocker, which returned real pages in testing on 2026-10-21.


Step 3: Add optional filters

Work.ua's robots.txt disallows its own filter parameters (salaryfrom=, days=, experience= and others), so the Actor reads the result pages and applies these filters to the rows itself. Rows a filter drops never reach your dataset:

Parameter Type What it keeps
minSalary / maxSalary integer Rows whose bold hryvnia range overlaps the band. Rows without a numeric range are dropped when either is set.
onlyWithSalary boolean Rows with a numeric hryvnia range.
postedWithinDays integer Rows posted in the last N days, Kyiv time (0 = today).
maxExperienceYears integer Rows asking for at most N years. Rows that state none pass.
verifiedCompaniesOnly boolean Companies with work.ua's verified tick.
includeKeywords / excludeKeywords array Words the title, company or snippet must / must not contain, ignoring case and accents.
includeDetails boolean Not a filter: opens each vacancy page for the full description, requirements and skill tags.

Three combinations that work:

  • Fresh Kyiv vacancies: city: "kyiv", postedWithinDays: 3, no keyword.
  • Junior-friendly sales roles: category: "sales", maxExperienceYears: 1.
  • Remote Python with a salary: remoteOnly: true, category: "it", searchQueries: ["python"], onlyWithSalary: true.

⚠️ city and remoteOnly cannot be combined: the Actor stops with an error at the start of the run. A category outside the 28 slugs is refused before the run starts.


Step 4: Run the Actor

Now start the run with client.actor("reventadata/workua-scraper").call(run_input=run_input). The call starts the Actor on Apify and waits for it to finish before returning the run:

# 3. Start the Actor run and wait for it to finish
print("Starting Work.ua extraction...")
run = client.actor("reventadata/workua-scraper").call(run_input=run_input)
if run is None or run.status != "SUCCEEDED":
    status = run.status if run else "unknown"
    raise SystemExit(f"Run did not succeed (status: {status}).")

# 4. Fetch the saved vacancies
items = client.dataset(run.default_dataset_id).list_items().items
print(f"Retrieved {len(items)} vacancies.\n")
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  • The Actor builds the search URL (/jobs-python/ here, plus ?page=N from page 2 on) and fetches each result page through Apify Unblocker.
  • It parses each vacancy card, skips the promoted Defence Forces insert that work.ua's own count leaves out, applies your filters, and saves the rows.
  • It stops at maxItems, at maxPages, or at the last page the site has, whichever comes first.

Step 5: View and download results

Loop over the rows to print the parts you need:

for item in items:
    print(item["title"])
    print(f"  Company: {item.get('company') or 'hidden'}")
    print(f"  Location: {item.get('location')}")
    if item.get("salaryMin") is not None:
        print(f"  Salary: {item['salaryMin']}-{item['salaryMax']} UAH")
    else:
        print(f"  Salary text: {item.get('salaryText') or 'not shown'}")
    print(f"  Posted: {item.get('postedDate')}")
    print("-" * 40)
Enter fullscreen mode Exit fullscreen mode

Here is one row, a sample from a capture dated 2026-10-21 (page 1 of the python search) parsed with the Actor's own parser. A saved row also carries scrapedAt, added at save time:

{
    "id": "7394323",
    "url": "https://www.work.ua/jobs/7394323/",
    "title": "Python developer (Django, FastAPI)",
    "company": "UncleSolutions",
    "companyHidden": false,
    "companyVerified": true,
    "companyIsVip": false,
    "companyLogo": "https://assets.work.ua/employer_design/3238813/company_logo_v1.png",
    "location": "Дистанційно",
    "distanceFromCenterKm": null,
    "isRemote": true,
    "isHybrid": false,
    "conditions": [
        "Досвід від 1 року"
    ],
    "experienceYearsMin": 1,
    "suitableFor": null,
    "studentsWelcome": false,
    "contactMethods": [],
    "salaryText": "43 000 – 84 000 грн",
    "salaryMin": 43000,
    "salaryMax": 84000,
    "salaryCurrency": "UAH",
    "salaryAfterDeductions": true,
    "salaryNote": null,
    "snippet": "We are looking for a motivated and ambitious Strong Junior Python Developer who is eager to grow in the field of modern web technologies. You will become part of our team, contribute to interesting and challenging…",
    "isHot": false,
    "isNew": false,
    "hasDeferment": false,
    "salaryAboveAverage": false,
    "postedAt": "2026-10-21T01:38:02+03:00",
    "postedDate": "2026-10-21",
    "matchedKeyword": "python",
    "page": 1
}
Enter fullscreen mode Exit fullscreen mode

Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.


Understanding your results

The fields that matter most:

  • salaryMin, salaryMax, salaryCurrency: the bold hryvnia range as integers (UAH). Any other wording stays in salaryText and is never guessed into a number. In the 14-row capture, 7 rows had some salary text, 5 of them a numeric range; від 1200$ and гідна (залежить від кваліфікації претендента) stayed text.
  • salaryAfterDeductions: true when work.ua marks the range as net of tax.
  • location and isRemote: Дистанційно means remote. 6 of the 14 captured rows were remote.
  • experienceYearsMin: years from Досвід від N, null when none is stated. In the capture, 7 rows asked for 1 year, 6 for 2, and 1 stated none.
  • postedAt / postedDate: Kyiv-time timestamp and date. Promoted (isHot) cards show a date only, so their postedAt is null.
  • companyVerified, hasDeferment, salaryAboveAverage: work.ua's verified tick, its reservation-for-employees label, and its above-average-salary label.
  • matchedKeyword and page: which query and result page produced the row.

Pro tips

  1. Narrow on the site, filter in the Actor. city, remoteOnly and category change the URL work.ua serves; salary, date, experience and keyword filters run on the rows. A narrow filter on a broad search reads many pages for few rows, so set maxPages to bound it.
  2. Tighten loose matches. The README notes that a data analyst search returned accounting vacancies listing data analysis as a skill. Add includeKeywords: ["analyst"] to keep only rows that say it.
  3. Batch queries in one run. Put several terms in searchQueries. A vacancy kept under one term is not saved again under the next.
  4. Turn on details only when you need them. includeDetails adds description, requirements, skills, workplace, employmentSchedule and the company link, at one extra request per vacancy. A closed vacancy keeps its listing fields with detailFetched: false.
  5. Start small. Keep maxPages: 1 and maxItems: 20 until your downstream code handles the fields, then raise them.

Troubleshooting

  • Run FAILED with zero rows: a run that is blocked or cannot read a page ends FAILED. An unknown but well-formed city slug returns no vacancies, and an over-strict filter does too; both end SUCCEEDED with an empty dataset and a status message saying so. Check it against work.ua/jobs/by-region/.
  • Run stops at once: city and remoteOnly were both set, or no query, city, category or remoteOnly was given.
  • Salary filter left almost nothing: rows without a numeric hryvnia range are dropped whenever minSalary, maxSalary or onlyWithSalary is set.
  • Fewer rows than expected: read the log's summary line, Run finished: N rows pushed, N detail pages failed. Rejections: ...; it counts each filter's drops (salary, too-old, experience, unverified-company, keywords). From Python: client.log(run.id).get().

For help, use the Actor's Store page.


Pricing and next steps

For current pricing and details, see the Store page.

To repeat a search, save the input as a task and add an Apify schedule, which runs an Actor or task on a cron expression (0 8 * * * is every day at 8 AM). To react when a run finishes, add a webhook on ACTOR.RUN.SUCCEEDED; its payload carries the run object, including defaultDatasetId.

Which roles or cities are you planning to track on Work.ua? Drop a comment! 💬

Top comments (1)

Collapse
 
magia_cc_0f5823d6cb56ed5 profile image
magia c'c •

Your filter drops every salary that cannot be parsed as a UAH range, so I'd keep a rejected-row count beside any salary trend. For a BrowserAct Cloud Bot feeding a jobs dashboard, I'd return currency, original text and parse status as separate fields. That makes an apparent pay change easier to distinguish from a change in what the parser could understand.