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

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

Pricing

from $9.00 / 1,000 results

Go to Apify Store
Cannabis Jobs Scraper — 3 US Sources, 4.6K+ Listings

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

Sankov Vadim

Maintained by Community

Actor 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

  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

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

Flags, instead of guessing

  1. dataCompleteness on every row — FULL or LISTING_ONLY.
  2. salaryNormalizationwhy a pay figure is missing, not just that it is.
  3. 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

SourceRecordsExtraction depthLimits and notes
CareersInCannabis (engin)3,200FULL — list + per-record detail callOpen JSON API, 33 pages of 100. Adds salary, benefits, skills, tags, full facility address, company description. 99 distinct employers.
Vangst (vangst)1,415LISTING_ONLY — job URL onlyDetail 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)46FULL — sitemap + detail pageSmall 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.
Total4,6613,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

ParameterTypeDefaultDescription
sourcesToIncludearrayall threeWhich 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.
maxItemsinteger60Hard 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).
includeJobDescriptionbooleantrueSet false for a compact, spreadsheet-friendly export. CareersInCannabis descriptions are HTML and run to several kilobytes each, so this materially shrinks the output.
maxPagesinteger0 (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.
maxRequestsPerMinuteinteger60Courtesy 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.
proxyConfigurationobjectnoneUsually 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

FieldTypeNotes
sourceSitestringengin, vangst or cannabismarketspace.
sourceUrlstring | nullPublic URL for the posting.
jobIdstringThe source's own id. Unique within a source — key on sourceSite + jobId.
dataCompletenessstringFULL or LISTING_ONLY. See below.
scrapedAtstringISO 8601 UTC.
titlestring | nullAs published. For Vangst, reconstructed from the URL slug — see limitations.
companyNamestring | nullAlways null for Vangst.
citystring | nullAlways null for Vangst — deliberately; see limitations.
statestring | nullUSPS two-letter code. null for non-US postings.
categorystringNine values, never null. See taxonomy below.
employmentTypestring | nullFull-time, Part-time, … Only CareersInCannabis publishes it.
salaryMin / salaryMaxnumber | nullAs published. One may be set while the other is null.
salaryUnitstring | nullhourly, yearly, …
postedDatestring | nullISO YYYY-MM-DD. Always null for Vangst.
descriptionstring | nullFull text or HTML as published.
siteSpecificExtraobjectEverything one source publishes that the shared columns have no home for.

Derived fields

FieldHow it is computedDeliberate omissions
categoryCareersInCannabis'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.
annualEquivalentPayMidpoint 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.
salaryNormalizationNORMALIZED_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 specificallyThe 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.
seniorityTierCareersInCannabis'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.
workLocationTypeRemote / 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.
postedAgeDaysscrapedAt − 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.
possibleDuplicateKeySHA-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 key3,239
Carry no key (too little identifying data — every Vangst row)1,422
Distinct keys2,982
Keys shared by two or more rows178
Rows sitting inside a shared key435
Rows remaining if you collapsed every shared key4,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 scraperThis Actor
One site, one shapeThree sources normalised to one shape
Silently thinner rows when a page is blockeddataCompleteness on every row, and the reason documented
Merges "duplicates" on a guessTags them and leaves the decision to you
Raw fields onlyCategory, seniority, annualised pay, remote flag, posting age
null with no explanationsalaryNormalization says why a figure is missing
Source-specific fields dropped in normalisationPreserved in siteSpecificExtra
A JSON fileA JSON file and an HTML dashboard per run
Invented city / currency conversion to look completeBlank 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.