# NHS Jobs Scraper (`scrapyx/nhs-jobs-scraper`) Actor

Job adverts from NHS Jobs (jobs.nhs.uk), the UK's public health job board: title, employer, postcode, salary with period, posted and closing dates, contract, working pattern - optionally the full advert with duties, person spec and contact. Filter by keyword, place, band, contract, staff group.

- **URL**: https://apify.com/scrapyx/nhs-jobs-scraper.md
- **Developed by:** [Ibnu Adzim](https://apify.com/scrapyx) (community)
- **Categories:** Jobs
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.84 / 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.
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

## NHS Jobs Scraper

Job adverts from **NHS Jobs** (jobs.nhs.uk), the UK's public-sector health
job board — ~12,800 live adverts from NHS trusts, GP practices and partner
organisations: title, employer, town and postcode, salary parsed with its
period, date posted, closing date, contract type and working pattern. With
`includeDetails`, the full advert too: summary, main duties, description,
person specification (essential and desirable, by category), pay scheme,
employer address and website, contact name / phone / email, DBS requirement.

HTTP only, no login, no key, no browser. 10 jobs per page; every page of
every search is reachable.

### What it is for

- **Vacancy monitoring** by role, place and radius, on a schedule.
- **Salary and band research** — every row has the salary as shown *and*
  parsed, with the period, so hourly and annual are never mixed.
- **Employer tracking** — filter by employer, keep the rows.

### Input

| field | what it does |
| --- | --- |
| `searchTerms` | `nurse`, `pharmacist`, `data analyst`. Empty = every advert. |
| `locations` + `distanceMiles` | Town, city or postcode, with a 5–100 mile radius. |
| `contractTypes`, `workingPatterns`, `payBands`, `staffGroups`, `salaryFrom/To`, `employer`, `sortBy` | Filters, each verified against the rows it returns. |
| `includeDetails` | One extra request per job for the full advert. |
| `maxItems`, `maxConcurrency`, `minRequestInterval`, `proxyConfiguration` | Limits. |

Targets are `searchTerms` × `locations`; each gets its own `SEARCH_SUMMARY`.

### Six things about this site worth knowing before you trust a run

#### 1. Three different "nothing" pages

A nonsense keyword answers "No result found for …" with zero cards. A
place the site cannot resolve answers a 19 KB page with an **empty heading**
and no count — not "no results", a page it could not build. And between
7:00 and 9:30 UK time the whole site answers a 228 KB **planned-downtime
page with HTTP 200**. The Actor tells them apart: `no_matches`,
`location_not_recognised`, and `site_maintenance` (with the notice text) —
never a parser error, never a retry.

#### 2. Past the last page is empty; far past it is a 500

Page 46 of a 45-page search is a clean empty page. Page 9,999 is an HTTP 500.
The Actor computes the last page from the site's own "N jobs found" and
never asks beyond it.

#### 3. Multi-value filters are comma-joined; the natural encoding is a 500

`contractType=Bank,Locum` works. `contractType=Bank&contractType=Locum`
answers HTTP 500, as does `sort=zz`, and an empty `distance=` turns a
search into the unknown-location page. Every enum is validated here first,
with the site's vocabulary in the error, and nothing empty is ever sent.

#### 4. `distanceMiles` narrows a place, not a region

Nurse jobs within 1 / 5 / 10 / 20 / 50 / 100 miles of Manchester: 12 / 75 /
170 / 248 / 1,080 / 2,172. Within 5 or 100 miles of **London**: 450 either
way — London resolves to a region, and a region has no radius. The summary
carries the distance it sent so a flat count is visible.

#### 5. Salary is free text in five shapes

"£31,049 to £37,796 a year", "£12.71 an hour", "£397 a day", "£62.50 a
session", "Depends on experience". Rows carry `salaryText` as shown plus
`salaryMin`, `salaryMax`, `salaryPeriod` (`year` / `hour` / `day` /
`session`) and `salaryDependsOnExperience`. Hourly rates are **not**
annualised.

#### 6. The advert HTML is malformed and duplicated

The description sits in a `<p>` nested inside a `<p>`, so an id lookup
returns an empty string; and every address line appears twice (mobile and
desktop copies). The detail parser slices the raw HTML by its headings and
takes each heading's first occurrence.

### Output

- **`JOB`** — `jobReference`, `url`, `title`, `employer`, `location`, `town`,
  `postcode`, `salaryText`, `salaryMin`, `salaryMax`, `salaryPeriod`,
  `salaryCurrency`, `salaryDependsOnExperience`, `datePosted`,
  `closingDate`, `contractType`, `workingPatterns`, `query`,
  `locationSearched`, `resultPosition`, `pageFound`; with details:
  `summary`, `mainDuties`, `aboutEmployer`, `description`,
  `personSpecification`, `payScheme`, `jobLocationsText`,
  `employerAddress`, `employerWebsite`, `contactName`, `contactJobTitle`,
  `contactEmail`, `contactPhone`, `dbsCheckRequired`.
- **`SEARCH_SUMMARY`** — one per target: `totalResults`, `jobsReturned`,
  `pagesFetched`, `stoppedReason`, `distanceMiles`, `filters`,
  `detailsFetched`, `detailsFailed`, `rowsWithSalary`,
  `rowsDependsOnExperience`, `contractTypeCounts`, `salaryPeriodCounts`.
- **`ERROR`** — `invalid_input`, `site_maintenance`, `page_shape_changed`,
  `upstream_error`, `fetch_failed`, with detail.

### Known limits

- `salaryFrom`/`salaryTo` are applied by the site with its own conversion of
  hourly and daily rates; the rows show what it returned.
- Welsh-language adverts are served in English by default; the Welsh text is
  not fetched.
- The planned-downtime window (07:00–09:30 UK) returns no data; schedule
  runs outside it.

# Actor input Schema

## `searchTerms` (type: `array`):

Job title or keyword - 'nurse', 'pharmacist', 'data analyst'. Each term is its own target (combined with each location). Empty = every live advert (~12,800).

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

A town, city or postcode - 'Manchester', 'Leeds', 'SW1A 1AA'. Combined with each search term. A place the site cannot resolve ends that target with location\_not\_recognised. 'London' resolves to a region and ignores distanceMiles.

## `distanceMiles` (type: `integer`):

Radius around each location: 5, 10, 20, 30, 50 or 100 (the values the site offers; anything else is refused). Needs at least one location.

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

Any of: Permanent, Fixed-Term, Apprenticeship, Voluntary, Locum, Training, Secondment, Honorary, Bank, NHS Reservist. Empty = any.

## `workingPatterns` (type: `array`):

Any of: full-time, part-time, job-share, flexible-working, remote-working, compressed-hours, term-time-hours, annualised-hours. Empty = any.

## `payBands` (type: `array`):

Agenda for Change bands and medical grades: BAND\_2 ... BAND\_9, BAND\_8A-8D, SPECIALTY\_DOCTOR, CONSULTANT, SALARIED\_GP, DOCTOR\_IN\_TRAINING, OTHER. Empty = any.

## `staffGroups` (type: `array`):

Any of: NURSING\_AND\_MIDWIFERY, MEDICAL\_AND\_DENTAL, ALLIED\_HEALTH\_PROF, ADMINISTRATIVE\_AND\_CLERICAL, CLINICAL\_SERVICES, PROF\_SCIENTIFIC\_AND\_TECHNICAL, HEALTHCARE\_SCIENTISTS, ESTATES\_AND\_ANCILLARY, STUDENTS. Empty = any.

## `salaryFrom` (type: `integer`):

Lowest annual salary. The site applies its own conversion for hourly and daily rates.

## `salaryTo` (type: `integer`):

Highest annual salary.

## `employer` (type: `string`):

Employer name, partial match - 'Barts Health', 'Guy's and St Thomas'.

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

Listing order. Only these values are accepted; the site answers anything else with an HTTP 500.

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

Off by default. On: one extra request per job for the summary, main duties, full description, person specification (essential/desirable by category), pay scheme, employer address and website, contact name/phone/email, DBS requirement.

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

Overall cap on JOB rows across every target. 10 jobs per page; every page of every search is reachable.

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

Parallel requests across targets and advert pages.

## `minRequestInterval` (type: `integer`):

Politeness delay between request starts. This is a public service; 0.5 s is the default because it should be, not because the site pushed back.

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

Optional. No anti-bot layer was observed. Enable Apify's free datacenter proxy only if a cloud run reports fetch\_failed.

## Actor input object example

```json
{
  "searchTerms": [
    "nurse"
  ],
  "locations": [
    "Manchester"
  ],
  "sortBy": "bestMatch",
  "includeDetails": false,
  "maxItems": 100,
  "maxConcurrency": 2,
  "minRequestInterval": 1,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per scraped record. See the dataset's default view for field definitions.

# 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 = {
    "searchTerms": [
        "nurse"
    ],
    "locations": [
        "Manchester"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapyx/nhs-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 = {
    "searchTerms": ["nurse"],
    "locations": ["Manchester"],
}

# Run the Actor and wait for it to finish
run = client.actor("scrapyx/nhs-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 '{
  "searchTerms": [
    "nurse"
  ],
  "locations": [
    "Manchester"
  ]
}' |
apify call scrapyx/nhs-jobs-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapyx/nhs-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/a8fE9kCj88WxdNnKg/builds/ITEdqmi70yjhk6hQo/openapi.json
