# Workana Projects & Client Demand Scraper (LATAM) (`oswaldocarabano/workana-scraper`) Actor

Scrape public project listings from Workana, Latin America's largest freelance marketplace: budget, skills, client country, proposal count and client reputation signals. Only filters that Workana actually applies. Declares its own coverage ceiling. No login, no session cookies.

- **URL**: https://apify.com/oswaldocarabano/workana-scraper.md
- **Developed by:** [Oswaldo Carabano](https://apify.com/oswaldocarabano) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Workana Projects & Client Demand Scraper (LATAM)

Scrape public project listings from **Workana**, Latin America's largest freelance
marketplace. Budgets parsed into numbers, client country, proposal counts and client
reputation signals — plus a coverage report that tells you what the run could **not**
reach.

No login. No session cookies. Nothing that expires.

***

### Two things this actor does that the alternatives do not

#### 1. Every filter it offers actually filters

Workana accepts a lot of URL parameters with `HTTP 200` and then **ignores them**. We
tested each one and found **thirteen** that look like filters and do nothing:
`budget_min`, `budget`, `is_hourly`, `project_type`, `duration`, `published`, `date`,
`urgent`, `payment_verified`, `max_bids`, `featured`, `hourly`, `budget_from`.

Offering any of those would promise a filter that does not exist — you would pay for
rows you did not ask for. So this actor offers **only the five that Workana really
applies**: category, subcategory, skill, country and free-text search.

`language` is not offered either, and that one is worth explaining: it does not
narrow results, it **widens** them. Measured on the live site:

| Query | Projects declared |
|---|---|
| `country=BR` | 2,826 |
| `country=BR` + `language=pt` | **10,000** (Workana's cap) |

#### 2. It tells you what it could not reach

Workana serves **7 projects per page and stops at page 50**. That is a limit of the
site, not of any scraper: **about 350 projects per query is the ceiling for
everyone.** Its own result counter is also capped at 10,000, so for large queries even
Workana will not tell you how much there is.

This actor does two things about it:

- **Splits large queries automatically** along the country axis, which we measured to
  be almost purely additive (4% overlap, 6.8× more unique projects across 6
  countries).
- **Writes a coverage report** — one entry per query, in the key-value store under
  `COVERAGE` — with what was declared, what was delivered, whether the page-50 ceiling
  was hit, and how many projects are out of reach. If a run cannot give you
  everything, you will know from the output, not by guessing.

***

### Output

One row per project in the default dataset, **54 fields**. The coverage report and
any failed requests go to the key-value store, under `COVERAGE` and `ERRORS`, so no
export ever mixes row types.

If a run has no failures, no `ERRORS` record is created at all — an empty error file
would suggest something went wrong when nothing did.

```json
{
  "slug": "integracion-de-crm-con-meta-para-atribucion-de-conversiones",
  "url": "https://www.workana.com/es/job/integracion-de-crm-con-meta-...",
  "title": "Integración de CRM con Meta para Atribución de Conversiones",
  "budget_raw": "USD 100 - 250",
  "budget_min": 100,
  "budget_max": 250,
  "budget_currency": "USD",
  "budget_unit": "project",
  "budget_type": "range",
  "is_hourly": false,
  "client_name_masked": "Plaga D.",
  "client_country_code": "AR",
  "client_country_name": "Argentina",
  "client_rating": 5,
  "client_has_rating": true,
  "client_payment_verified": true,
  "total_bids": 30,
  "total_bids_raw": "Propuestas: 30",
  "posted_at": "2026-08-18T19:30:31.906Z",
  "posted_at_raw": "Hace 14 horas",
  "posted_age_hours": 14,
  "skills": ["API", "CRM", "Facebook", "PHP", "Python", "JavaScript"],
  "skill_slugs": ["api", "crm", "facebook", "php", "python", "javascript"],
  "category": "it-programming",
  "category_name": "Programación y Tecnología",
  "subcategory": "web-development",
  "subcategory_name": "Programación Web",
  "project_scope": "Crear un nuevo sitio personalizado",
  "project_size": "Medio",
  "from_cache": false,
  "data_age_hours": 0
}
```

#### The posting form's answers, extracted

Workana appends the client's answers to its posting form at the end of every
description — and they are structured data hiding inside prose. This actor pulls them
out into their own fields instead of leaving you to parse them:

| Field | Present in |
|---|---|
| `category`, `category_name` | **100%** |
| `subcategory`, `subcategory_name` | **100%** |
| `project_scope` — "Create a new custom site", "Small bug or change"… | 68% |
| `project_size` — Small / Medium / Large | 30.5% |
| `client_needs`, `engagement_type`, `required_availability`, `time_required`, `client_currently_has`, `required_platforms`, `word_count_raw` | varies by subcategory |
| `extra_attributes` | the subcategory-specific tail, with its original label |

Two things worth knowing:

- **`category` is always filled**, even when you query by subcategory or by skill. The
  parent-child relationship comes from Workana's own taxonomy, not from a guess.
- **`extra_attributes` is by design, not a gap.** Workana asks questions specific to
  each subcategory — "Logo type", "App type", "Payment processor" — and that tail is
  endless. The cross-cutting questions get their own columns; the rest travels in
  `extra_attributes` with its original label, so nothing is lost and the schema does
  not grow columns that are almost always empty.
- **`description_text` no longer repeats that block.** The metadata comes out as
  fields, so leaving it in the text too would just inflate the largest field in the
  dataset. `description_html` keeps Workana's original, untouched.

#### Every parsed field keeps its original

`budget_min: 100` comes with `budget_raw: "USD 100 - 250"`. `total_bids: 30` comes
with `total_bids_raw: "Propuestas: 30"`. The parsed number is our interpretation; the
raw string is Workana's data, and you can always check our work.

This matters more than it sounds. Workana writes its budgets in the language you
request, and **the thousands separator changes with it**: `"USD 1,000"` in English is
one thousand, `"USD 1.000"` in Spanish is also one thousand. A parser that ignores
the language turns one of them into `1` — a three-orders-of-magnitude error in the
field people filter on most. Every row carries its `locale` so you can verify.

#### `budget_type` — why a missing number is not a missing value

A quarter of Workana budgets have no lower bound at all: the client posted
`"Less than USD 50"`, not a range. Reporting `budget_min: null` there and calling it
a coverage gap would be wrong — there is no minimum to report. And some projects
carry no figure whatsoever (`"Open"`), which is a different thing again.

So every row says which case it is:

| `budget_type` | Meaning | `min` | `max` | Measured (n=272) |
|---|---|---|---|---|
| `range` | `"USD 100 - 250"` | ✅ | ✅ | 70.6% |
| `max_only` | `"Less than USD 50"` — no lower bound exists | `null` | ✅ | 25.0% |
| `min_only` | `"Over USD 3,000"` — no upper bound exists | ✅ | `null` | 4.4% |
| `fixed` | a single figure | ✅ | ✅ (same) | rare |
| `unspecified` | Workana shows no figure at all (`"Open"`) | `null` | `null` | rare |
| `null` | the listing did not carry the field | `null` | `null` | not seen |

**Sort or filter on `budget_type` before you compare numbers.** Averaging `budget_min`
across a mixed set silently drops the quarter of projects that never had one, which
biases the average upward. And `unit` matters too: some budgets are `"USD 15 - 45 /
hour"`, so `budget_unit` is `hour` there, not `project`.

The distinction is decided by structure, not vocabulary: a budget string with no
digits is `unspecified` in any language. We only ever observed the English wording
(`"Open"`), so guessing at Spanish and Portuguese labels would have been inventing
data we had not measured.

***

### Field coverage — measured, not estimated

Measured on **718 projects across 12 subcategories and 8 countries**. We publish how
often a field carries **useful** information, not how often the key exists:

| Field | Useful in |
|---|---|
| `slug`, `title`, `description`, `budget_raw`, `budget_type`, `client_country_*`, `total_bids`, `posted_at`, `client_name_masked` | **100%** |
| `budget_max` | 95.6% |
| **`budget_min`** | **75.0%** — and that is not a gap, see below |
| `skills`, `skill_slugs` | 98.6% |
| `last_client_message_raw` | 51.7% |
| **`client_rating`** | **19.4%** — see below |
| `client_payment_verified` (true) | 18.9% |
| `is_hourly` (true) | 11.7% |
| `deadline_days` | 1.9% |
| `client_plan` | 0.4% |

#### ⚠️ About `client_rating`

Workana returns a rating for every project, but **80.5% of them are `0.00`, which
means "no ratings yet"** — not "rated zero". That is why every row also carries
`client_has_rating`. If you filter on rating without checking that flag you will
discard four fifths of the market believing you discarded bad clients, when you
discarded clients with no history.

We could have published "client rating: 100% coverage". It would have been true and
misleading.

#### Fields we do not return

`isUrgent`, `isCompany` and `isInvite` were `false` in **all 718** projects — an
anonymous visitor never sees them set. A column with one constant value is not data,
so they are not in the output. `shortDescription` was empty in all 718.

***

### Input

| Option | Notes |
|---|---|
| `category` | One of the 8 top-level categories. Leave empty to sweep all of them |
| `subcategory` | e.g. `web-development`, `mobile-development`, `logo-design` |
| `skill` | e.g. `python`, `react-js`, `laravel`. **One skill applies** — Workana keeps the first value and silently drops the rest |
| `country` | Client country, ISO-2: `AR`, `BR`, `MX`, `ES`, `CO`, `PE`… |
| `query` | Free-text search. The only axis without a fixed vocabulary |
| `maxItems` | Hard cap. Default 500 |
| `locale` | `es`, `en` or `pt` — changes the language of Workana's own text |
| `maxCacheAgeHours` | Default 6. Set to 0 to force fresh data |
| `maxConcurrency` | Default 3, and that number is measured (see below) |

#### Slugs are checked before the run starts

A wrong skill slug returns **zero projects with HTTP 200** on Workana: `react` gives
nothing, `react-js` gives hundreds. That is the worst kind of failure — an empty run
that looks like a correct one. This actor checks the slug first and tells you what is
wrong instead of charging you for nothing.

***

### Freshness

Proposal counts move fast. Measured across 401 projects: the median goes from about
**1 proposal in the first hours to about 10 by the twelfth**. If you are using
`total_bids` to judge competition, a stale row is a wrong row.

So the default cache age is **6 hours**, and every row declares `from_cache`,
`fetched_at` and `data_age_hours`. Set `maxCacheAgeHours: 0` for live data. You
always know how old a row is.

***

### Speed and politeness

`maxConcurrency` defaults to **3**, and that is a measurement: 180 requests at
concurrency 1, 2 and 3 completed with **zero** failures, while concurrency 5 produced
**5% HTTP 503** responses from Workana's own servers. Raising it makes runs less
reliable, not faster — which is why the maximum is 5 and not 20.

***

### Billing

Pay per event, and two rules:

- **Failed requests are never charged.** If a request fails, you get an entry under
  `ERRORS` in the key-value store explaining what happened, free.
- **Charges happen right after a row is delivered**, never in a batch at the end. If a
  run is aborted halfway you pay for what you received and nothing more.

***

### What this actor does not do

- It does not log in, use session cookies, or need credentials.
- It does not solve captchas.
- It does not scrape freelancer profiles. Project listings arrive already anonymised
  by Workana; a freelancer profile is an identified person with their rate and
  history. That is a different product with a different risk, and it is not in here.
- It does not promise a coverage percentage. Workana's own result counter is capped,
  so the true size of the market is unknown — and a percentage of an unknown is a
  made-up number. You get what was delivered, what was declared, and where the
  ceiling was hit.

# Actor input Schema

## `category` (type: `string`):

One of Workana's 8 top-level categories. Leave empty to sweep all of them.

## `subcategory` (type: `string`):

Narrower than a category — 61 to choose from, grouped by their parent. This is the axis that narrows a large category the most.

## `skill` (type: `string`):

A single skill slug as it appears in a Workana URL, e.g. "python", "react-js", "laravel". This is a free-text field on purpose: Workana keeps adding skills, so any fixed list here would go stale. The actor checks the slug before the run starts — a wrong one returns zero projects with HTTP 200 on Workana ("react" gives nothing, "react-js" gives hundreds), and an empty run that looks correct is the worst kind of failure. You can find valid slugs in the skill\_slugs field of any row. Note that only one skill applies: Workana keeps the first value and drops the rest.

## `country` (type: `string`):

Where the client is based. The most active countries are listed first.

## `query` (type: `string`):

Free-text search over the project listings. The only axis without a fixed vocabulary, so it is the most flexible way to narrow a large category.

## `maxItems` (type: `integer`):

Hard cap on projects delivered. Note the site's own limit: Workana serves 7 projects per page and stops at page 50, so a single query reaches at most ~350 projects. To go beyond that the actor splits the query by country automatically, and every run reports what it could and could not reach under COVERAGE in the key-value store.

## `locale` (type: `string`):

Which language Workana renders its text fields in. This changes the wording of budget, dates and proposal counts, and the actor parses each one accordingly — including the thousands separator, which is a dot in Spanish and a comma in English.

## `maxCacheAgeHours` (type: `integer`):

Upper bound on how old a cached page may be before it is refetched. Set to 0 to force fresh data on every page. The cache is shared across everyone who runs this Actor and lives outside Apify, so a page someone already fetched within the window is served without touching Workana again: fewer requests, faster runs, and less load on the site. You never have to take our word for which case you are in — every row carries from\_cache, fetched\_at and data\_age\_hours, and the run log reports the hit and miss counts. The default of 6 hours is measured rather than guessed: the median project goes from about 1 proposal in its first hours to about 10 by the twelfth, so anything staler than that would understate the competition you face.

## `includeCoverageReport` (type: `boolean`):

Write one entry per query under COVERAGE in the key-value store, stating how many projects Workana declared, how many were delivered and whether the site's page-50 ceiling was reached. Keep this on to know exactly what a run did and did not cover.

## `maxConcurrency` (type: `integer`):

Parallel requests. The default of 3 is measured, not guessed: 180 requests at concurrency 1, 2 and 3 completed with zero failures, while concurrency 5 produced 5% HTTP 503 responses from Workana's own servers. Raising it makes runs less reliable, not faster.

## Actor input object example

```json
{
  "skill": "react-js",
  "query": "scraping",
  "maxItems": 500,
  "locale": "es",
  "maxCacheAgeHours": 6,
  "includeCoverageReport": true,
  "maxConcurrency": 3
}
```

# Actor output Schema

## `dataset` (type: `string`):

One row per Workana project, with budget parsed into min/max plus the original string, client country, proposal count and client reputation signals.

## `coverage` (type: `string`):

One entry per query the planner ran: how many projects Workana declared, how many were delivered, and whether the site's page-50 ceiling was reached.

## `errors` (type: `string`):

One entry per request that could not be completed. These are never charged.

## `summary` (type: `string`):

Totals for the run: projects delivered, duplicates skipped, requests made, and the measured compression ratio.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "maxItems": 500,
    "locale": "es",
    "maxCacheAgeHours": 6,
    "includeCoverageReport": true,
    "maxConcurrency": 3
};

// Run the Actor and wait for it to finish
const run = await client.actor("oswaldocarabano/workana-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "maxItems": 500,
    "locale": "es",
    "maxCacheAgeHours": 6,
    "includeCoverageReport": True,
    "maxConcurrency": 3,
}

# Run the Actor and wait for it to finish
run = client.actor("oswaldocarabano/workana-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "maxItems": 500,
  "locale": "es",
  "maxCacheAgeHours": 6,
  "includeCoverageReport": true,
  "maxConcurrency": 3
}' |
apify call oswaldocarabano/workana-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,oswaldocarabano/workana-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/rETZ4M3KPzEdNqu7B/builds/b6s8xC6oOK0OQumKb/openapi.json
