# APEC Jobs Scraper (apec.fr) (`nice_dev/apec-jobs-scraper`) Actor

Scrape apec.fr, France's executive & manager job board, by keywords, place and filters or from search URLs: title, company, place, salary, contract, full advert text, skills, apply link.

- **URL**: https://apify.com/nice\_dev/apec-jobs-scraper.md
- **Developed by:** [Nice Dev](https://apify.com/nice_dev) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.64 / 1,000 job offers

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

### 💼 What is APEC Jobs Scraper?

**APEC Jobs Scraper** extracts **job offers from [apec.fr](https://www.apec.fr)**, France's national job board for executives, managers and engineers (about 100 000 live offers): **title, company and employer type, place and distance, salary, contract type, remote-work policy, sector, the full advert text, required skills, the apply link, and whether the offer is new or re-posted**.

Type your **keywords** (`data engineer`, `chef de projet`), optionally **places** (`Lyon`, `75`, `Ile-de-France`, `Allemagne`) or a **radius around a city**, pick filters, or paste any apec.fr search URL, click **Start**, and download the offers in JSON, CSV or Excel. No login needed, and it is **fast (100 offers with full text in a few minutes, 1,000 without it in seconds) and cheap ($0.70 per 1,000 offers)**. Schedule it with **Only new offers** to receive, at each run, only what you have not seen yet.

### 📋 What data can you extract from apec.fr?

One item per offer, 77 fields:

| Category | What you get |
| --- | --- |
| 🏷️ **Offer** | title, link, APEC reference number — `Data Manager Gouvernance Data F/H` |
| 🏢 **Employer** | company name and registered name, logo, employer type (direct employer, recruitment agency, staffing agency, IT services, job board), confidential or not — `Cabinet de recrutement` |
| 📍 **Place** | city, department, region, country, map coordinates, distance from the centre of a radius search — `Paris 01 - 75` |
| 💰 **Salary** | the salary as written, minimum and maximum in euros a year, negotiable or not — `45 - 50 k€ brut annuel` |
| 📄 **Contract** | contract type and length, remote work, part time, number of positions, position status — `CDI`, `Partiel possible` |
| 📝 **Full advert** | the whole job description, the candidate profile and the company presentation, in plain text and in HTML, plus the short teaser of the results page |
| 🧠 **Skills** | each required skill with its kind (know-how, soft skill, language) and level — `Anglais · Courant` |
| 🧭 **Job and sector** | experience required, job function and family, APEC job role, business travel, sector, NAF activity code — `Minimum 5 ans`, `Chief Data Officer` |
| ✉️ **How to apply** | the apply link and how applications are received |
| 🕒 **Dates** | the date apec.fr shows, the first publication (the real age), re-posted or not, validation and last edit |
| 🔎 **Origin and flags** | where the advert comes from (APEC recruiter back-office, website, feed, resold by another job board), few applicants, quality checked, search score, which search found it |

Every decoded label comes with its raw APEC id, in the same item: you read `Cabinet de recrutement` and you join on `employerTypeId`.

Every field, with an example, is listed in the **Output** section below.

Fields marked **detail** in the Output tab (`description`, `candidateProfile`, `companyDescription`, `skills`, `applyUrl`, `firstPublishedAt`, `isRepost`, `experienceLevel`, `jobFunction`, `jobRole`, `travelZone`…) are filled when **Include details** is on (default). Turn it off for a search-results-only scrape: one request per 100 offers instead of one per offer (1,000 offers in seconds instead of minutes); the description is then the 283-character snippet.

### ✅ Why use APEC Jobs Scraper?

- 📄 **The whole advert, not a teaser**: description, candidate profile and company presentation, plus the skills list and the apply link, at the same price as the search-results-only scrapers.
- 🆕 **Only new offers**: schedule the Actor, it remembers what it delivered and skips it (not charged). Re-posted offers are flagged (`isRepost`) and can be dropped (**Skip re-posted offers**): on 2026-09-17, 16 of the 30 newest offers of the board had first been published 1 to 77 days earlier.
- 🎯 **Type the place, not a code**: `Lyon`, `Paris 15`, `Rhône`, `75`, `Bretagne`, `Allemagne` are resolved against APEC's own list of regions, departments, countries and 36 000 communes; or search **within N km** of a city or of a point, with `distanceKm` on each offer.
- 🧭 **Every filter of the site**: contract type and duration, experience, education level, internship length, start date, remote work, business travel, employer type (direct employer vs recruitment agency vs job-board feeds), sectors, job functions and the 502 APEC job roles, salary band, posting age, geolocated only, sort order. Or paste an apec.fr search URL: its filters are used as is.
- 🧹 **Filters on the offers themselves**: publication date range, excluded words in the title, company name contains / excluded, no confidential employer, salary figure required, few applicants, posting origin. A filtered-out offer is **not charged**.
- 🏢 **Employer type on every row**: direct employer, recruitment agency, staffing agency, IT services or reselling job board.
- 🔁 **Several searches in one run** (keywords × places), each with its own cap, deduplicated on the offer number.
- 🔌 API, scheduling, monitoring, integrations (Make, Zapier, n8n, Google Sheets…) and JSON/CSV/Excel export via the Apify platform.

### 🚀 How to scrape apec.fr

1. Create a free Apify account.
2. Open **APEC Jobs Scraper** and enter one or more **Keywords** (leave empty for the whole board, newest first), optionally **Locations** (one search per place) and filters (contract types, remote work, experience, employer types, sectors, job functions or roles, salary band, posting age…).
3. Or paste your own apec.fr URLs into **Start URLs**: search-results pages (their filters are kept) or single offers.
4. Set **Max offers** (default 100; `0` = no limit) and, with several searches, **Max offers per search**. Keep **Include details** on for the full text, skills, apply link and re-post flag.
5. Click **Start** and download the dataset in JSON, CSV, Excel or via API.

### 💰 How much does it cost to scrape apec.fr?

This Actor uses **pay per event** pricing: **$0.70 per 1,000 offers**, full details included — plus **$0.001 per run start** (10 cents per 100 runs). Higher Apify plans pay less per offer:

| Apify plan | Price per 1,000 offers |
| --- | --- |
| Free | **$0.70** |
| Bronze | $0.68 |
| Silver | $0.66 |
| Gold | $0.64 |

Platform usage (compute, proxy) is included in the price.

- Every `data` offer on the board (≈ 8,000) ≈ **$5.60**.
- A daily monitor of 200 new offers ≈ **$0.14** a day.
- The $5 of free credits every Apify account starts with already cover about **7,000 offers**.

Offers filtered out or already delivered (**Only new offers**) are not charged.

### ⚙️ Input

```json
{
    "searchQueries": ["data engineer", "data scientist"],
    "locations": ["Ile-de-France", "Lyon"],
    "maxItemsPerQuery": 100,
    "contractTypes": ["CDI"],
    "remoteWork": ["PARTIAL", "FULL"],
    "employerTypes": ["COMPANY", "IT_SERVICES"],
    "salaryMin": 45,
    "postedWithin": "7",
    "maxItems": 500,
    "includeDetails": true
}
```

Monitoring: only the offers not delivered before, re-posts dropped:

```json
{
    "searchQueries": ["contrôleur de gestion"],
    "locations": ["Rhône"],
    "onlyNew": true,
    "stateKey": "controle-gestion-rhone",
    "skipReposts": true,
    "maxItems": 0
}
```

Or with your own URLs:

```json
{
    "startUrls": [
        { "url": "https://www.apec.fr/candidat/recherche-emploi.html/emploi?motsCles=data&lieux=75&typesContrat=101888" },
        { "url": "https://www.apec.fr/candidat/recherche-emploi.html/emploi/detail-offre/179239722W" }
    ],
    "maxItems": 500
}
```

| Field | Notes |
| --- | --- |
| `searchQueries`, `locations` | Keywords (one search per entry) × places (a department number or name, a region, a city, `France` or a country). Up to 500 searches per run; an offer found by several searches is saved once. |
| `startUrls` | Your own apec.fr URLs: search-results pages (filters kept, pagination automatic) or single offer pages. When set, keywords, places and the radius fields are ignored. |
| `maxItems`, `maxItemsPerQuery` | Stop after this many offers for the whole run, and for EACH search. `0` = no cap. |
| `contractTypes`, `contractDurationBand`, `internshipDuration` | APEC's own contract filters: permanent, fixed-term, temp, work-study, internship; the length of a fixed-term contract or of an internship. |
| `experienceLevels`, `educationLevel`, `positionStatus`, `startDateWithin` | Years of experience, degree level, status of the position (cadre, agent de maîtrise…), how soon it starts. |
| `remoteWork`, `travelZones`, `onlyGeolocated` | Remote-work policy, how far the job travels, and whether APEC has placed the offer on a map. |
| `sectors`, `jobFunctions`, `jobRoles` | APEC's 29 sectors, 56 job functions and 502 métiers. A métier is given by its exact name or its id; offers of any selected function OR métier are kept. |
| `employerTypes`, `tagEmployerType`, `postingOrigins` | Who posts the offer (company, recruitment or staffing agency, IT services, resold by another job board), and where the advert comes from. Tagging runs each search once per employer type so that every offer carries one. |
| `salaryMin`, `salaryMax`, `requireSalary` | APEC's own salary filter, in thousands of euros a year; an offer is kept when its published range overlaps yours. Offers with no figure are kept unless you ask for one. |
| `radiusKm`, `latitude`, `longitude` | Search within N km of each city, or of a point of your own. Every offer then carries `distanceKm`. |
| `postedAfter`, `postedBefore`, `postedWithin`, `sortBy` | A publication-date range of your own (`2026-09-01`, or a period before now: `7 days`, `2 weeks`, `1 month`, `24 hours`), APEC's own "posted within" filter, and the order of results. |
| `excludeKeywords`, `companyNameContains`, `excludeCompanies`, `excludeConfidential`, `onlyLowApplicantCount`, `skipReposts` | Filters applied on the offers themselves: drop a title that contains one of these words (case and accents ignored), keep or drop a company, drop confidential adverts, keep the ones with few applicants, drop re-posts. Nothing they drop is charged. |
| `includeDetails` | Open each offer for the full advert text, skills, apply link, first publication date and re-post flag (default on). |
| `onlyNew`, `stateKey`, `resetState` | Monitoring: only the offers never delivered under this memory key, down to the oldest one the last run delivered for each search; with `skipReposts`, the re-posts it dropped are remembered too (a re-post stays one). `resetState` forgets the memory. |
| `excludeEmptyFields` | Drop null and empty values from every offer: a compact JSON, smaller exports. |
| Advanced | `proxyConfiguration` (Apify proxy by default, included in the price; your own proxies serve the search pages, the offer pages always go through the included proxy; the residential proxy is not available), `maxConcurrency`, `maxRequestsPerMinute`, `maxRequestRetries`, `debugLog`. |

### 📦 Output

```json
{
    "id": 179239722,
    "offerNumber": "179239722W",
    "url": "https://www.apec.fr/candidat/recherche-emploi.html/emploi/detail-offre/179239722W",
    "source": "apec.fr",
    "title": "Data Manager Gouvernance Data F/H",
    "company": "DGTL Performance",
    "isConfidential": false,
    "employerTypeId": 143685,
    "employerType": "Cabinet de recrutement",
    "locationText": "Paris 01 - 75",
    "city": "Paris 01",
    "departmentCode": "75",
    "department": "Paris",
    "region": "Ile-de-France",
    "country": "France",
    "latitude": 48.8566,
    "longitude": 2.3522,
    "isGeolocated": true,
    "distanceKm": null,
    "salaryText": "45 - 50 k€ brut annuel",
    "salaryMin": 45000,
    "salaryMax": 50000,
    "salaryCurrency": "EUR",
    "salaryPeriod": "YEAR",
    "salaryBasis": "GROSS",
    "salaryIsNegotiable": false,
    "contractTypeId": 101888,
    "contractType": "CDI",
    "contractDurationMonths": null,
    "remoteWorkId": 20765,
    "remoteWork": "Partiel possible",
    "sectorId": 101762,
    "sector": "Conseil et gestion des entreprises",
    "nafCodeId": 101606,
    "nafCode": "7022Z",
    "nafLabel": "CONSEIL POUR LES AFFAIRES ET AUTRES CONSEILS DE GESTION",
    "postingOriginCode": 101866,
    "postingOrigin": "ADEP",
    "isAggregatorListing": false,
    "isDirectClient": true,
    "isQualityChecked": true,
    "lowApplicantCount": false,
    "relevanceScore": 44.07,
    "logoUrl": null,
    "publishedAt": "2026-09-10T03:38:05.000Z",
    "validatedAt": "2026-09-10T03:38:05.000Z",
    "descriptionSnippet": "Rejoignez un acteur majeur du secteur des transports en commun pour piloter la montée en maturité de sa plateforme Data…",
    "description": "Rejoignez un acteur majeur du secteur des transports en commun pour piloter la montée en maturité de sa plateforme Data. Vous structurerez et industrialiserez les pratiques de gouvernance…",
    "descriptionHtml": "<p>Rejoignez un acteur majeur…</p>",
    "candidateProfile": "Indispensables :\n- Plus de 6 ans d'expérience directe en Gouvernance Data…",
    "companyDescription": "DGTL / Signe + est le facilitateur pour tous les acteurs qui recherchent des ressources ou des missions DATA…",
    "skills": [
        { "label": "Gouvernance des données", "type": "SAVOIR_FAIRE", "level": "Confirmé" },
        { "label": "Anglais", "type": "LANGUE", "level": "Courant" }
    ],
    "applyUrl": "https://www.jobposting.pro/emploi-2665246-113#postuler",
    "applicationMethod": "URL_ONLY",
    "firstPublishedAt": "2026-08-06T17:02:38.000Z",
    "isRepost": true,
    "updatedAt": "2026-09-10T03:38:05.000Z",
    "experienceLevelId": 597155,
    "experienceLevel": "Minimum 5 ans",
    "jobFunctionId": 101813,
    "jobFunction": "Système, réseaux, données",
    "jobFunctionFamily": "Informatique",
    "jobRoleId": 600070,
    "jobRole": "Chief Data Officer",
    "positionTypeId": 101900,
    "positionType": "Cadre du secteur privé",
    "travelZoneId": 596720,
    "travelZone": "Pas de déplacement",
    "numberOfPositions": 1,
    "isPartTime": false,
    "jobReference": "2665246",
    "companyLegalName": "DGTL PERFORMANCE",
    "companyId": 803909,
    "detailFetched": true,
    "searchKeyword": "data",
    "searchUrl": "https://www.apec.fr/candidat/recherche-emploi.html/emploi?motsCles=data&typesConvention=143685&sortsType=DATE&sortsDirection=DESCENDING",
    "scrapedAt": "2026-09-17T08:10:00.000Z"
}
```

You can download the dataset in various formats such as JSON, HTML, CSV or Excel.

#### All 77 fields

| Fields | What you get |
| --- | --- |
| `id`, `offerNumber`, `url`, `source`, `title` | `179239722`, `179239722W`, `https://www.apec.fr/candidat/recherche-emploi.html/emploi/detail-offre/179239722W`, `apec.fr`, `Data Manager Gouvernance Data F/H` |
| `company`, `isConfidential`, `logoUrl`, `employerType` | `DGTL Performance`, `false`, logo URL, `Cabinet de recrutement` (with **Tag each offer with its employer type**) |
| `postingOrigin`, `isAggregatorListing` | `ADEP` (APEC recruiter back-office), `SITE_WEB_APEC`, `IMPORT_XML`, `AGREGATION` = resold by another job board → `true` |
| `locationText`, `city`, `departmentCode`, `department`, `region`, `country`, `latitude`, `longitude`, `distanceKm` | `Paris 01 - 75`, `Paris 01`, `75`, `Paris`, `Ile-de-France`, `France`, `48.86`, `2.34`, `7.4` (radius searches) |
| `salaryText`, `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryPeriod`, `salaryBasis`, `salaryIsNegotiable` | `45 - 50 k€ brut annuel`, `45000`, `50000`, `EUR`, `YEAR`, `GROSS`, `false` (`true` for "A négocier") |
| `contractType`, `contractDurationMonths`, `remoteWork` | `CDI`, `null`, `Partiel possible` |
| `sector`, `nafCode`, `nafLabel` | `Conseil et gestion des entreprises`, `7022Z`, `CONSEIL POUR LES AFFAIRES…` |
| `publishedAt`, `firstPublishedAt`, `isRepost`, `validatedAt`, `updatedAt` | ISO 8601 — date shown by apec.fr, **first publication (the real age)**, `true` when the offer was pushed back to the top, validation, last edit |
| `descriptionSnippet` | the 283-character teaser shown in search results |
| `description`, `descriptionHtml`, `candidateProfile`, `companyDescription` | the full advert in three parts (plain text), and its HTML as apec.fr stores it — not sanitized: clean it before inserting it into a web page |
| `skills` | `[{ "label": "Python", "type": "SAVOIR_FAIRE", "level": "Confirmé" }, …]` (know-how, soft skills, languages) |
| `applyUrl`, `applicationMethod` | external application link, `URL_ONLY` / `EMAIL_URL` |
| `experienceLevel`, `jobFunction`, `jobFunctionFamily`, `jobRole`, `positionType`, `travelZone`, `numberOfPositions`, `isPartTime`, `jobReference` | `Minimum 5 ans`, `Système, réseaux, données`, `Informatique`, `Chief Data Officer`, `Cadre du secteur privé`, `Nationale`, `1`, `false`, `2665246` |
| `lowApplicantCount`, `isDirectClient`, `isQualityChecked`, `relevanceScore` | APEC's own flags and search score |
| `searchKeyword`, `searchUrl`, `scrapedAt` | which search found the offer, the equivalent apec.fr page, ISO timestamp |
| `companyId`, `companyLegalName`, `isGeolocated`, `detailFetched` | APEC's own company id and registered name, whether APEC placed the offer on a map, and whether the full advert could be read |
| `employerTypeId`, `contractTypeId`, `remoteWorkId`, `sectorId`, `nafCodeId`, `postingOriginCode`, `experienceLevelId`, `jobFunctionId`, `jobRoleId`, `positionTypeId`, `travelZoneId` | the raw APEC id of each decoded label, side by side with it: join on stable codes |

### 💡 Tips

#### How to get more results

Leave `maxItems` at `0` and keep the search broad (no keywords, a whole region rather than a single city) — APEC exposes every matching offer, there is no result cap. Add `employerTypes` only when you want to narrow the board (e.g. direct employers).

#### How to reduce costs

The price is per offer saved: `maxItems`, `maxItemsPerQuery`, **Only new offers** and the filters on the offers are the levers (nothing filtered out is charged). Filters of the site (contract, place, salary…) are cheaper still: the offers are never downloaded. Turning `includeDetails` off makes runs faster but does not change the price.

#### Several searches in one run

`searchQueries` × `locations` = one search each (4 above), max 500 per run. **Max offers per search** caps each of them, so that the first one does not use the whole **Max offers** budget; an offer found by two searches is saved once and counts for the first.

#### Monitoring: only the new offers

Tick **Only new offers** (`onlyNew`), give each schedule its own **Memory key** (`stateKey`) and schedule the Actor daily. The first run returns everything; the next ones return only the offers no previous run with the same **Memory key** delivered, down to the oldest one the last run delivered for each search — the others are skipped before their details are opened and are **not charged**. The memory lives in the named key-value store `apec-jobs-scraper-seen` of your account (up to 150 000 offers per key). Give each schedule its own key; **Reset the memory** starts over. A re-posted offer keeps its number: it is not delivered twice. Keep the date sort, and add **Skip re-posted offers** if a re-post should not count as new. A run that finds nothing new ends green with "No new or matching offers" after a few search pages.

#### Filter by publication date

`postedAfter` / `postedBefore` take a date (`2026-09-01`, the whole day in Paris time) or a period before now (`7 days`, `2 weeks`, `1 month`: same time of day in Paris; `24 hours` through the API). The date is `publishedAt`, the one apec.fr shows and sorts by: a re-post moves it to the day the offer came back to the top, so add **Skip re-posted offers** for truly new offers. With the default date sort, the crawl stops at the first page older than `postedAfter`.

#### Radius search

`"locations": ["Villeurbanne"], "radiusKm": 15` = offers within 15 km of Villeurbanne, like "à moins de 15 km" on apec.fr (the site adds the offers of the city itself). Or `"radiusKm": 20, "latitude": 45.764, "longitude": 4.8357` around any point. Departments, regions and countries cannot be the centre of a radius.

#### Start URLs

Paste apec.fr search URLs (`https://www.apec.fr/candidat/recherche-emploi.html/emploi?motsCles=data&lieux=75&typesContrat=101888`): their filters are kept (keywords, places, contract type and duration, experience, education level, internship length, start date, job function, sector, employer type, position status, remote work, business travel, geolocated only, salary bounds or band, posting age, sort, and a `distance` around one city) and the other input filters fill what the URL does not set. Single offer URLs (`…/detail-offre/179239722W`) are scraped directly. The `page` of a pasted search URL is not read: the search starts at its first page, and the same search pasted twice (another page, a tracking key) is one search. An offer URL pasted alone has no search-result card: `validatedAt`, `descriptionSnippet` and `isDirectClient` are `null`, every other field is the same as when a search finds it.

### 🔌 Integrations and API

Call the Actor via the Apify API, the JavaScript or Python clients, or connect it with integrations and webhooks (Make, Zapier, n8n, Google Sheets, Slack, Airtable…). The dataset can be fetched as JSON, CSV or Excel from any tool, with only the fields you need (`fields`, `omit` options of the dataset export). Tick **Drop empty fields** (`excludeEmptyFields`) for a compact JSON without null or empty values — the export's `skipEmpty` drops empty offers, not empty fields.

### ❓ FAQ

#### Is it legal to scrape apec.fr?

The Actor only reads what apec.fr shows publicly to any anonymous visitor: job advertisements. It logs in to nothing and solves no captcha. Job adverts can contain personal data (a named contact in the text), which is protected by GDPR: do not store it without a legitimate reason — recruiter names and e-mails are never output. You are responsible for using the data in compliance with apec.fr's Terms of Use and applicable law. This Actor is not affiliated with, endorsed by or connected to the APEC.

#### Does it need a login or a proxy?

No login. The proxy is included in the price: leave the default setting (the residential proxy is not available). Your own proxies, if you give some, serve the search pages: the offer pages always go through the included proxy. A request apec.fr turns away is retried at once on a new proxy session (without a proxy, after a pause of 5 seconds, doubled at each retry up to 300 seconds).&#x20;

#### Is the data safe to open in Excel or to show on a web page?

Titles and advert texts are the recruiters' own words, copied as they are. A text can begin with `-`, `+`, `=` or `@` (a title such as `-20% de turnover`, a reference such as `=REF2026`): Excel and Google Sheets may read such a cell of a CSV file as a formula or as a number. The Actor leaves the text as it is, so that the JSON and the API give the real value. **For Excel, download the Excel (XLSX) export**: its cells are written as text, so a leading `-` or `=` is shown as written (measured: `=1+1` stays `=1+1`); the CSV export is raw, so if you open a CSV, import these columns as text. `descriptionHtml` is the advert's HTML exactly as apec.fr stores it, and the Actor does not sanitize it: escape it, or clean it with a sanitizer, before putting it on a web page — as you would with any text written by a stranger.

#### Good to know

- About **40 % of the board are "Partenaire" offers** (`postingOrigin: AGREGATION`, `isAggregatorListing: true`): adverts resold by other job boards (cadremploi, Meteojob, Direct Emploi…), where `company` is often the board's name. Use `employerTypes`, `postingOrigins` or `excludeCompanies` to keep them out.
- `employerType` is filled when the search is restricted to one type: **Tag each offer with its employer type** runs each search once per type (apec.fr has no such field per offer; the 5 types split the board exactly), a few more search pages, and **Max offers per search** then applies to each type.
- A radius around a city needs its coordinates: apec.fr's own place autocomplete gives them (1 light request per city; Paris, Lyon and Marseille are built in). A city apec.fr cannot place fails that search with a clear message — use `latitude` / `longitude` then.
- `salaryMin` / `salaryMax` filters are APEC's own: an offer is kept when its published range overlaps yours; offers without a figure ("A négocier") are always kept — add **Only offers with a salary figure** to drop them.
- APEC re-publishes offers regularly: `publishedAt` moves, `firstPublishedAt` does not, `isRepost` tells them apart (`null` without details).
- **Only new offers**: a date-sorted search is read down to the oldest offer the last run delivered for it (same memory key), no further — an older offer never comes: one that run left out (its cap), or one apec.fr publishes late under an older date. **Reset the memory**, or a new key, reads deeper. The search also stops after 500 already-delivered offers in a row, or at the run's cap (`maxItems` / `maxItemsPerQuery`) of them: a previous run under the same cap never delivered more in a row. A delivered offer re-posted since the previous run (back on top with a newer date) does not count and lowers that cap by one. Two runs at the same time on the same memory key may deliver an offer twice.
- Date-sorted crawls of thousands of offers can see a few offers twice (new offers are inserted at the top while the crawl runs): they are deduplicated on `offerNumber`. `sortBy: "ID"` is steadier for very deep crawls.
- Recruiter names and e-mails are **never** output: only what the public offer page shows.
- An offer URL pasted next to a search that also finds it is saved once, counted in that search.
- If a detail cannot be fetched after every retry (a rare refusal, a network error), the offer is still saved from its search-result card with `detailFetched: false`, and its URL is listed in the `FAILED_REQUESTS` record of the key-value store. A run that saves nothing while requests failed ends as **failed**, never as an empty success. If apec.fr refuses the full adverts one after the other (20 in a row — or **Max offers** when it is lower, 8 at least —, more refused than read), the run stops and fails instead of charging rows without their details.

**A run the platform stops without warning** (out of memory, run timeout)

- Resurrect it: it goes on from where it stood at most a minute before the stop. What it had read since is read again, and the offers already saved are skipped: none is delivered or charged twice, and `maxItems` still counts them.
- With **Only new offers**, the memory is saved once a minute: resurrect the stopped run and the offers it had saved meanwhile join the memory; leave it stopped for good, and the next run may return up to a minute of them once more.

#### Something doesn't work?

The last line of the log counts the offers saved, filtered out, already delivered and no longer on apec.fr (an offer URL pasted alone that was removed), and the requests that failed after every retry. Those requests and the removed offers are listed, with the reason, in the `FAILED_REQUESTS` record of the run's key-value store (a search page under its apec.fr URL). A run that saved nothing and had failed requests fails, and its last message gives the cause (a search apec.fr rejects says so, instead of "run it again"). A run that saved some offers fails too when at least as many requests failed for good as were read (page 1 read, the next pages or the offer pages blocked): a green run with a short dataset would hide the outage. One failed request among many is only a warning.

If apec.fr changes its pages, you are told instead of paying for blank rows. A search page that counts offers but gives none the Actor can read is an error (listed in `FAILED_REQUESTS`), never a quiet "No offers found". If the first 20 offers read all lack their title, publication date, salary text, contract type, posting origin, company (confidential offers aside), place, sector or — with `includeDetails` — description or first publication date, the run saves nothing more, stops and fails, and its last message names the missing field: at most those first offers are charged. An offer that `postedAfter` / `postedBefore` drops because it has no date at all counts among those 20.&#x20;

### 🛟 Support

Open an issue in the **Issues** tab with a link to your run: the log and the `FAILED_REQUESTS` record of the key-value store show which requests failed and why.

# Actor input Schema

## `startUrls` (type: `array`):

apec.fr search URLs (`https://www.apec.fr/candidat/recherche-emploi.html/emploi?motsCles=data&lieux=75`, pagination is automatic) or single offer URLs (`https://www.apec.fr/candidat/recherche-emploi.html/emploi/detail-offre/179239722W`). Max 1 000 URLs. The site's filters in the URL are kept: keywords, places, contract type and duration, experience, education level, internship length, start date, job function, sector, employer type, position status, remote work, business travel, geolocated only, salary (bounds or band), publication date, sort, and a `distance` around ONE city (like the site, the radius is ignored with several places). When not empty, **Keywords**, **Locations** and the radius fields are ignored; the other filters below fill what a URL does not set.

## `searchQueries` (type: `array`):

Free-text searches, one search per entry (`data engineer`, `chef de projet`, `comptable`), times each location below (max 500 searches per run). Offers found by several searches are saved once. Leave empty to crawl the whole board (≈ 100 000 live offers, newest first).

## `locations` (type: `array`):

Places, resolved against APEC's own list: a department number or name (`75`, `Rhône`), a region (`Ile-de-France`, `Bretagne`), a city (`Lyon`, `Toulouse`, `Paris 15`), `France`, or a country (`Allemagne`). One search per place, so that **Max offers per search** applies to each. Empty = everywhere (France + international).

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

Maximum number of offers to save (after deduplication across searches). 0 = no limit. For reference: `data` ≈ 8 000 offers, `commercial` ≈ 28 000, whole board ≈ 100 000.

## `maxItemsPerQuery` (type: `integer`):

Cap for EACH search (keyword × location, × employer type when tagged, or search URL), so that the first search cannot use up the whole **Max offers** budget. 0 = no per-search cap.

## `contractTypes` (type: `array`):

Empty = all contract types.

## `postedWithin` (type: `string`):

Only offers published (or re-published) in the last N days. `1` + a daily schedule = monitoring of new offers.

## `sortBy` (type: `string`):

Order of results. New offers are inserted at the top of a date-sorted crawl while it runs (duplicates are removed); `ID` is steadier for crawls of thousands of offers.

## `includeDetails` (type: `boolean`):

Open each offer (1 extra request per offer) for the full advert text (description, candidate profile, company presentation), skills, apply link, experience level, job function, first publication date and number of positions. Off = search-result fields only (the description is then a 283-character snippet): one request per 100 offers instead of one per offer, so 1 000 offers take seconds instead of minutes.

## `experienceLevels` (type: `array`):

Experience required by the offer.

## `remoteWork` (type: `array`):

Remote-work policy stated in the offer.

## `employerTypes` (type: `array`):

Who posts the offer. `Partenaire` offers come from other job boards: the company name is then the board's (e.g. cadremploi), not the employer's.

## `tagEmployerType` (type: `boolean`):

Fills `employerType` on every offer (Entreprise, Cabinet de recrutement, Agence d'emploi, SSII / ESN, Partenaire). apec.fr gives no such field per offer, so each search is run once per employer type (the 5 types split the board exactly): a few more search pages, and **Max offers per search** applies to each type. Off: `employerType` is filled only when **Employer types** holds a single type.

## `sectors` (type: `array`):

Industry sector of the employer (APEC's 29 sectors).

## `jobFunctions` (type: `array`):

Job family (10) or sub-function (46), APEC's own classification.

## `jobRoles` (type: `array`):

APEC's 502 métiers, by name (`Data engineer`, `Contrôleur de gestion`, case and accents ignored) or id. Narrower than **Job functions**; offers of any selected function OR role are kept. Each offer's métier is in `jobRole` (with details).

## `positionStatus` (type: `array`):

Status of the position.

## `salaryMin` (type: `integer`):

Gross annual salary floor in thousands of euros, e.g. `45`. APEC keeps every offer whose published range overlaps \[min, max]; offers with 'A négocier' (no figure) are kept too.

## `salaryMax` (type: `integer`):

Gross annual salary ceiling in thousands of euros, e.g. `70`.

## `contractDurationBand` (type: `string`):

Length of fixed-term contracts (CDD, interim…). One band only: apec.fr accepts a single value.

## `educationLevel` (type: `string`):

Degree asked for. apec.fr fills it on internships and work-study offers (half of the internships), rarely elsewhere.

## `internshipDuration` (type: `string`):

Internships only (combine with contract type `Stage`).

## `startDateWithin` (type: `string`):

When the job starts.

## `travelZones` (type: `array`):

Travel the job requires (`travelZone` in the output, with details).

## `onlyGeolocated` (type: `boolean`):

Keep only the offers apec.fr can place on a map (`isGeolocated`, `latitude`, `longitude` filled: ≈ 97 % of the board).

## `radiusKm` (type: `integer`):

0 = off. With cities in **Locations**: a radius around each city (apec.fr adds the offers of the city itself; departments, regions and countries are refused). With **Latitude** / **Longitude**: around that point, **Locations** left empty. Each offer gets `distanceKm`.

## `latitude` (type: `number`):

Centre of the radius, e.g. `45.764` (Lyon). Set together with **Longitude** and **Radius (km)**.

## `longitude` (type: `number`):

Centre of the radius, e.g. `4.8357` (Lyon).

## `postedAfter` (type: `string`):

Only offers whose publication date (`publishedAt`: the date apec.fr shows, which a re-post moves to the day the offer came back to the top) is on or after this date: `2026-09-01`, or a period before now such as `7 days`, `2 weeks`, `1 month` (API: `24 hours` and full ISO date-times work too). With the date sort, the crawl stops at the first page older than that. Add **Skip re-posted offers** for truly new offers.

## `postedBefore` (type: `string`):

Only offers published on or before this date (the whole day is included), or older than a period such as `30 days`.

## `excludeKeywords` (type: `array`):

Drop the offers whose title contains one of these words (case and accents ignored), e.g. `stage`, `alternance`, `senior`.

## `companyNameContains` (type: `array`):

Keep only the offers whose company name contains one of these texts (case and accents ignored). Confidential offers have no company name and are dropped.

## `excludeCompanies` (type: `array`):

Drop the offers whose company name contains one of these texts, e.g. `cadremploi`, `Meteojob` (job boards reselling offers).

## `excludeConfidential` (type: `boolean`):

Drop the offers whose employer is hidden (`isConfidential`).

## `onlyLowApplicantCount` (type: `boolean`):

Keep only the offers apec.fr flags as having received few applications (`lowApplicantCount`).

## `requireSalary` (type: `boolean`):

Drop the 'A négocier' offers: keep only those with an amount (`salaryMin` or `salaryMax`), about 2 offers in 3.

## `postingOrigins` (type: `array`):

How the offer reached apec.fr (`postingOrigin`). Empty = all. Leave `AGREGATION` out to skip the offers resold by other job boards (≈ 40 % of the board).

## `skipReposts` (type: `boolean`):

Drop the offers whose first publication is older than the date apec.fr shows (`isRepost`): offers pushed back to the top, often weeks old (16 of the 30 newest offers of the board on 2026-09-17). Needs **Include details** (the first publication date comes with the details); a dropped offer is not charged.

## `onlyNew` (type: `boolean`):

Skip the offers that a previous run (same **Memory key**) already delivered, re-posted ones included: they are not saved and not charged, and their details are not even opened. A date-sorted search is read down to the oldest offer the last run delivered for it, no further — an older offer never comes: one that run left out (its cap), or one apec.fr publishes late under an older date; **Reset the memory** reads deeper. First run = everything is new.

## `stateKey` (type: `string`):

Name of the memory used by **Only new offers**. Give each schedule / task its own key (e.g. `data-lyon`) so that they do not share their memory. Letters, digits, `-` and `_`.

## `resetState` (type: `boolean`):

Forget everything remembered under this **Memory key** before the run: this run returns (and charges) every offer again. Untick it afterwards.

## `excludeEmptyFields` (type: `boolean`):

Remove null values, empty texts and empty lists from every offer: a compact JSON, smaller exports (e.g. the 26 detail fields of a run without **Include details**). Off: every offer has all the fields (stable CSV columns). The dataset export cannot do this: its `skipEmpty` / `clean` options drop empty offers, not empty fields.

## `proxyConfiguration` (type: `object`):

Apify Proxy or your own proxies, for the search pages: the offer pages always go through the proxy included in the price, whatever you set here. Keep the default: it is included in the price. The residential Apify proxy is not available in this Actor.

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

Maximum number of requests processed in parallel. Keep 1: the pace is set by "Max requests per minute".

## `maxRequestsPerMinute` (type: `integer`):

Global request rate (search pages + offer details). Lower it if the log shows HTTP 429 / 403.

## `maxRequestRetries` (type: `integer`):

Retries per request before it is marked as failed.

## `debugLog` (type: `boolean`):

Include debug messages in the run log.

## Actor input object example

```json
{
  "searchQueries": [
    "data engineer"
  ],
  "locations": [],
  "maxItems": 100,
  "maxItemsPerQuery": 0,
  "contractTypes": [],
  "postedWithin": "ANY",
  "sortBy": "DATE",
  "includeDetails": true,
  "experienceLevels": [],
  "remoteWork": [],
  "employerTypes": [],
  "tagEmployerType": false,
  "sectors": [],
  "jobFunctions": [],
  "jobRoles": [],
  "positionStatus": [],
  "contractDurationBand": "ANY",
  "educationLevel": "ANY",
  "internshipDuration": "ANY",
  "startDateWithin": "ANY",
  "travelZones": [],
  "onlyGeolocated": false,
  "radiusKm": 0,
  "excludeKeywords": [],
  "companyNameContains": [],
  "excludeCompanies": [],
  "excludeConfidential": false,
  "onlyLowApplicantCount": false,
  "requireSalary": false,
  "postingOrigins": [],
  "skipReposts": false,
  "onlyNew": false,
  "stateKey": "default",
  "resetState": false,
  "excludeEmptyFields": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "maxConcurrency": 1,
  "maxRequestsPerMinute": 120,
  "maxRequestRetries": 5,
  "debugLog": false
}
```

# Actor output Schema

## `results` (type: `string`):

No description

# 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 = {
    "searchQueries": [
        "data engineer"
    ],
    "locations": [],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("nice_dev/apec-jobs-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 = {
    "searchQueries": ["data engineer"],
    "locations": [],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("nice_dev/apec-jobs-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 '{
  "searchQueries": [
    "data engineer"
  ],
  "locations": [],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call nice_dev/apec-jobs-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,nice_dev/apec-jobs-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/B1bJNSZ2j7BZkFbGu/builds/5d8Lut5Y2bd1kLbnC/openapi.json
