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:
-
Python 3.11+ (
apify-client3.3.0 requires it). - The Apify API client for Python.
- 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"
The code reads the token from an environment variable:
export APIFY_API_TOKEN="your_apify_token_here"
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 askyiv,lviv,odesa,dniproorkharkiv. -
remoteOnly: search Work.ua's remote-work listing instead of a city. -
category: one of 28 work.ua category slugs, such asit,sales,retailoraccounting.
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,
}
💡 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.
⚠️
cityandremoteOnlycannot be combined: the Actor stops with an error at the start of the run. Acategoryoutside 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")
Behind the scenes:
- The Actor builds the search URL (
/jobs-python/here, plus?page=Nfrom 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, atmaxPages, 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)
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
}
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 insalaryTextand 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. -
locationandisRemote:Дистанційно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 theirpostedAtis null. -
companyVerified,hasDeferment,salaryAboveAverage: work.ua's verified tick, its reservation-for-employees label, and its above-average-salary label. -
matchedKeywordandpage: which query and result page produced the row.
Pro tips
-
Narrow on the site, filter in the Actor.
city,remoteOnlyandcategorychange 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 setmaxPagesto bound it. -
Tighten loose matches. The README notes that a
data analystsearch returned accounting vacancies listing data analysis as a skill. AddincludeKeywords: ["analyst"]to keep only rows that say it. -
Batch queries in one run. Put several terms in
searchQueries. A vacancy kept under one term is not saved again under the next. -
Turn on details only when you need them.
includeDetailsaddsdescription,requirements,skills,workplace,employmentScheduleand the company link, at one extra request per vacancy. A closed vacancy keeps its listing fields withdetailFetched: false. -
Start small. Keep
maxPages: 1andmaxItems: 20until 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 endSUCCEEDEDwith an empty dataset and a status message saying so. Check it against work.ua/jobs/by-region/. -
Run stops at once:
cityandremoteOnlywere both set, or no query, city, category orremoteOnlywas given. -
Salary filter left almost nothing: rows without a numeric hryvnia range are dropped whenever
minSalary,maxSalaryoronlyWithSalaryis 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)
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.