Danish Trade Media Job Scraper avatar

Danish Trade Media Job Scraper

Pricing

from $0.01 / 1,000 results

Go to Apify Store
Danish Trade Media Job Scraper

Danish Trade Media Job Scraper

Scrapes job postings from 14 Danish trade-media job boards into one normalised dataset, with CVR numbers and named contact persons where published.

Pricing

from $0.01 / 1,000 results

Rating

0.0

(0)

Developer

Centox ApS

Centox ApS

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

3 days ago

Last modified

Categories

Share

dk-job-scraper

Scrapes job postings from 14 Danish trade-media job boards into one normalised dataset, ready for enrichment and automation.

All 14 sites run the same publishing platform (Nordiske Medier), so the project is one adapter driven by a site registry rather than 14 scrapers. Adding a site that runs the same platform is a one-line change to config/sites.json.


Quick start

pip install -r requirements.txt
# What is configured
python3 -m dk_job_scraper sites
# Scrape everything, store it, and write a JSONL file
python3 -m dk_job_scraper -v scrape --all --out data/jobs.jsonl
# Incremental run for cron: only postings never seen before
python3 -m dk_job_scraper -v scrape --all --since 14d --new-only --dedupe \
--out data/new.jsonl

A full cold run over all 14 sites is about 90 seconds and ~430 requests (409 postings at the time of writing). Later runs stop as soon as they reach postings already in the database, so a scheduled run costs one page per site.


Sites

SlugPublicationSearch URL
licitationenLicitationenhttps://www.licitationen.dk/job/search
soefartSøfarthttps://www.soefart.dk/job/search
motormagasinetMotor-magasinethttps://www.motormagasinet.dk/job/search
metal-supplyMetal Supplyhttps://www.metal-supply.dk/job/search
building-supplyBuilding Supplyhttps://www.building-supply.dk/job/search
transportmagasinetTransportmagasinethttps://www.transportmagasinet.dk/job/search
lastbilmagasinetLastbil Magasinethttps://www.lastbilmagasinet.dk/job/search
food-supplyFood Supplyhttps://www.food-supply.dk/job/search
medtechnewsMedTechNewshttps://www.lifesciencenordic.com/job/search
energy-supplyEnergy Supplyhttps://www.energy-supply.dk/job/search
electronic-supplyElectronic Supplyhttps://www.electronic-supply.dk/job/search
plastforumPlastforumhttps://www.plastforum.dk/job/search
retailnewsRetailNewshttps://www.retailnews.dk/job/search
wood-supplyWood Supplyhttps://www.wood-supply.dk/job/search

Output schema

One JSON object per posting. uid (<site>:<job id>) is the stable primary key.

FieldNotes
uid, job_idjob_id is the platform-wide id — the same posting cross-published to several sites shares it
source_slug, source_name, source_urlWhich board it came from
urlCanonical posting URL
title, company, company_logo
locationDanmark > Midtjylland > Lemvig Kommune
country, region, municipalitySplit out of location
postal_code, city, streetFrom the employer's address microdata
cvrDanish company registry number — the join key for company-data enrichment
company_id, company_urlEmployer's profile on the publishing site
company_website, company_email, company_phoneEmployer's own contact details
contact_name, contact_title, contact_email, contact_phoneThe named "Kontaktperson" on the posting
published_atISO 8601 with timezone
deadlineISO date, or null
deadline_raw, deadline_open_endedtrue for "Løbende ansøgning" / "Snarest muligt"
start_date, start_date_rawExpected start
positionsNumber of openings
employment_typeAnsættelsesforhold, e.g. Fast
work_hoursArbejdstid, e.g. Fuldtid
employmentAnsættelse, e.g. Permanent
teaserShort summary from the listing
description_textPlain text body — what enrichment should read
description_htmlRaw HTML body (excluded unless --include-html)
how_to_apply_text / _html"Sådan ansøger du" block
apply_urlOutbound application link (often an ATS)
also_onOther sites carrying the same posting (set by --dedupe)
expiredPosting was removed while still linked from the listing
scraped_at, content_hashBookkeeping; the hash drives change detection

Field coverage measured over a full run (403 live postings):

title 100% apply_url 94% cvr 24%
company 100% positions 98% company_email 24%
url 100% employment_type 98% company_phone 23%
published_at 100% work_hours 98% company_website 24%
teaser 100% city / postal_code 90% contact_name 24%
description_text 100% region 90% contact_email 24%
location 100% municipality 88% contact_phone 10%
street 100% deadline 29% contact_title 9%

deadline looks low because 71% of postings are open-ended ("Løbende ansøgning", "Snarest muligt"); those are flagged with deadline_open_ended, and the two together cover 100% of records.

The employer and contact-person fields come from the sidebar card's schema.org microdata and are only present when the employer has a profile on the platform — about a quarter of postings. Where present, they give you a CVR number and a named person with a direct email and phone, which is the highest-value part of the record for enrichment.


Cross-posting

About 36% of postings appear on more than one of these sites (409 records → 261 unique postings in a full run). They share the same job_id, so --dedupe collapses them: the richest copy is kept and the other boards are listed in also_on. Running it before enrichment cuts that step's cost by roughly a third.

$python3 -m dk_job_scraper export --dedupe --out data/unique.jsonl

Commands

scrape

FlagMeaning
--all / --sites a,bWhich boards to scrape
--since 7d7d, 48h, 2w, 2026-08-01, or an ISO timestamp
--max-pages NResult pages per site (default 25)
--fullIgnore stored uids and re-scrape everything
--no-detailsListing pages only — fast, but no description or apply URL
-o / --out, --stdoutWhere to write
--format jsonl|json|csvDefault jsonl
--include-htmlKeep raw HTML bodies
--new-onlyExport only postings not seen on a previous run
--dedupeCollapse cross-posted duplicates
--db PATHSQLite path (default data/jobs.db)
--no-dbDo not read or write the database
--delaySeconds between requests to the same host (default 1.0)
--site-workers / --detail-workersParallelism (default 4 / 3)
--cache-dirCache fetched HTML — makes re-runs and debugging free

export

Re-export what is already in the database without touching the network.

python3 -m dk_job_scraper export --since 30d --format csv --out data/jobs.csv
python3 -m dk_job_scraper export --new-since 24h --dedupe --stdout
python3 -m dk_job_scraper export --sites metal-supply --limit 50 --stdout

stats

$python3 -m dk_job_scraper stats

Per-site counts, the newest posting seen, and the last run's summary.


How incremental runs work

The search pages are requested with sort=3 (Nyeste — newest first). Given the set of uids already stored for a site, the scraper walks pages until it hits one where every posting is already known, then stops. --since does the same thing against publication dates. Combined with --new-only, a scheduled run emits a clean delta and nothing else.

--full disables the early stop for a periodic full reconciliation.


Design notes

One adapter, not fourteen. Every site is the same CMS: server-rendered HTML, /job/search for listings, /job/view/<id>/<slug> for details, identical field labels on the detail page. dk_job_scraper/parser.py is the whole adapter.

No browser. Everything needed is in the initial HTML response, so there is no Selenium/Playwright dependency and no headless-browser cost.

Politeness. One request per second per host by default, exponential backoff with jitter on 429/5xx, and a real browser user-agent. Sites are scraped in parallel, but the rate limit is per host, so no single site sees a faster rate. robots.txt on these domains allows /job/search and /job/view; the excluded paths (/job/import, management endpoints) are never requested.

Failure is isolated. A dead page, a parse error or a site being down affects only that item: the run continues and the failure is reported at the end. When a detail page 404s — which happens routinely, because postings get pulled while still linked from the listing — the listing-level record is kept and flagged expired rather than being counted as an error.

Selector fallbacks. The listing parser prefers ul.result-list but falls back to any /job/view/ link, so a CSS-class rename in a redesign degrades instead of returning nothing.


Deploying to Apify

The repository is a ready-to-push Apify Actor. The scraper package stays runnable on its own — src/ is a thin wrapper that reads the Actor input, runs the same code, and pushes records to the default dataset.

npm install -g apify-cli
apify login
apify push

apify push builds Dockerfile and uploads the Actor. To check the build locally first:

docker build -t dk-job-scraper .
apify run # runs the Actor with ./storage as local storage

Input

Everything is optional; the defaults scrape all 14 boards incrementally.

FieldDefaultMeaning
sitesallWhich boards to scrape
onlyNewJobstruePush only postings not seen on a previous run
since7d, 48h, 2w, 2026-08-01, or an ISO timestamp
dedupeCrossPostedtrueCollapse the same posting published on several boards
fetchDetailstrueOpen each posting for description, apply URL, CVR, contact
maxItems0Cap the run (0 = no cap)
maxPages25Result pages per site
includeHtmlfalseAdd the raw HTML bodies
requestDelaySecs1Delay between requests to the same host
siteConcurrency / detailConcurrency4 / 3Parallelism
stateRetentionDays120How long a posting stays in the seen-before list
resetStatefalseForget the seen-before list for one run
proxyConfigurationoffOptional Apify Proxy

Incremental runs on the platform

The default key-value store is wiped between runs, so the set of already-seen uids lives in a named store, dk-job-scraper-state, which persists. A scheduled run therefore costs one page per site until something new is posted:

first run 409 postings 434 requests ~90s
later runs 0-20 new 14 requests ~6s

State is bounded by stateRetentionDays — entries first seen longer ago than that are dropped, since those postings are long gone from the boards.

Set resetState: true for a one-off full re-scrape without losing the store.

Suggested schedule

Given the posting volume (roughly 35–45 new postings a week across all boards), once or twice a day is plenty. In the Actor's Schedules tab:

0 6 * * * # 06:00 UTC daily

Wiring it into the rest of the pipeline

Add a webhook on the Actor's RUN.SUCCEEDED event pointing at your automation. The payload carries resource.defaultDatasetId, and the new postings from that run — and only those — are the dataset's contents:

https://api.apify.com/v2/datasets/{datasetId}/items?format=json&clean=true

Or pull the last run's items directly:

https://api.apify.com/v2/acts/{username}~dk-job-scraper/runs/last/dataset/items?token={token}

The dataset ships two table views: Overview (title, company, location, dates, source) and Employer & contacts (CVR, contact person, emails, phones), so the run is readable in the Apify UI without exporting.

Notes

  • Actor memory: 1 GB is enough — the scraper is I/O bound, not memory bound.
  • A full run is ~90 seconds of mostly waiting, so compute-unit cost is low.
  • No proxy is needed under normal use; the input accepts one if you ever want the traffic routed.
  • onlyNewJobs: false disables the state entirely and returns the full board each run, if you would rather deduplicate downstream.

Tests

pip install -r requirements-dev.txt
python3 -m pytest

67 tests, no network required — parsing is verified against saved HTML fixtures in tests/fixtures/ and the scraper is driven by a fake HTTP client.


Project layout

.actor/ Apify Actor manifest, input schema, dataset views
actor.json
input_schema.json
dataset_schema.json
Dockerfile Apify build
src/ Apify Actor entry point
main.py reads input, runs the scrape, pushes to the dataset
state.py cross-run "already seen" state
config/sites.json site registry (add a site here)
dk_job_scraper/
sites.py registry loading and selection
http.py retries, backoff, per-host rate limit, cache
parser.py the Nordiske Medier adapter
models.py the normalised Job record
scraper.py pagination, incremental stop, detail fetching
store.py SQLite dedupe + change detection
exporters.py JSONL / JSON / CSV, cross-site dedupe
cli.py command line interface
examples/
daily_scrape.sh cron-ready incremental run
use_as_library.py driving the scraper from Python
enrich_new_jobs.py consuming the delta downstream
tests/ fixtures and test suite

Maintenance

If a site changes its markup, tests/test_parser.py fails against the fixture and points at the field that broke. To refresh a fixture:

curl -A "Mozilla/5.0" https://www.building-supply.dk/job/search \
-o tests/fixtures/listing_building-supply.html

To add a site on the same platform, append it to config/sites.json — no code change. A site on a different platform needs a new parser module following the parse_listing / parse_detail / build_job interface in parser.py.