# France Hiring Signals (`komelf/hellowork-hiring-leads`) Actor

Turn public HelloWork job listings into company-level hiring signals. Rank French companies by recent hiring activity, matching roles, locations, contracts, salary disclosure, and remote-work signals.

- **URL**: https://apify.com/komelf/hellowork-hiring-leads.md
- **Developed by:** [Flavian COMBES](https://apify.com/komelf) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $5.00 / 1,000 results

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/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

## France Hiring Signals — HelloWork

Turn French job postings into company-level hiring signals.

### 1. What it does

Find companies with observable hiring activity around your topic without sorting through repeated advertisements. This Python Apify Actor deduplicates job postings, groups them by posting company, and returns an explainable hiring score from 0 to 100.

This Actor uses publicly accessible HelloWork SEO job pages and does not use HelloWork's disallowed dynamic search routes.

This Actor measures observed hiring activity, not company growth, financial health, purchasing intent, or commercial qualification.

### 2. What you get

One dataset row per company: active relevant job counts, recent posting counts, role families, locations, contracts, salary disclosure, remote options, evidence URLs, seven score components, reasons and warnings. Structured `roles` make each signal auditable. Export JSON or CSV for your research workflows.

Example interpretation: “8 relevant active job postings were observed.” Public hiring activity may be useful as a commercial research signal; it does not establish that a company needs your services.

### 3. Who is this for

Recruitment agencies, B2B sales teams, IT consultancies, cybersecurity consultancies, training providers, HR software vendors and market researchers. Automation workflows, developers and AI agents can consume the company dataset and its source evidence.

### 4. How it works

1. Supply supported keywords and a location, or explicit SEO URLs.
2. Check current robots.txt and validate every URL and redirect before fetching it.
3. Parse public HTML, identify exact/related cards, deduplicate across sources.
4. Optionally read each unique public job detail once for validity, date and description match evidence.
5. Apply filters, aggregate by company, score, sort, then limit company results.

Current SEO pagination uses a GET form with `p`; query URLs are excluded. Only the accessible first page is collected, with a warning. Coverage depends on public SEO pages; there is no promise of exhaustive national coverage.

### 5. Example input

```json
{
  "start_urls": [
    "https://www.hellowork.com/fr-fr/emploi/mot-cle_cybersecurite.html",
    "https://www.hellowork.com/fr-fr/emploi/metier_devops.html"
  ],
  "keywords": ["cybersecurite", "cloud", "devops"],
  "posted_within_days": 14,
  "max_jobs": 40,
  "max_companies": 20,
  "min_jobs_per_company": 1,
  "include_related_results": false,
  "include_expired_jobs": false,
  "include_anonymous_companies": false,
  "exclude_recruitment_agencies": false,
  "collect_job_details": true
}
```

Discovery example: `{"keywords": ["cybersecurite"], "location": "Lille"}`. Supported combinations are listed in `my_actor/seo_catalog.json`. Accents and case are normalized; English aliases are not translated. Unsupported combinations produce warnings and are skipped; if none remain the run fails explicitly.

In URL mode, `start_urls` overrides discovery and `location` is ignored. Optional `keywords` provides textual match terms; otherwise the topic is derived from each SEO slug. Here `matched_search_keywords` means source-associated topics, not evidence of a dynamic query. `source_observations` records the actual source URLs and classifications.

### 6. Example output

Illustrative excerpt, not a real company. Full live rows are saved in `examples/live-companies.json`.

```json
{
  "company": "Example SAS",
  "company_normalized": "example sas",
  "posting_company": "Example SAS",
  "possible_intermediary": null,
  "hiring_score": 71.37,
  "hiring_level": "high",
  "matching_open_jobs": 4,
  "historical_job_count": 0,
  "unique_role_families": 3,
  "recent_jobs_3d": 2,
  "recent_jobs_7d": 3,
  "recent_jobs_14d": 4,
  "score_breakdown": {
    "volume": 22,
    "freshness": 20.62,
    "role_diversity": 9,
    "locations": 4,
    "permanent_contracts": 7.5,
    "keyword_depth": 4.5,
    "offer_transparency": 3.75
  },
  "reasons": ["4 relevant active job postings were observed"],
  "warnings": ["Pagination unavailable under current robots-compliant crawling policy."],
  "source": "HelloWork"
}
```

### 7. Pricing

**$0.005 per company result = $5 / 1,000 company signals.**

Each returned dataset item is billed as one company signal.

A minimal Actor start event is also configured at **$0.00005**.

Platform usage is included in the Store price.

### 8. Input reference

| Field | Default | Meaning / bounds |
| --- | --- | --- |
| `keywords` | — | Required in discovery mode: 1–10 trimmed strings, ≤200 characters, case-insensitive deduplication |
| `start_urls` | `[]` | Up to 20 HTTPS HelloWork SEO URLs; no query strings or fragments |
| `location` | France | Supported catalog location; no geocoding |
| `posted_within_days` | 14 | Integer 1–90, inclusive cutoff |
| `contract_types` | `[]` | Optional normalized filter, up to 20 strings |
| `max_jobs` | 250 | Integer 1–500, unique discovered jobs before filters/details |
| `max_companies` | 100 | Integer 1–500, after scoring/sorting |
| `min_jobs_per_company` | 1 | Positive integer, minimum retained active relevant jobs |
| `include_related_results` | false | Related jobs still require title/description topic evidence |
| `include_expired_jobs` | false | Retain relevant expired jobs as history, never score them |
| `include_anonymous_companies` | false | Separate identity for each anonymous job |
| `exclude_recruitment_agencies` | false | Exclude explicitly identified possible intermediaries |
| `collect_job_details` | true | False sends no detail requests |

Unknown or lower-bound ages cannot verify the date window and are excluded from active counts. Expired history can be older than the window; its company still needs enough recent active jobs. `max_jobs` bounds discovery, not surviving output. Keyword order matters when the cap is reached; later-source evidence may be incomplete.

### 9. Output reference

| Fields | Meaning |
| --- | --- |
| `company`, `company_normalized`, `posting_company` | Observed publisher and normalized identity; no invented client |
| `possible_intermediary` | True for explicit evidence; otherwise null, not assumed false |
| `hiring_score`, `hiring_level` | Score 0–100 and low / medium / high / very\_high |
| `matching_open_jobs`, `historical_job_count` | Retained active relevant jobs; retained expired jobs |
| `unique_role_families` | Distinct normalized title signatures |
| `recent_jobs_3d`, `recent_jobs_7d`, `recent_jobs_14d` | Inclusive known-age active-job counts |
| `locations`, `contract_types` | Structured raw locations and normalized active-job contracts |
| `has_remote_option`, `remote_modes` | True if any explicit option; false only if all explicitly say no; otherwise null |
| `salary_disclosed` | At least one active job discloses salary |
| `matched_search_keywords`, `title_keyword_matches`, `description_keyword_matches` | Source topics versus textual evidence, including retained history |
| `score_breakdown`, `reasons`, `warnings` | Components, observed facts and caveats |
| `roles`, `job_urls` | Structured job evidence and direct public URLs |
| `source`, `collected_at` | HelloWork and UTC collection timestamp |
| `freshest_posting_age_days` | Best known active age, used for tie-breaking |

Roles retain URL/ID, title, company, source match type, per-source provenance, matches, raw/parsed age, salary, contract, location, remote and activity evidence. Salary has `raw`, `min`, `max`, `currency`, `period`, without annualization. Full descriptions and candidate profiles are not stored. Activity evidence distinguishes listing presence, JSON-LD validity, expiry notices and 404/410. With details enabled, unknown validity is excluded.

The default key-value store's `RUN_REPORT` records raw cards, unique jobs, retained jobs, final companies, pages, HTTP requests and warnings, including empty runs.

### 10. Scoring methodology

Only retained active relevant jobs enter the score. Let `n` be their count. Components and their sum are rounded to two decimals with Python `round`; no missing points are redistributed.

| Component | Exact formula | Max |
| --- | --- | --- |
| Volume | 1→6, 2→12, 3→18, 4→22, 5→25, 6–7→28, 8+→30 | 30 |
| Freshness | `25 × sum(weight) / n`; ≤3d=1, ≤7d=0.8, ≤14d=0.5, older/unknown=0 | 25 |
| Role diversity | `min(15, 3 × distinct_role_signatures)` | 15 |
| Locations | Distinct nonempty normalized raw locations: 0–1→0, 2→4, 3→7, 4+→10 | 10 |
| Permanent contracts | `10 × CDI_count / n`; only CDI | 10 |
| Keyword depth | `5 × mean(depth)`; title=1, else description=0.6, else exact SEO only=0.2, else=0 | 5 |
| Transparency | `2.5 × (salary_disclosed_count + remote_disclosed_count) / n`; explicit no-remote counts as disclosure | 5 |

Thresholds: ≥80 very\_high, ≥60 high, ≥35 medium, otherwise low. Sort by score descending, active count descending, best known age ascending, normalized company name ascending.

Role signatures remove accents, punctuation, H/F and seniority tokens junior, senior, jr, sr, confirmé(e), expert, expérimenté(e), then sort remaining tokens. Senior Java Developer and Java Developer Senior coincide. C++, C# and .NET remain distinct. This is a deterministic title signature, not a semantic occupation taxonomy.

### 11. Responsible use

Only unauthenticated public SEO and direct job pages are fetched. Current robots rules and a stricter path allowlist are checked before requests and redirects. No dynamic search, query strings, internal API, candidate accounts, applications, candidate personal data, user cookies, proxy rotation or CAPTCHA bypass. A descriptive User-Agent is used. No LLM or external enrichment database.

Company normalization uses trim, case, accents, HTML entities and whitespace. Only explicitly separated decorative recruitment suffixes are removed. Orange and Orange Business are different. Anonymous identities are `anonymous:<job_id>`. Intermediaries require explicit organization wording or explicit recruitment-for-client wording; no brand list is used. A possible intermediary's client and internal hiring are never inferred.

### 12. Limitations

Public SEO availability and first-page ordering determine coverage. Displayed totals may include related results. Cached listings, unknown age/validity, incomplete fields and anonymous publishers can reduce the dataset. Weeks/months use 7/30-day displayed-age approximations; “plus de 1 mois” remains a lower bound, never an exact age. Valid detail `datePosted` takes precedence.

No fuzzy employer merging, synonym translation or geocoding. Company aliases, role synonyms and location variants can remain separate. Intermediaries can be missed when explicit evidence is absent. With details disabled, activity relies on listing presence and is disclosed as such.

Natural expired offers may not be available during live investigation; tests use elapsed validThrough on a real fixture plus synthetic expiry notices and 404 responses. The Actor has also been built and run successfully on Apify Cloud. Public SEO coverage and source markup can still change over time.

### 13. Technical details

Python 3.12, HTTPX, BeautifulSoup's standard HTML parser, Protego robots handling, and the Apify SDK. Verified TLS uses the system trust store.

Detail enrichment uses bounded concurrency with up to 4 in-flight requests and global pacing of roughly 2–3 request starts per second. Requests use a 25-second timeout, up to 3 attempts, exponential backoff, and bounded Retry-After handling. Persistent 403/429/5xx responses and timeouts fail explicitly.

No browser is needed: job cards and public JobPosting JSON-LD are server-rendered.

Selectors: `[data-cy=serpCard]`, `[data-cy=offerTitle] h3 > p`, `[data-cy=localisationCard]`, `[data-cy=contractCard]`, `.tag-secondary-s`, age footer. The “Résultats proches” DOM marker changes all following cards to related. Direct numeric offer paths provide canonical URLs/IDs; details require matching canonical and JobPosting JSON-LD.

Dedup uses canonical URL, then HelloWork ID, then strict company/title/location/contract tuple. Missing critical structure raises `ParserRegressionError`, including positive/unknown expected counts with zero parsed cards; explicit zero is legitimate. Missing noncritical fields become null with warnings. No pagination form is submitted; visited sets, source caps and unique-job caps bound work and prevent loops.

Modules separate URL policy, HTTP, parsing, normalization, deduplication, discovery, input validation, filtering, aggregation, scoring and Apify integration. See `my_actor/`, `.actor/`, `tests/` and `fixtures/manifest.json`.

```bash
python -m venv .venv
## Activate the virtual environment for your shell.
python -m pip install -r requirements-dev.txt
python -m pytest -q
python -m compileall -q my_actor scripts tests
python scripts/live_validation.py
## Save input at storage/key_value_stores/default/INPUT.json, then:
python -m my_actor
```

Offline tests use minimized real HTML and synthetic failure cases; live tests are separate. See `investigation/SEO_REPORT.md` for results and `investigation/REPORT.md` for the superseded dynamic-search stop.

Apify references: [Actor definition](https://docs.apify.com/actors/development/actor-definition/actor-json), [input](https://docs.apify.com/actors/development/actor-definition/input-schema/specification/v1), [dataset](https://docs.apify.com/storage/dataset-schema), [output](https://docs.apify.com/actors/development/actor-definition/output-schema).

# Actor input Schema

## `keywords` (type: `array`):

Required in discovery mode (1–10). Trimmed and case-insensitive deduplicated. Optional textual match terms in URL mode.

## `start_urls` (type: `array`):

Optional. Takes priority. HTTPS HelloWork /fr-fr/emploi/ SEO URLs, no query or fragment.

## `location` (type: `string`):

Supported catalog location only; ignored in explicit URL mode.

## `contract_types` (type: `array`):

Optional normalized contracts: CDI, CDD, Alternance, Stage, Intérim, Freelance, Fonctionnaire, Other, Unknown.

## `posted_within_days` (type: `integer`):

See README for filtering and bounds.

## `max_jobs` (type: `integer`):

See README for filtering and bounds.

## `max_companies` (type: `integer`):

See README for filtering and bounds.

## `min_jobs_per_company` (type: `integer`):

See README for filtering and bounds.

## `include_related_results` (type: `boolean`):

See README for evidence and exclusion rules.

## `include_expired_jobs` (type: `boolean`):

See README for evidence and exclusion rules.

## `include_anonymous_companies` (type: `boolean`):

See README for evidence and exclusion rules.

## `exclude_recruitment_agencies` (type: `boolean`):

See README for evidence and exclusion rules.

## `collect_job_details` (type: `boolean`):

See README for evidence and exclusion rules.

## Actor input object example

```json
{
  "keywords": [
    "cybersecurite",
    "devops"
  ],
  "posted_within_days": 14,
  "max_jobs": 250,
  "max_companies": 100,
  "min_jobs_per_company": 1,
  "include_related_results": false,
  "include_expired_jobs": false,
  "include_anonymous_companies": false,
  "exclude_recruitment_agencies": false,
  "collect_job_details": true
}
```

# Actor output Schema

## `companies` (type: `string`):

No description

## `report` (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 = {
    "keywords": [
        "cybersecurite",
        "devops"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("komelf/hellowork-hiring-leads").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 = { "keywords": [
        "cybersecurite",
        "devops",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("komelf/hellowork-hiring-leads").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 '{
  "keywords": [
    "cybersecurite",
    "devops"
  ]
}' |
apify call komelf/hellowork-hiring-leads --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,komelf/hellowork-hiring-leads"
        }
    }
}
```

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/NmnGmcqzFAglQdBcc/builds/FMkhSfoJrd8EPv3lo/openapi.json
