Purpose
Search Beatport for electronic-music metadata by a free-text query — either an artist name ("deadmau5") or an artist + track combination ("eric prydz opus") — and return structured results across five entity types: tracks, artists, releases, labels, and charts. For each result you get IDs, names, mix names, BPM, musical key, genre, label, release, price, ISRC, artwork URIs, and a canonical Beatport URL. Read-only; never logs in, buys, or downloads.
The recommended path is a lightweight page load, not scripted browsing. Beatport is a Next.js app whose search page server-side-renders the complete result set into an embedded __NEXT_DATA__ JSON blob. A single navigation to the search URL (over a residential proxy) returns every field the rendered page shows — no clicking, no pagination, no interaction. Lead with the goto + extract path; the full browser flow below is a fallback for when you also need screenshots or player interaction.
When to Use
- Look up a track's metadata (BPM, key, genre, label, release, ISRC, price) from an artist + title string.
- Resolve an artist name to a Beatport
artist_idand canonical artist URL, plus their genre spread. - Bulk-enrich a playlist / crate list of "Artist – Title" strings with Beatport IDs and metadata.
- Disambiguate similarly named artists or mixes (the API returns relevance
scoreper result). - Find the release, label, or chart an artist/track belongs to.
Workflow
Recommended: load the search page and parse __NEXT_DATA__
Build the search URL. URL-encode the whole query into the
qparam:https://www.beatport.com/search?q=<url-encoded query>For artist + track, just join them with a space:
q=eric%20prydz%20opus. The singleqparam drives all five result buckets — there is no separate "artist field" vs "track field".Load over a residential proxy. Beatport sits behind Cloudflare. The bare homepage (
/) returns 403 to datacenter IPs, but the/searchpage returns 200 over a residential proxy with a normal browser User-Agent. Callbrowserless_agentwith a top-levelproxy: { proxy: "residential" }(datacenter egress gets Cloudflare-blocked — repeat theproxyarg on every call, since the session persists across calls keyed byproxy/profile, and a call that drops or changes the proxy lands in a different session). It navigates and follows redirects natively:{ "proxy": { "proxy": "residential" }, "commands": [ { "method": "goto", "params": { "url": "https://www.beatport.com/search?q=eric%20prydz%20opus", "waitUntil": "load", "timeout": 45000 } }, { "method": "html", "params": { "selector": "html" } } ] }No auth, no cookies, no full stealth browser session required for this path — the residential proxy alone is sufficient (confirmed across multiple queries). Response is ~150–170 KB of HTML. (You can also fold the extraction in step 3 into an
evaluatecommand that reads the__NEXT_DATA__script in-page and returns a compact projection under.value, avoiding shipping the whole HTML.)Extract the embedded JSON. Pull the
__NEXT_DATA__script tag and walk to the search payload:const m = html.match( /<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/, ); const next = JSON.parse(m[1]); const data = next.props.pageProps.dehydratedState.queries[0].state.data; // data => { tracks, artists, charts, labels, releases }Each bucket is
{ data: [...], ... }, so the result arrays aredata.tracks.data,data.artists.data,data.releases.data,data.labels.data,data.charts.data. Results are pre-sorted by relevancescore(descending) — element[0]is the best match.Decode the fields you need (see Expected Output for full shapes). Key gotchas:
lengthis milliseconds.543453→543.453 s→9:03. Format asmm:ss = floor(ms/60000):round((ms%60000)/1000).genreis an array of{ genre_id, genre_name }(tracks usually have one; artists list many).artists/remixersare arrays of{ artist_id, artist_name, artist_type_name }. Use this to separate the original artist(s) from remixers.priceis an object{ code, symbol, value, display }— useprice.display("$1.49").key_name("A Major"),bpm,isrc,mix_name("Original Mix") are top-level track fields.- Image URIs are absolute (
track_image_uri,artist_image_uri); the*_dynamic_urivariants contain{w}x{h}placeholders you substitute for a custom size.
Build canonical URLs. The slug segment is cosmetic — Beatport resolves by the trailing numeric ID, so any slug (or a placeholder) works and redirects to the canonical one:
Track: https://www.beatport.com/track/<slug>/<track_id> Artist: https://www.beatport.com/artist/<slug>/<artist_id> Release: https://www.beatport.com/release/<slug>/<release_id> Label: https://www.beatport.com/label/<slug>/<label_id> Chart: https://www.beatport.com/chart/<slug>/<chart_id>
Browser fallback (only when you need screenshots / player interaction)
The SSR search page is fully JS-hydrated. A live browserless_agent session clears Cloudflare with the residential proxy (stealth is built in — no extra flag needed), and the whole flow lives in one commands array so the session persists:
- Call
browserless_agentwith top-levelproxy: { proxy: "residential" }; datacenter egress hits the Cloudflare interstitial. { "method": "goto", "params": { "url": "https://www.beatport.com/search?q=<query>", "waitUntil": "load", "timeout": 45000 } }.- A cookie-consent dialog ("Beatport Group Cookie Consent") overlays the page — dismiss it with
{ "method": "click", "params": { "selector": "..." } }targeting theI Acceptbutton (confirm the selector via a{ "method": "snapshot" }if it misses) before screenshotting. - Rather than scraping the rendered DOM, run an
htmlcommand (or anevaluatethat reads the script tag) and parse the same__NEXT_DATA__blob described above — the rendered sections (Artists / Releases / Tracks / Charts / Labels) carry no data the JSON doesn't already have.
Site-Specific Gotchas
- Bare homepage 403s; the search page does not. A pre-run probe of
https://beatport.com/returned 403 (Cloudflare). Don't conclude the site is unreachable — go straight to/search?q=…, which returns 200 over a residential proxy. Never gate your flow on a homepage load. - The residential proxy is mandatory. Both the lightweight goto+extract path and the full live-browser path need
proxy: { proxy: "residential" }on everybrowserless_agentcall (datacenter IPs get Cloudflare-blocked). The extract-only path needs nothing more; the interactive browser path just adds cookie-dialog dismissal and screenshots on top of the same proxied session. - The SSR payload is capped at 15 results per bucket. The embedded query key is
["search-all", { q, count: "15", is_approved: true, preorder: true }, "US"]. You always get the top ~15 tracks / artists / releases / labels / charts. To go deeper you'd need the authenticatedapi.beatport.com/v4API or the per-type search pages — the public search-all SSR does not paginate. lengthis in milliseconds, not seconds. Forgetting this turns a 9-minute track into "543,453 seconds". Divide by 1000.- Results are scored, not alphabetized.
state.data.tracks.data[i].scoreis the relevance score;[0]is the best match. For "artist + track" queries the intended track is reliablytracks.data[0], but verify by matchingartists[].artist_name+mix_nameto your input rather than blindly trusting index 0. - Geo / locale is baked into the payload. The query key's trailing element is the storefront locale (
"US"from a US proxy egress) andpriceis denominated accordingly (USD). A proxy egressing elsewhere returns localized pricing/availability. api.beatport.com/v4/catalog/search/is OAuth-gated — don't waste time on it unauthenticated. It returns HTTP 401 without a bearer token. Confirmed blocked; only useful if you already hold Beatport API credentials.- robots.txt disallows AI/crawler UAs (
ClaudeBot,GPTBot,CCBot, …) butAllow: /for genericUser-agent: *. The residential-proxy page load uses a normal browser UA, which is in the allowed bucket; search indexing is explicitly permitted (Content-Signal: search=yes). Stay read-only. - URL slugs are throwaway.
https://www.beatport.com/track/anything/15744386resolves to the canonical track by ID — you never need to know the real slug to build a working link. - No site-specific rate limit was hit across ~5 loads in testing, but Cloudflare fronts everything — keep request volume modest and batch bulk lookups within a single proxied session where you can.
Expected Output
A normalized object per search. Example for q="eric prydz opus":
{
"query": "eric prydz opus",
"locale": "US",
"result_counts": {
"tracks": 15,
"artists": 15,
"releases": 15,
"labels": 15,
"charts": 15
},
"top_track": {
"track_id": 15744386,
"track_name": "Opus",
"mix_name": "Original Mix",
"artists": [
{
"artist_id": 2863,
"artist_name": "Eric Prydz",
"artist_type_name": "Artist"
}
],
"remixers": [],
"release": { "release_id": 3517329, "release_name": "Opus" },
"label": { "label_id": 70017, "label_name": "Virgin Records Ltd" },
"genre": [{ "genre_id": 96, "genre_name": "Mainstage" }],
"bpm": 128,
"key_name": "A Major",
"length_ms": 543453,
"length_display": "9:03",
"isrc": "GB6CM1500105",
"price": {
"code": "USD",
"symbol": "$",
"value": 1.49,
"display": "$1.49"
},
"publish_date": "2016-02-05T00:00:00",
"is_explicit": false,
"track_image_uri": "https://geo-media.beatport.com/image_size/1500x250/....png",
"score": 12345.6,
"url": "https://www.beatport.com/track/opus/15744386"
},
"top_artist": {
"artist_id": 2863,
"artist_name": "Eric Prydz",
"genre": [
{ "genre_id": 15, "genre_name": "Progressive House" },
{ "genre_id": 90, "genre_name": "Melodic House & Techno" }
],
"downloads": 46024,
"latest_publish_date": "2026-01-28",
"artist_image_uri": "https://geo-media.beatport.com/image_size/590x404/....jpg",
"score": 189914.77,
"url": "https://www.beatport.com/artist/eric-prydz/2863"
}
}Distinct outcome shapes:
- Track match (artist + title query) —
tracks.data[0]is the intended track; populatetop_trackas above. - Artist match (bare artist query) —
artists.data[0]is the artist;top_artistcarries the ID, genre spread, and URL.tracks.datawill also be populated with that artist's most relevant tracks. - Multiple/ambiguous matches — return the full ranked arrays (or top N per bucket) and let the caller disambiguate via
score,mix_name, andartists[].artist_name. - No results — every bucket comes back empty (
tracks.data.length === 0, same for artists/releases/labels/charts). HTTP status is still 200; detect "not found" by empty arrays, not by status code:
{
"query": "zzzxqyqwlkjhgfd",
"locale": "US",
"result_counts": {
"tracks": 0,
"artists": 0,
"releases": 0,
"labels": 0,
"charts": 0
},
"top_track": null,
"top_artist": null
}