# Cannabis Jobs Scraper — 3 US Sources, 4.6K+ Listings (`solalab_digital/cannabis-jobs-scraper`) Actor

Cannabis jobs from 3 US sources (CareersInCannabis, Vangst, CannabisMarketSpace) — 4,661 listings on last full run. Normalised category, seniority, pay, remote flag, honest dataCompleteness (Vangst detail pages are LISTING\_ONLY, blocked by Cloudflare). Duplicates tagged, not merged.

- **URL**: https://apify.com/solalab\_digital/cannabis-jobs-scraper.md
- **Developed by:** [Sankov Vadim](https://apify.com/solalab_digital) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $9.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

## Cannabis Jobs Scraper — 3 US Sources, 4.6K+ Listings

The cannabis hiring market has no job board. It has dozens of them, plus every
operator's own ATS, and none of them talk to each other. A recruiter tracking
cultivation hiring in Illinois checks one site for MSO retail roles, another for
everything Vangst has indexed, a third for the corporate and VP-level searches
that never show up on the hourly boards — and then reconciles three exports with
three different field names, three different category systems, and no way to tell
which rows are the same opening posted twice.

This Actor collects all three into one dataset with one shape. Every row means
the same thing whichever site it came from, every row says how complete it is,
and every row tells you when it might be a duplicate of another — without
deciding that for you.

***

### What it does

**Collects, from three sources in parallel**

- **CareersInCannabis** (Engin) — the anchor. 3,200 live postings with employer,
  city, state, department, level, employment type, published pay range, benefits,
  required skills, tags and the full description.
- **Vangst** — 1,415 postings. Job title, state and remote flag only; see
  *Known limitations* below for exactly why, stated up front rather than buried.
- **CannabisMarketSpace** — 46 postings, weighted heavily toward corporate and
  executive roles (VP Finance, Director of Production Operations) that the hourly
  boards do not carry.

A full uncapped run on **2026-09-16** returned **4,661 records** — every posting
all three sources published that day, with nothing sampled and nothing merged.
The figures throughout this README come from that run, not from an estimate.

**Normalises, so the export has one shape**

1. Three spellings of a US state (`FL`, `California`, `…-stamford-fl`) become one
   USPS code.
2. Three category systems — one real taxonomy and two absent ones — become one
   nine-value vocabulary.
3. Three timestamp formats become one ISO date.
4. Anything only one source publishes is preserved in `siteSpecificExtra` rather
   than flattened away.

**Enriches, on top of what was scraped**

5. `annualEquivalentPay` — hourly and monthly figures restated as a year, so
   $18/hr and $52,000/yr sort against each other.
6. `seniorityTier` — Executive through Hourly/Frontline.
7. `workLocationType` — remote, on site, hybrid, or honestly unknown.
8. `postedAgeDays` — freshness, measured from the scrape so it never drifts.
9. `category` — the nine-value job taxonomy.

**Flags, instead of guessing**

10. `dataCompleteness` on every row — `FULL` or `LISTING_ONLY`.
11. `salaryNormalization` — *why* a pay figure is missing, not just that it is.
12. `possibleDuplicateKey` — rows that may be the same posting cross-listed, left
    for you to collapse or ignore.

**Reports** — every run writes a standalone HTML dashboard to the key-value store
under `DASHBOARD`: postings by source and completeness, category and state
breakdowns, top employers, seniority mix, remote share, a pay distribution, and a
breakdown of *why* pay is or is not stated on each row.

***

### Sources

| Source | Records | Extraction depth | Limits and notes |
|---|---|---|---|
| **CareersInCannabis** (`engin`) | **3,200** | **FULL** — list + per-record detail call | Open JSON API, 33 pages of 100. Adds salary, benefits, skills, tags, full facility address, company description. 99 distinct employers. |
| **Vangst** (`vangst`) | **1,415** | **LISTING\_ONLY** — job URL only | Detail pages return HTTP 403 behind a Cloudflare Turnstile challenge. No employer, no salary, no description, no date, no city. A state is recovered from the URL slug on 1,397 of the 1,415. |
| **CannabisMarketSpace** (`cannabismarketspace`) | **46** | **FULL** — sitemap + detail page | Small WordPress site, read at 20 requests/minute out of courtesy. Includes a handful of non-US postings (Canada, Germany). Publishes no salary on any page. |
| **Total** | **4,661** | | 3,246 `FULL` + 1,415 `LISTING_ONLY` |

These are measured counts from a complete run on **2026-09-16**, not estimates —
every page of every source, no cap. They are not a *guaranteed* run size: boards
gain and lose listings daily. This Actor does not merge across sources, so the
total is the sum of the three, not a de-duplicated figure.

That run cost 3,285 HTTP requests in total (3,237 for CareersInCannabis, whose
per-record detail pass is the expensive half; 47 for CannabisMarketSpace; 1 for
Vangst) and finished in about 28 minutes, paced by the per-source rate limits
rather than by anything on the sources' side.

`robots.txt` was fetched and read for all three hosts on the same date. None
disallows the paths this Actor reads. Every request is checked against the live
`robots.txt` at run time anyway, per source.

***

### Input

| Parameter | Type | Default | Description |
|---|---|---|---|
| `sourcesToInclude` | array | all three | Which sources to read. Deselect what you do not need — every source adds records and therefore cost. If you only want rows with salary data, select `engin` alone. |
| `maxItems` | integer | `60` | Hard cap on records across the whole run — you are never charged for more. The budget is split evenly between the sources you selected, so a capped run is a representative sample rather than a pile of rows from whichever source answers fastest. A run can therefore finish slightly under the cap when a source holds less than its share. The default keeps a plain run under a minute; set `0` for a full pull (~28 minutes for all three sources as of 2026-09-16). |
| `includeJobDescription` | boolean | `true` | Set `false` for a compact, spreadsheet-friendly export. CareersInCannabis descriptions are HTML and run to several kilobytes each, so this materially shrinks the output. |
| `maxPages` | integer | `0` (all) | Caps pages of the CareersInCannabis API (33 pages of 100 on the 2026-09-16 run — 32 full pages and a short final one). No effect on the other two, which are read from one sitemap each. Use it to sample the newest postings without a full walk. |
| `maxRequestsPerMinute` | integer | `60` | Courtesy rate limit, applied per source. Each source's own budget still wins where it is stricter. Lower it if you run this on a schedule. |
| `proxyConfiguration` | object | none | Usually unnecessary — all three sources serve the paths this Actor reads to an ordinary client. A proxy will **not** unlock Vangst's detail pages. |

***

### Output

One record per posting. Every field is present on every row; fields a source does
not publish are `null`, never omitted, so the dataset exports cleanly to CSV.

#### Scraped fields

| Field | Type | Notes |
|---|---|---|
| `sourceSite` | string | `engin`, `vangst` or `cannabismarketspace`. |
| `sourceUrl` | string | null | Public URL for the posting. |
| `jobId` | string | The source's own id. Unique **within** a source — key on `sourceSite` + `jobId`. |
| `dataCompleteness` | string | `FULL` or `LISTING_ONLY`. See below. |
| `scrapedAt` | string | ISO 8601 UTC. |
| `title` | string | null | As published. For Vangst, reconstructed from the URL slug — see limitations. |
| `companyName` | string | null | Always `null` for Vangst. |
| `city` | string | null | Always `null` for Vangst — deliberately; see limitations. |
| `state` | string | null | USPS two-letter code. `null` for non-US postings. |
| `category` | string | Nine values, never `null`. See taxonomy below. |
| `employmentType` | string | null | `Full-time`, `Part-time`, … Only CareersInCannabis publishes it. |
| `salaryMin` / `salaryMax` | number | null | As published. One may be set while the other is `null`. |
| `salaryUnit` | string | null | `hourly`, `yearly`, … |
| `postedDate` | string | null | ISO `YYYY-MM-DD`. Always `null` for Vangst. |
| `description` | string | null | Full text or HTML as published. |
| `siteSpecificExtra` | object | Everything one source publishes that the shared columns have no home for. |

#### Derived fields

| Field | How it is computed | Deliberate omissions |
|---|---|---|
| `category` | CareersInCannabis's own department key where it maps to exactly one category; otherwise explicit keyword rules on the title. Anything unmatched is `other_unspecified`. | **No fuzzy matching and no "closest category".** A title either matches a rule someone wrote down or it is visibly unclassified. `other_unspecified` is charted in the dashboard like any other category, so you can see how much of the data the taxonomy did not classify. |
| `annualEquivalentPay` | Midpoint of the published band (or the single bound, when only one is published) × a unit multiplier: hourly ×2080, monthly ×12, weekly ×52, daily ×260. | **No currency conversion, ever.** A published Actor runs for months; a baked-in exchange rate silently goes stale and then presents a guess with the same confidence as a scraped number. No source publishes a currency code at all, and all three are US-market boards — the handful of non-US postings are excluded from this column and flagged rather than quietly restated in dollars. |
| `salaryNormalization` | `NORMALIZED_ANNUAL` (1,745 rows), `NO_SALARY_PUBLISHED` (2,884), `IMPLAUSIBLE_PAY_RANGE` (32), plus `UNSUPPORTED_PAY_UNIT` and `NON_US_CURRENCY_UNKNOWN`. | An explicit status rather than a bare `null`, so "this employer publishes no pay" is distinguishable from "we declined to restate this one". |
| `IMPLAUSIBLE_PAY_RANGE` specifically | The employer mislabelled `salaryUnit` and the conversion produced an impossible wage — an annual figure tagged `hourly` (a Cultivation Manager at a notional **$176,800,000**) or an hourly figure tagged `yearly` (a technician on **$16.75 a year**). Refused outside $15,080–$1,000,000: the floor is the US federal minimum wage over the same 2,080 hours, the ceiling is above any base pay these boards carry. | **The unit is never silently re-tagged.** `salaryMin`, `salaryMax` and `salaryUnit` are exported exactly as published; only the derived restatement is withheld. Guessing "they obviously meant annual" would overwrite a published number with an inference. The guard catches *impossible*, not merely surprising — an Inventory Associate posted at $100–300/hr annualises to $416,000 and is left alone, because calling that wrong needs a judgement about what the role should pay. |
| `seniorityTier` | CareersInCannabis's own level key where present; otherwise keyword rules on the title, ordered so *specific beats general* (`Director of Retail Operations Manager` is Director, not Manager). | Unmatched titles are `Unclassified`, not forced into the nearest bucket. |
| `workLocationType` | `Remote` / `On site` / `Hybrid` / `Not specified`. | **Never defaults to `On site`.** A source that says nothing gets the visible unknown bucket, because a default would read as a fact. |
| `postedAgeDays` | `scrapedAt − postedDate`, in days. | Measured from the scrape, not from "now", so re-reading a stored record next week cannot change its age. Negative values are kept: a source publishing a future date is a fact about the source. |
| `possibleDuplicateKey` | SHA-1 of normalised employer + category + state + city + first four title words. Legal-entity suffixes (`Inc`, `LLC`, `Ltd`) are stripped so `Curaleaf` and `Curaleaf, Inc.` hash the same. Requires a title **plus an employer or a city**; a state alone does not earn a key. | **Nothing is ever merged or deleted.** `null` rather than a weak key — a state-only key put 67 distinct Florida listings in one bucket on real data. See below. |

#### Job categories

`cultivation_grow` · `extraction_processing` · `retail_budtender` ·
`compliance_qa` · `sales_wholesale` · `delivery_logistics` ·
`corporate_marketing` · `security` · `other_unspecified`

***

### Duplicates are tagged, not merged

This is the single most consequential design decision in the Actor, so it is
stated plainly rather than left for you to discover.

Large operators post the same role many times — one opening per store, per shift,
or re-listed after a few weeks. So `possibleDuplicateKey` buckets the identifying
parts (employer, category, state, city, and the first four words of the title) and
hashes them: rows sharing a key **may** be the same posting.

On the 2026-09-16 run, of 4,661 records:

| | Rows |
|---|---|
| Carry a key | 3,239 |
| Carry no key (too little identifying data — every Vangst row) | 1,422 |
| Distinct keys | 2,982 |
| Keys shared by two or more rows | 178 |
| Rows sitting inside a shared key | 435 |
| Rows remaining **if** you collapsed every shared key | 4,404 |

The largest shared group was 11 rows — Verano hiring Cultivation Specialists in
Apollo Beach, FL — which may be eleven openings at one site or one opening
re-listed eleven times. The Actor does not claim to know which, and neither
should a merge rule.

**No key spanned two sources.** Not one of the 178 shared keys mixed sources, so
on this run there was no measurable cross-posting *between* these three boards —
mostly because Vangst publishes no employer to match on. If you are buying this
expecting the three sources to overlap heavily and need de-duplicating, they do
not: the 4,661 rows are 4,661 distinct listings.

The Actor never acts on that key. There is no way to *prove* from public listing
data that two rows are one opening — an employer genuinely running two
"Budtender — Phoenix, AZ" openings at two stores produces one key, and merging
them deletes a real vacancy from your pipeline. Showing you both costs you one
glance. Which trade is right depends on what you are doing with the data, and
only you know that.

So: `GROUP BY possibleDuplicateKey` to collapse, or ignore the column entirely.

The key is `null` on rows carrying too little to make any claim, because a key
built from blanks collides every under-specified row with every other one, which
is worse than no key at all. Tagging requires a title **plus an employer or a
city** — something naming who is hiring or where the job is. A state is not
enough: it narrows a job title to a region, not to an opening. Every Vangst row
therefore has a `null` key, since Vangst publishes neither employer nor city.

*Within* a single source, exact duplicates (the same id seen twice during
pagination) **are** collapsed. Same id on the same site is proof; two rows on two
sites is not.

***

### Known limitations

**Vangst is listing-only, and here is exactly why.** Its open sitemap
(`jobs-sitemap.vangst.com`) returns HTTP 200 and lists 1,415 job URLs. Its job
detail pages return **HTTP 403 behind a Cloudflare Turnstile challenge**, verified
2026-09-16. This Actor does not attempt to solve that challenge — not because it
is hard, but because working around a site's stated access control is not what
this Actor is for. The consequence is real and is priced into every row:
`companyName`, `salaryMin`/`salaryMax`, `postedDate` and `description` are `null`
for all 1,415 Vangst rows, and `dataCompleteness` says `LISTING_ONLY` so you can
filter them out in one clause. If you only want rows with employer and salary,
select `engin` alone, or filter `dataCompleteness = "FULL"`.

**Vangst `city` is deliberately empty.** The URL slug is
`{id}-{title-words}-{city-words}-{state}` with no delimiter between the title and
the city. Multi-word city names are common in the live data
(`…-port-st-lucie-fl`, `…-crystal-river-fl`) and some titles carry a street
address (`…-part-time-1720-sw-gatlin-blvd-port-st-lucie-fl`). Any split rule is
wrong on a material share of rows, and a wrong city is worse than an honest blank
for anyone filtering by market. The state *is* recoverable — 1,397 of 1,415 slugs
end in a valid USPS code — and the raw slug plus its trailing segments are
exported in `siteSpecificExtra` so you can attempt the split against your own
gazetteer. The same slug limitation means Vangst `title` values contain the city,
and occasionally an address.

**Not every posting is in the United States.** CannabisMarketSpace carries a small
number of Canadian and European roles. Those keep `state = null` and their real
region and country in `siteSpecificExtra`, rather than being dropped or assigned
an invented US state.

**Volumes are not guaranteed.** The figures in this README are what the sources
held on 2026-09-16. Boards gain and lose listings daily.

**CannabisMarketSpace is occasionally unreachable from this platform.** Its
sitemap sometimes returns a non-XML response to requests from this Actor's
cloud IP range — seen on roughly half of runs checked on 2026-09-17, consistent
for the whole lifetime of one run and different on the next. The Actor retries
twice before giving up; when all three attempts fail, that run simply proceeds
without this source (see the run log for a `[cannabismarketspace] Sitemap
response did not parse as XML` warning) rather than failing the whole run.
Since this source is ~1% of total volume, the practical effect on a full pull is
small, but do not expect all three sources present on every single run.

***

### Who this is for

- **A cannabis-sector recruiting agency mapping a new state.** Filter
  `state = "IL"`, group by `category` and `seniorityTier`, and you have the
  hiring shape of a market in one query — including which operators are hiring
  hourly versus management, which is what tells you where the agency spend is.
- **An MSO's compensation analyst benchmarking pay.** Filter
  `salaryNormalization = "NORMALIZED_ANNUAL"`, group by `category` and `state`,
  and read `annualEquivalentPay` — hourly and salaried roles already restated to
  the same unit, so a budtender and a compliance manager sort on one axis.
- **A B2B vendor selling into cannabis operators.** `companyName` plus
  `postedAgeDays` is a hiring-intent signal: a cultivator that posted eight roles
  this month is expanding, and expansion is when equipment and software budgets
  move.
- **A job aggregator or careers site filling its own index.** One normalised feed
  instead of three integrations, with `possibleDuplicateKey` already computed so
  your ingest can decide its own merge policy.
- **A market researcher sizing cannabis employment.** `category` × `state` ×
  `sourceSite`, with `dataCompleteness` telling you which rows can carry a salary
  claim and which cannot — so your methodology footnote writes itself.

***

### How this compares

| Typical job scraper | This Actor |
|---|---|
| One site, one shape | Three sources normalised to one shape |
| Silently thinner rows when a page is blocked | `dataCompleteness` on every row, and the reason documented |
| Merges "duplicates" on a guess | Tags them and leaves the decision to you |
| Raw fields only | Category, seniority, annualised pay, remote flag, posting age |
| `null` with no explanation | `salaryNormalization` says *why* a figure is missing |
| Source-specific fields dropped in normalisation | Preserved in `siteSpecificExtra` |
| A JSON file | A JSON file **and** an HTML dashboard per run |
| Invented city / currency conversion to look complete | Blank where the data does not support a claim, and says so |

***

### Pricing

Pay per event, billed per result: one charge per record written to the dataset,
at the price shown on this Actor's Store page. There is no monthly fee, no
subscription and no charge for a run that returns nothing.

Two guarantees against paying for rows you did not ask for:

- **`maxItems` is a hard cap**, exact under concurrency. The three sources share
  one counter guarded by a lock, so they cannot jointly overshoot your limit. Set
  it to 50 for a trial run and you are charged for at most 50 — and those 50 are
  split evenly across your selected sources, so the trial tells you what a full
  run would look like rather than what the fastest source looks like.
- **Within-source duplicates are never pushed twice.** The same posting seen twice
  during pagination is charged once.

A record whose push fails releases its slot back to your budget rather than
silently consuming it.

***

### FAQ

**Why do so many rows have no salary?**
Because most cannabis employers do not publish one. On the 2026-09-16 run, 1,777
of 4,661 rows carried a pay figure — all of them from CareersInCannabis, which
publishes pay on about 56% of its postings. CannabisMarketSpace's salary block was
empty on all 46 pages, and Vangst publishes nothing readable. After the
plausibility check, **1,745 rows carry an `annualEquivalentPay`**: median
**$39,520**, ranging from $16,640 to $416,000. Check `salaryNormalization` — it
distinguishes "not published" from "published but not restatable".

**One posting says $176 million a year. Is the data broken?**
It said that; it does not any more. Some employers tag an annual figure as
`hourly`, and multiplying by 2,080 turns a typo into a headline number. Those rows
now come back with `salaryNormalization = "IMPLAUSIBLE_PAY_RANGE"` and a `null`
`annualEquivalentPay`, while `salaryMin`/`salaryMax`/`salaryUnit` keep exactly what
the employer published. 32 rows out of 1,777 on the last full run. The Actor
flags them rather than re-tagging the unit, because "they obviously meant annual"
is a guess, and a scraper that quietly rewrites published numbers is worse than
one that says it does not know.

**Why does Vangst have so much less data than the other two?**
Its job detail pages sit behind a Cloudflare Turnstile challenge that this Actor
does not attempt to bypass. What remains readable is the open sitemap, which
encodes the job id, the title and the state. Those rows are flagged
`LISTING_ONLY` so they never masquerade as complete ones.

**Can a proxy fix the Vangst limitation?**
No. It is an interactive challenge, not an IP block. A proxy changes nothing, and
the Actor does not pretend otherwise.

**Why is `city` empty for Vangst but `state` is not?**
The URL slug ends in a two-letter state code that can be identified reliably; it
has no delimiter marking where the job title ends and the city begins. Rather than
split on a rule that is wrong on a tenth of rows, the Actor leaves `city` blank
and exports the raw slug so you can decide.

**Two rows look like the same job. Why didn't you merge them?**
Because they might not be. See *Duplicates are tagged, not merged* — the key is
there for you to group on when that suits your use.

**How fresh is the data?**
Each run reads the sources live. `postedDate` is the source's own publication date
where it publishes one, and `postedAgeDays` gives the gap to the scrape. Vangst
publishes no date, so those rows have neither.

**Can I run just one source?**
Yes — `sourcesToInclude`. A source failing never fails the run either: if one goes
down or changes its contract, the others complete normally and the run reports
which source went quiet.

**Do I need a proxy at all?**
Almost certainly not. All three sources serve the paths this Actor reads to an
ordinary HTTP client.

***

### Responsible use

This Actor reads **only publicly available listing data** — job postings that each
source publishes to any visitor, plus the sitemaps and public API those sources
provide for exactly this purpose. `robots.txt` is fetched and enforced per source
on every request.

It does **not** collect personal data: no applicant records, no recruiter contact
details, no named individuals. It does **not** bypass authentication, paywalls, or
bot-protection challenges — the Vangst limitation documented above is precisely
that boundary being respected rather than worked around.

Job postings may be protected by copyright in your jurisdiction. You are
responsible for how you use, store and redistribute the output, including
compliance with applicable data-protection and employment-advertising law.
Cannabis is regulated differently in every US state and is federally illegal in
the United States; nothing in this dataset is legal or commercial advice.

# Actor input Schema

## `sourcesToInclude` (type: `array`):

Which sources to read. All three are included by default, which returned 4,661 postings on the last full run. Deselect sources you do not need: every source you include adds records — and therefore cost — to a full run. Measured sizes on 2026-09-16: CareersInCannabis 3,200 (the only source publishing salary), Vangst 1,415 (LISTING\_ONLY — title and state only), CannabisMarketSpace 46.

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

Stop the whole run after this many job records have been saved across all sources combined. Set to 0 to scrape every posting currently listed (took about 28 minutes on 2026-09-16 for all three sources). You are never charged for more than this number: concurrent sources share one counter and cannot jointly overshoot it. The budget is divided evenly between the sources you selected, so a capped run is a representative sample rather than a pile of rows from whichever source answers fastest — which means a run may finish slightly under the cap if a source holds fewer postings than its share. Defaults to a small sample so a plain run finishes in well under a minute; raise it or set it to 0 for a full pull.

## `includeJobDescription` (type: `boolean`):

Include the full job description in each record. Turn this off for a smaller, tabular dataset that is easier to load into a spreadsheet or CRM — CareersInCannabis descriptions are HTML and run to several kilobytes each.

## `maxPages` (type: `integer`):

Limit how many pages of the CareersInCannabis API are walked. Set to 0 for no limit, in which case the Actor keeps paging until the API reports no further results (33 pages of 100 on the 2026-09-16 run — 32 full pages and a short final one). Has no effect on the other two sources, which are read from a single sitemap each rather than paged.

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

Courtesy rate limit applied to each source separately. Each source's own budget still wins where it is stricter: CannabisMarketSpace is a small WordPress site and is never read faster than 20 requests per minute regardless of this setting, and Vangst needs exactly one request. Lower this if you run the Actor frequently.

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

Usually not needed. All three sources serve their documents to an ordinary HTTP client with no bot protection on the paths this Actor reads. Note that a proxy will NOT unlock Vangst's job detail pages: those sit behind a Cloudflare Turnstile challenge that this Actor deliberately does not attempt to solve, which is why Vangst records are flagged LISTING\_ONLY.

## Actor input object example

```json
{
  "sourcesToInclude": [
    "engin",
    "vangst",
    "cannabismarketspace"
  ],
  "maxItems": 60,
  "includeJobDescription": true,
  "maxPages": 0,
  "maxRequestsPerMinute": 60,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

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

Every scraped posting as a normalised 23-column record, one row per listing across CareersInCannabis, Vangst and CannabisMarketSpace. Download as JSON, CSV, XLSX or read via the API.

## `dashboard` (type: `string`):

Standalone HTML report for the run: postings by source and data completeness, job category and state breakdowns, top employers, seniority mix, remote share, pay distribution, a breakdown of why pay is or is not stated, and the full posting table. Open it in a browser.

# 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 = {
    "sourcesToInclude": [
        "engin",
        "vangst",
        "cannabismarketspace"
    ],
    "maxItems": 60,
    "maxPages": 0,
    "maxRequestsPerMinute": 60
};

// Run the Actor and wait for it to finish
const run = await client.actor("solalab_digital/cannabis-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 = {
    "sourcesToInclude": [
        "engin",
        "vangst",
        "cannabismarketspace",
    ],
    "maxItems": 60,
    "maxPages": 0,
    "maxRequestsPerMinute": 60,
}

# Run the Actor and wait for it to finish
run = client.actor("solalab_digital/cannabis-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 '{
  "sourcesToInclude": [
    "engin",
    "vangst",
    "cannabismarketspace"
  ],
  "maxItems": 60,
  "maxPages": 0,
  "maxRequestsPerMinute": 60
}' |
apify call solalab_digital/cannabis-jobs-scraper --silent --output-dataset

```

## MCP server setup

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