By the end of this tutorial you will have a Python script that pulls Ukrainian job vacancies from robota.ua into structured rows: title, employer, city, stated salary bounds, skills, schedule, contact fields and posting timestamp.

robota.ua is a Ukrainian job board whose pages load as an Angular shell and fetch vacancy data from a GraphQL endpoint behind Cloudflare. In tests from a Mac on 7 November 2026, curl hit a Cloudflare 403 challenge on / and /robots.txt, and a browser-TLS HTTP client hit 403 on the GraphQL API instead of JSON. robota.ua Scraper (reventadata/robota-ua-scraper) is the Apify Actor I built to query that endpoint through Apify Proxy and return one typed row per vacancy.

This guide drives it from Python with the Apify API client, and every output line and sample row below comes from a real 20-row run of a python search on 14 November 2026.

Prerequisites

You need three things:

  1. Python 3.11 or newer (required by the 3.x Apify API client for Python).
  2. A free Apify account, which you can create with an email address or a GitHub login.
  3. Your API token, from Settings > API & Integrations in Apify Console.

Install the client and export your token:

pip install -U "apify-client~=3.0" export APIFY_API_TOKEN="your_apify_token_here" 

No browser automation or scraping library is needed on your machine; the Actor handles fetching on Apify.

What you'll achieve

You will run a python keyword search on robota.ua with includeDetails enabled, wait for the run to finish, read its 20 rows in Python and print a two-line summary per vacancy showing title, employer, city, stated salary, remote flag, category and relative posting time.

You will also see how robota.ua data behaves in practice: most listings omit salary numbers, a Kyiv location can still be marked remote in its schedule tags, and one vacancy in the sample run came from an anonymous employer with company set to null. The same script works for any Ukrainian or English keyword by changing the input dictionary.

Step 1: Open robota.ua Scraper

Open the robota.ua Scraper page on Apify Store. The Try for free button opens the Actor in Apify Console if you want to inspect the form. Because this tutorial drives everything from Python, you only need the Actor slug reventadata/robota-ua-scraper.

Step 2: Configure the search

The Actor accepts these main input fields:

  • searchQueries: list of job titles or keywords, one search per term (for example python, водій, sales manager). Duplicates across terms are dropped by vacancy id after saving. Leave it empty to pull the site's default-ranked list across all topics.
  • maxItems and maxPages: row cap across all queries (default 100, minimum 1) and pages per query (default 10, up to 500; 20 vacancies per page).
  • includeDetails: when true (default false), opens each kept vacancy page to add 18 extra fields including description, postedAt, category, schedules, isRemote, skills and published contacts.
  • city: case- and accent-insensitive substring match on location.
  • minSalary, maxSalary, onlyWithSalary: salary bounds compared as the API returns them (the API gives no currency field). Vacancies without a stated salary are dropped whenever any of these is set.
  • includeKeywords, excludeKeywords: case- and accent-insensitive substring matches across title, company and snippet (for example, "python" also matches "pythonista").
  • proxyConfiguration: Apify Proxy is enabled by default with no group named; switch to RESIDENTIAL if a run hits a block.

The city, salary and keyword filters run after each page is read, so they drop rows rather than reducing pages fetched. Here is an example input that narrows two keywords to Kyiv salaries from 60000 upwards:

{ "searchQueries": ["python", "data engineer"], "city": "Київ", "onlyWithSalary": true, "minSalary": 60000, "maxItems": 40, "maxPages": 3 } 

Step 3: Run it from Python

This script starts the Actor, checks that the run succeeded, reads the dataset and prints two lines per vacancy:

import os from apify_client import ApifyClient # 1. Initialise the client with your token from the environment client = ApifyClient(os.environ["APIFY_API_TOKEN"]) # 2. Describe the search: "python" vacancies with full detail pages run_input = { "searchQueries": ["python"], "maxItems": 20, "maxPages": 2, "includeDetails": True, } # 3. Start the Actor and wait for it to finish run = client.actor("reventadata/robota-ua-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. Read the rows from the run's default dataset items = client.dataset(run.default_dataset_id).list_items().items print(f"Retrieved {len(items)} vacancies.\n") # 5. Print two lines per vacancy for item in items: salary_from, salary_to = item.get("salaryFrom"), item.get("salaryTo") if salary_from is not None and salary_to is not None: salary = f"{salary_from}-{salary_to}" elif salary_from is not None: salary = f"from {salary_from}" elif salary_to is not None: salary = f"up to {salary_to}" else: salary = "not stated" remote = "remote" if item.get("isRemote") else "on-site/hybrid" print(f"{item['title']} | {item.get('company') or 'anonymous'} | {item.get('location')} | salary: {salary} | {remote}") print(f" category: {item.get('category')}, posted: {item.get('postedText')}") 

With includeDetails set to True, the Actor issues one extra request per kept vacancy, so keep maxItems modest while testing.

Step 4: Read the output

The cited run on 14 November 2026 finished in 11.5 seconds and returned 20 vacancies (18 from page 1 and 2 from page 2). Here are the first five lines printed by the loop:

ВІ аналітик | Дарниця, Фармацевтична компанія | Київ | salary: not stated | on-site/hybrid category: Фармацевтика, posted: 3 години тому AppSec Engineer (Application Security Engineer) | robota.ua | Київ | salary: not stated | remote category: IT, posted: 23 години тому Middle QA Engineer (ML / AI & Data) | robota.ua | Київ | salary: not stated | remote category: IT, posted: 1 день тому Провідний фахівець з HR-систем, автоматизації та аналітики | UNIQA, Страхова компанія | Київ | salary: not stated | on-site/hybrid category: Страхування, posted: 1 день тому Менеджер/Менеджерка з моделювання та планування продуктів зі зниженим ризиком / Бізнес-аналітик | JTI / Japan Tobacco International | Київ | salary: not stated | on-site/hybrid category: FMCG, posted: 1 день тому 

Every row had location set to Київ, yet two of those first five (isRemote: true) support remote work according to their schedules.

Step 5: Inspect a complete row

When includeDetails is True, each row carries all 42 fields. Here is one complete row copied verbatim from that 14 November 2026 run:

{ "id": "11400591", "url": "https://robota.ua/company15469395/vacancy11400591", "title": "Data Scientist (LLM/AI)", "company": "NDA Recruitment", "companyId": "15469395", "companyLogo": "https://company-logo-frankfurt.rabota.ua/15469395_20250619204422.png", "location": "Київ", "cityId": "1", "scheduleIds": [ "8", "1" ], "salaryFrom": 121500, "salaryTo": 150000, "salaryComment": null, "snippet": "Про нас:Ми — одна з найбільших українських MilTech-компаній. Активно впроваджуємо AI-рішення для оптимізації та автоматизації внутрішніх бізнес-процесів. Розширюємо нашу LLM & AI команду і шукаємо людину, яка не просто запускає ChatGPT через API, а г...", "postedText": "2 дні тому", "isHot": false, "isActive": true, "isAnonymous": false, "publicationType": "PROFESSIONAL", "benefits": [ "Бронювання співробітників" ], "applyUrl": null, "matchedKeyword": "python", "page": 1, "detailFetched": true, "postedAt": "2026-11-12T03:01:00.027000+02:00", "description": "Про нас:\nМи — одна з найбільших українських MilTech-компаній. Активно впроваджуємо AI-рішення для оптимізації та автоматизації внутрішніх бізнес-процесів. Розширюємо нашу LLM & AI команду і шукаємо людину, яка не просто запускає ChatGPT через API, а готова створювати надійні продуктові ML-рішення.\nЩо треба буде робити:\n- Проєктувати промпти, структуру контексту та будувати нормальні RAG-системи (chunking, retrieval, reranking).\n- Розробляти агентні системи з tool use та валідацією виходів.\n- Будувати пайплайни evaluation (golden datasets, метрики, LLM-as-judge) перед релізом, щоб моделі не галюцинували в найвідповідальніший момент.\n- Проводити fine-tuning (за потреби), писати чистий production-код на Python та дбати про безпеку рішень (guardrails, injection-тести).\nНаші очікування від кандидата:\n- 3+ роки досвіду в DS/ML, з них від 1 року — практична робота з LLM у реальних продакшн-проєктах.\n- Досвід побудови RAG або агентних систем своїми руками (і розуміння, чому саме такі архітектурні рішення були обрані).\n- Практика evaluation: збір golden datasets, вибірка метрик і відстеження деградацій.\n- Впевнений Python (тести, рев'ю, структура сервісу) та вміння пояснити складні технічні речі нетехнічним стейкхолдерам.\nЧому варто долучитися:\nТут немає нудних пет-проєктів заради слайдів. Ваші рішення матимуть реальний вплив на внутрішні процеси та допоможуть наближати перемогу України.\nЗацікавила вакансія?", "address": null, "district": null, "metro": null, "latitude": 50.4500351, "longitude": 30.5241356, "category": "HR", "schedules": [ "гібридна", "повна зайнятість" ], "isRemote": false, "skills": [ "Мова програмування python", "Machine Learning Engineer", "Дата Саєнтист", "Дата Майнинг", "Середній розробник Python" ], "contactName": "Вікторія Н.", "contactPhones": [], "contactSocials": [ "viktoriia.n@nda.in.ua" ], "companyVerified": true, "companyFoundedYear": null, "companySize": "від 20 до 50", "companyBenefits": [ "Їжа в офісі", "Офіційне працевлаштування згідно КЗпП України", "Повністю офіційна зарплата" ], "scrapedAt": "2026-11-14T14:45:19+00:00" } 

Understanding your results

Each row holds 42 fields: 24 list and run metadata fields plus 18 detail fields that stay null when includeDetails is false:

  • salaryFrom, salaryTo, salaryComment: numeric bounds as robota.ua states them (0 becomes null), with no currency field in the API. In the 20-row run, 2 vacancies stated a salary (121500–150000 and an open-ended 75000) and 18 stated none.
  • postedText vs postedAt: the list gives relative Ukrainian text ("2 дні тому"), while postedAt adds the ISO 8601 timestamp with the Europe/Kyiv offset (+02:00 here) when includeDetails is true.
  • schedules and isRemote: isRemote is true when schedules includes "віддалена робота" (5 of 20 rows in the run).
  • company and isAnonymous: anonymous listings set company and companyId to null (1 of 20 rows).
  • contactName, contactPhones, contactSocials, applyUrl: populated only when published by the employer (contactName appeared in 14 of 20 rows, contactSocials in 3 of 20 rows, contactPhones in 1 of 20 rows, and an external applyUrl in 1 of 20 rows).

Pro tips

  1. Leave includeDetails off until you need full text or exact dates. List-only runs fetch one request per 20 vacancies instead of 21, and scheduleIds ("3" appeared on all five remote vacancies in the sample) and snippet still arrive on the list.
  2. Expect sparse salaries. Because 18 of 20 vacancies in the sample stated no salary, setting onlyWithSalary: true drops most rows after fetching. Raise maxPages if you filter on salary.
  3. Batch related terms in searchQueries. A vacancy returned under two keywords is pushed once, keeping your dataset deduplicated by id.
  4. Separate recruiting agencies via category. Only 3 of the 20 python vacancies were under IT; Банки held 4 and HR held 3 (including agency postings for technical roles).
  5. Handle missing detail pages gracefully. If a vacancy disappears before its detail request or fails to load, the Actor keeps the list fields and sets the 18 detail fields to null.

Troubleshooting

  • Zero rows, status SUCCEEDED. The API returned totalCount: 0 with no errors, or post-fetch filters dropped every candidate. The status message shows the rejection tally; loosen city, salary bounds or includeKeywords.
  • Zero rows, status FAILED. Either maxItems or maxPages was below 1, minSalary exceeded maxSalary, or every list page was blocked or returned an unexpected shape. If blocked, try setting proxyConfiguration to Apify Proxy's RESIDENTIAL group.
  • Fewer rows than expected. Check the log via client.log(run.id).get(). It ends with Skipped N: reason counts and Pushed N row(s)..

robota.ua's terms prohibit unlawful collection of personal data and interference with site operation; verify that your use complies with those terms and applicable data-protection law.

Next steps

For current pricing and details, see the Store page.

Save your input as an Apify task and attach a schedule to monitor new vacancies automatically. Datasets export as JSON, CSV, Excel, XML, HTML table, RSS or JSONL (dataset docs), and webhooks deliver the finished run object including defaultDatasetId to your endpoint (webhook docs).

What roles or cities are you tracking on robota.ua? Drop a comment! 💬

About the author: reventadata builds and publishes data extraction Actors on the Apify Store.