Cannabis Jobs Scraper — 3 US Sources, 4.6K+ Listings
Pricing
from $9.00 / 1,000 results
Cannabis Jobs Scraper — 3 US Sources, 4.6K+ Listings
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.
Pricing
from $9.00 / 1,000 results
Rating
0.0
(0)
Developer
Sankov Vadim
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
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
- Three spellings of a US state (
FL,California,…-stamford-fl) become one USPS code. - Three category systems — one real taxonomy and two absent ones — become one nine-value vocabulary.
- Three timestamp formats become one ISO date.
- Anything only one source publishes is preserved in
siteSpecificExtrarather than flattened away.
Enriches, on top of what was scraped
annualEquivalentPay— hourly and monthly figures restated as a year, so $18/hr and $52,000/yr sort against each other.seniorityTier— Executive through Hourly/Frontline.workLocationType— remote, on site, hybrid, or honestly unknown.postedAgeDays— freshness, measured from the scrape so it never drifts.category— the nine-value job taxonomy.
Flags, instead of guessing
dataCompletenesson every row —FULLorLISTING_ONLY.salaryNormalization— why a pay figure is missing, not just that it is.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 XMLWho this is for
- A cannabis-sector recruiting agency mapping a new state. Filter
state = "IL", group bycategoryandseniorityTier, 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 bycategoryandstate, and readannualEquivalentPay— 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.
companyNamepluspostedAgeDaysis 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
possibleDuplicateKeyalready computed so your ingest can decide its own merge policy. - A market researcher sizing cannabis employment.
category×state×sourceSite, withdataCompletenesstelling 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:
maxItemsis 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.