# PracticeMatch Physician & Healthcare Jobs Scraper (`piquno/practicematch-physician-jobs-scraper`) Actor

Scrape all 40,900+ physician, advanced-practitioner and healthcare jobs on PracticeMatch. Real salary ranges on about half the board, O\*NET specialty codes and mappable coordinates. HTTP-only, no browser, no proxy.

- **URL**: https://apify.com/piquno/practicematch-physician-jobs-scraper.md
- **Developed by:** [Piquno](https://apify.com/piquno) (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 $2.00 / 1,000 jobs

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?

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

## PracticeMatch Physician & Healthcare Jobs Scraper

**Every one of the 40,900+ physician, advanced-practitioner and healthcare jobs on PracticeMatch.com — with the salary range, which 10 of the 12 most recent postings published.**

Most job boards withhold pay. Physician recruiting does not. On a live run of the newest 12 postings, **10 carried a real pay range** — for example an Orthopedic Surgeon at $650,000–$750,000/year, Obstetrics & Gynecology at $439,296–$486,245/year, and a Hospitalist at $173–$213/hour. That is the point of this actor. Every posting also carries its **O\*NET SOC code**, so specialties join cleanly to BLS wage data instead of needing string matching.

HTTP-only. No browser, no proxy, no login.

***

### What you get

One flat row per posting — no nested objects or arrays, so CSV and Excel exports are clean.

| Field | What it is |
|---|---|
| `jobId`, `jobUrl`, `profession` | Stable id, canonical URL, and which of the three boards it came from |
| `title`, `description`, `descriptionHtml` | Posted title and the full posting, as text and as original HTML |
| `employerName`, `employerUrl` | Hiring organisation and its own website |
| `specialty`, `socCode`, `specialtySlug` | "Family Medicine Physicians", `29-1215.00`, and the URL slug |
| `salaryMin`, `salaryMax`, `salaryPeriod`, `salaryCurrency`, `salaryDisclosed` | The published range and whether there is one at all |
| `streetAddress`, `locationCity`, `locationState`, `locationPostalCode`, `locationCountry` | Full address |
| `latitude`, `longitude` | Coordinates where the posting carries them |
| `employmentType`, `directApply` | FULL\_TIME / PART\_TIME / etc, and whether it is a direct-apply listing |
| `datePosted`, `datePostedRaw`, `validThrough` | Normalised to UTC, plus the site's original string |
| `scrapedAt`, `fieldsPopulated` | When the row was taken, and how many of its fields are non-null |

#### Measured fill rates

Measured on two live 12-posting runs on 2026-08-23 — counted, not estimated. Salary disclosure depends heavily on how recent a posting is, so both ends are given:

| Field | Fill |
|---|---|
| `jobId`, `jobUrl`, `title`, `description` | 100% |
| `employerName`, `employerUrl`, `specialty`, `specialtySlug` | 100% |
| `locationCity`, `locationState`, `locationCountry`, `datePosted` | 100% |
| `socCode` | 92% |
| `locationPostalCode`, `employmentType` | 83% |
| `streetAddress`, `latitude`, `longitude` | 58% |
| **`salaryMin`, newest postings (the default)** | **83%** |
| `salaryMin`, oldest postings on the board | 42% |
| `salaryMax` | 33–75% — some postings publish a floor with no ceiling |
| `validThrough` | 0% — the site does not publish expiry dates |

Nothing is ever invented to fill a gap: a field the site did not publish stays `null`. A salary published as a bare minimum keeps `salaryMax` null rather than copying the floor into it.

***

### Coverage

The sitemap is the only complete index, and it is split across three shards. All figures below were counted, not estimated:

| Board | Postings | URL shape |
|---|---|---|
| Physicians | 32,528 | `/physicians/job-details.cfm/{id}/...` |
| Advanced practitioners | 8,376 | `/advanced-practitioners/job-details.cfm/{id}/...` |
| Healthcare | 546 | `/healthcare/jobs/job-details/{id}` |
| **Total** | **40,903 distinct job ids** | |

The site's paged browse pages cannot reach all of this, which is why seeding is done from the sitemap.

***

### Input

```json
{
  "professions": ["physician"],
  "specialtyContains": "Family Medicine",
  "locationState": "Texas",
  "withSalaryOnly": true,
  "maxJobs": 500
}
```

**Defaults:** `maxJobs` is **12** and `requestDelaySecs` is **5** — a quick sample that finishes in about 90 seconds. Raise `maxJobs` for a real extract and budget the time (see below).

`locationState` takes either `TX` or `Texas`. `startUrls` accepts PracticeMatch job-details URLs directly and overrides the board selection. `newestFirst` is on by default and walks the board from the most recently created postings backwards.

#### Speed, and the trade-off you should understand

**Read this before your first big run.** PracticeMatch publishes `Crawl-Delay: 5` in its robots.txt, and this actor honours it by default. Each job is one request, so:

| `requestDelaySecs` | Throughput | 1,000 jobs takes |
|---|---|---|
| 5 (default, what the site asks for) | ~720 jobs/hour | ~1h 25m |
| lower | faster | your call, and your blocking risk |

`requestDelaySecs` is adjustable. Lowering it departs from what the site asks for, and that is your decision rather than the default. A full 40,900-job extract at the default delay is a multi-day run — most buyers want a specialty or a state, not the whole board.

#### Monitoring the board

Set `incremental: true` with its own `stateKey`. Only postings not seen before are emitted, tagged `changeType: "new"`, so a daily watch bills for the handful that are actually new rather than re-billing everything.

***

### Filters are applied before billing

`titleContains`, `specialtyContains`, `locationState`, `employmentType`, `withSalaryOnly` and `postedWithinDays` all run **before** a row is charged. You are never billed for a row a filter excluded.

***

### Limitations — read before you buy

- **Salary disclosure tracks recency.** 10 of the newest 12 postings published a range; only 5 of the oldest 12 did. With the default `newestFirst` you are on the good end of that, but disclosure is never universal — use `withSalaryOnly` when you need it.
- **The board carries stale postings, and this is the thing to know before buying.** The sitemap still lists jobs dated as far back as 2007. Walking it forwards, only 1 of the first 12 postings was from the past year. This actor therefore defaults to `newestFirst: true`, which starts from the most recently created listings — but `datePosted` is not a clean proxy for freshness either, because it records the original posting date and survives re-listing. **If you need a live-market view, set `postedWithinDays`.** Without it you are getting the archive along with the active board.
- **Some sitemap entries are dead.** Taken-down jobs answer with a redirect and an empty body. Those are counted in the run summary as `deadOrUnparseableListings` and are **never charged** — you only pay for postings that actually returned content.
- **`validThrough` is not published.** The column exists and is honest about being empty rather than being filled with a guess.
- **US only.** PracticeMatch is a US physician recruiting board.
- **Coordinates are partial.** Present on much of the physician board, mostly absent on the other two.
- **Format risk.** Rows are built from each page's JSON-LD `JobPosting`, which parsed cleanly on 100% of sampled pages. If the site changes format the actor logs it and skips the row rather than silently emitting a blank one.

***

### Output

Three ready-made views on the Output tab: **Overview** (the columns most people export), **Compensation** (pay only, for salary benchmarking by specialty and state) and **Employers & geo** (lead lists and territory mapping). A `RUN_SUMMARY` record in the key-value store reports rows pushed, rows billed, rows filtered out, dead listings and fetch failures.

# Actor input Schema

## `professions` (type: `array`):

Which of the three job boards to read. Physicians is the largest at 32,528 postings, advanced practitioners adds 8,376, and the newer healthcare board adds 546.

## `maxJobs` (type: `integer`):

Hard limit. Each job is one request, and the site asks for 5 seconds between requests, so this is roughly maxJobs x 6 seconds of runtime. The default of 12 is a quick sample; raise it for a real extract and give the run enough time.

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

Optional. Specific PracticeMatch job-details URLs to scrape. Overrides the board selection.

## `requestDelaySecs` (type: `integer`):

PracticeMatch publishes Crawl-Delay: 5 in its robots.txt and this actor honours it by default. Lowering it departs from what the site asks for and raises your risk of being blocked. At 5s you collect roughly 720 jobs per hour.

## `newestFirst` (type: `boolean`):

Walk the board from the most recently created postings backwards. Leave this on: the sitemap is written oldest-first, so turning it off starts you on listings dated as far back as 2007.

## `titleContains` (type: `string`):

Keep only jobs whose title contains this text, e.g. "cardiology" or "locum".

## `specialtyContains` (type: `string`):

Keep only jobs whose specialty contains this text, e.g. "Family Medicine", "Nurse Practitioners", "Surgeons".

## `locationState` (type: `string`):

Keep only jobs in this US state. Accepts either the two-letter code ("TX") or the full name ("Texas").

## `employmentType` (type: `string`):

Keep only jobs of this type, e.g. FULL\_TIME, PART\_TIME, CONTRACTOR. Postings often carry more than one, and this matches any of them.

## `withSalaryOnly` (type: `boolean`):

Roughly half of postings publish a real pay range. Turn this on to keep only those.

## `postedWithinDays` (type: `integer`):

Keep only postings published in the last N days. Worth using: the sitemap still lists jobs dated as far back as 2007.

## `incremental` (type: `boolean`):

Remember job ids between runs and emit only postings not seen before. Turns a scheduled watch into a cheap delta instead of re-billing the whole board.

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

Namespace for incremental state. Use a different key per saved search so separate schedules do not share history.

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

Optional and off by default. Every probe returned full content with no challenge, so the actor runs unproxied. Only enable this if the log shows repeated blocks.

## Actor input object example

```json
{
  "professions": [
    "physician"
  ],
  "maxJobs": 12,
  "requestDelaySecs": 5,
  "newestFirst": true,
  "withSalaryOnly": false,
  "incremental": false,
  "stateKey": "default",
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `jobs` (type: `string`):

One row per posting: title, employer, specialty and O\*NET code, full address with coordinates, disclosed salary range, dates and the full description.

## `compensation` (type: `string`):

Pay columns only, for salary benchmarking by specialty and state.

## `runSummary` (type: `string`):

Rows pushed, jobs billed, rows filtered out, jobs skipped as already seen, dead listings and fetch failures.

# 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 = {
    "professions": [
        "physician"
    ],
    "maxJobs": 12,
    "requestDelaySecs": 5,
    "newestFirst": true
};

// Run the Actor and wait for it to finish
const run = await client.actor("piquno/practicematch-physician-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 = {
    "professions": ["physician"],
    "maxJobs": 12,
    "requestDelaySecs": 5,
    "newestFirst": True,
}

# Run the Actor and wait for it to finish
run = client.actor("piquno/practicematch-physician-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 '{
  "professions": [
    "physician"
  ],
  "maxJobs": 12,
  "requestDelaySecs": 5,
  "newestFirst": true
}' |
apify call piquno/practicematch-physician-jobs-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,piquno/practicematch-physician-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/Wzs6Ty6TQOK5Nz4nC/builds/AlL7IhcHCERx4Z0tO/openapi.json
