# Spotify Artist, Album & Playlist Scraper (`zinin/spotify-artist-intel`) Actor

Scrape one public Spotify artist, album or playlist URL: artist monthly listeners; album release year, type and track count; playlist item and save counts when Spotify exposes them. Server-rendered card lookup only — no search, tracks, audio features, history, login or API key.

- **URL**: https://apify.com/zinin/spotify-artist-intel.md
- **Developed by:** [Tim Zinin](https://apify.com/zinin) (community)
- **Categories:** Social media, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.70 / 1,000 entity delivereds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Spotify Artist, Album & Playlist Scraper: Read Public Spotify Cards by URL

![How this Actor turns your input into a decision](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/spotify-artist-intel/readme-hero.webp)
This Actor takes a Spotify artist, album or playlist URL you already have and scrapes its public card —
name, monthly listener count (artist), release year and track count (album), item count and save count
(playlist) — straight from the same server-rendered `application/ld+json` block Spotify's page ships
before any client-side JavaScript runs. No login, no API key, no Web Playback SDK, no browser.

This is deliberately narrow, and it runs backwards from almost every other Actor in this line. Most
scraping advice says "wear a browser User-Agent so the source doesn't block you." Here, that advice would
break the product: Spotify's card page only renders its data server-side for a plain, non-browser
request. A real browser signature gets an empty client-side shell — 156,405 bytes of "Spotify – Web
Player" with zero data — that expects a real browser to execute JavaScript this Actor never runs. This
Actor's request layer is built around exactly one non-negotiable rule: never send a browser User-Agent to
`open.spotify.com`, ever. That rule is a golden in `acceptance.json`, checked against this run's own
outgoing header, independent of what Spotify happens to return that day. See Evidence and boundaries for
the live measurement, and the first-pass recon mistake that made this rule necessary to spell out this
plainly.

The billable outcome is a **delivered card**: one row, `found:true`, where a name was actually extracted
from the page's own structured data. Everything else is free: a URL that doesn't resolve to a real
Spotify object (`found:false`, a genuine `HTTP 404` from Spotify itself — not a silent-empty result), a
non-Spotify host refused before any request is made, and a page that answered `HTTP 200` but carried no
usable `ld+json` block. This page shows the real JSON this Actor produced for an artist, an album, a
playlist, a blocked host and a genuine 404 — taken from runs made while this page was being written,
including five repeated fetches of the same playlist that show a live, disclosed inconsistency in
Spotify's own page. See Real happy, partial and failure output.

### What you get

- **Artist:** name and Spotify's own displayed monthly-listener figure.
- **Album:** name, release type, release year and track count.
- **Playlist:** name, item count and save count — with an honest disclosure that these last two can
  legitimately come back empty on the exact same URL across repeated fetches (see below; this is a real,
  observed property of the source, not a defect in this Actor).
- **The one rule that makes this Actor work at all: it never wears a browser User-Agent.** Confirmed on
  every successful row via the `requestUserAgent` field, which echoes the literal header this Actor sent
  — not a claim, a receipt, checked on every single row this build produces.
- **A genuine `HTTP 404` is never confused with an empty result.** Spotify answers a nonexistent object
  with a real 404, not a silent `HTTP 200` with nothing in it — this Actor checks the HTTP status before
  it ever tries to parse a page body, so the two failure modes other scrapers in this same hub have to
  guard against (silent-empty vs. genuine error) are simply not the same risk here. See Evidence and
  boundaries for the contrast, named explicitly.
- **No login, no API key, no Web Playback SDK, no browser runtime of any kind.**
- Runs on Apify: schedule it, call it from the API, the MCP server, or an n8n/agent workflow, export to
  JSON/CSV/Excel, or push results straight into your own pipeline.

### Who uses it

- **Music analytics and A\&R research teams** batch-checking artist monthly-listener figures across a
  watchlist, without opening each artist page in a browser.
- **Agencies tracking client or competitor releases** — album metadata (release year, track count) for a
  defined list of albums, refreshed on a schedule.
- **Playlist and campaign researchers** reading item and save counts for editorial or brand playlists
  they are monitoring, understanding that these two fields are the least stable ones this Actor reports
  (see Evidence and boundaries) and reading them as a best-effort snapshot, not a guaranteed number.
- **Developers and agent/workflow builders** who want one flat JSON row per target — `found`, `type`,
  `error` — that a script, an n8n node, or an LLM agent can branch on directly, without parsing Spotify's
  HTML or its `application/ld+json` shape by hand, and without accidentally breaking extraction by adding
  a browser-looking header somewhere upstream.
- **Not** for buyers who want to search Spotify by artist name, stream or download audio, or pull a full
  discography. This Actor accepts a URL you already have and reads that one page — see Limits for exactly
  what is out of scope.

![Where this Actor sits in your workflow](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/spotify-artist-intel/readme-workflow.webp)

### How to run

1. Populate **Spotify URLs** with one or more full URLs — `https://open.spotify.com/artist/<id>`,
   `/album/<id>` or `/playlist/<id>`. A bare ID without the URL is not accepted; the type is always read
   from the URL path, never guessed. Up to 50 per run.
2. Leave **Max concurrency** at its default of `5` unless you have a specific reason to raise it (up to
   `15`) or lower it (down to `1`).
3. Start the run from the Console, the Apify API, a scheduled Task, or an MCP client. The prefilled input
   (a real artist URL) is a verified, live target — a default run with no edits produces one real,
   non-empty row at essentially no cost, so you can see the actual data shape before committing a longer
   list.
4. Read the Dataset: one row per URL you supplied, always in the same 15-field shape regardless of
   outcome. Check `found` before anything else — only `found:true` rows are billed.
5. Check `requestUserAgent` on any row where you need to independently confirm this Actor behaved as
   documented — it is the literal header value this specific run sent, not a static claim on this page.
6. Check this run's key-value store record `OUTPUT` for a one-time run summary — requested/delivered/
   paid/free/failed/incomplete counts, replay safety and any fatal error — written once at the end of the
   run. The Dataset itself remains the complete, row-by-row authoritative record; `OUTPUT` is a roll-up
   of it.

### Pricing

**$0.005 per Actor start, plus $0.002 per delivered card.** The primary pricing noun on this page, in
the Dataset, and in this Actor's platform pricing record is the same throughout: the **delivered card**
— one row where a name was actually extracted from the target's own structured page data
(`found:true`). 100 delivered cards cost about **$0.205** (`$0.005` start + `100 × $0.002`).

Every other row is free: a target that doesn't resolve (a genuine `HTTP 404`), a non-Spotify host refused
before any request, and a page that answered `HTTP 200` with no usable `application/ld+json` block. A run
of 50 URLs where 10 are stale links no longer resolving is billed for the up-to-40 it actually delivered,
plus the one fixed start fee — never for the 10 it honestly reports as not found.

Both free outcomes are enforced in code, not left to a documentation promise: the charge and the dataset
write happen together, inside one mutex-protected step (`lib/billing.js`), and a row is never pushed to
the paid channel unless `found` is already `true` at that point. There is no code path in this build
where a `found:false` row is billed.

This Actor's own external data cost is close to zero — one GET request per target, no proxy, no LLM, no
paid third-party API — so the per-card price reflects the extraction and the User-Agent discipline
described on this page, not a resold data bill.

On Apify's own billing record these are metered as two separate platform events — `apify-actor-start`
for the run fee, and `result-found` for each delivered card — the same two lines you will see on your
invoice.

### Input contract

Exactly one field is required.

- **`targets`** — array of strings, 1 to 50 items, required. Each item must be a full URL —
  `https://open.spotify.com/artist/<id>`, `/album/<id>` or `/playlist/<id>` — with `open.spotify.com` as
  its host. The type (`artist`/`album`/`playlist`) is read directly from the URL path; this Actor never
  guesses it and never accepts a bare ID without the path. A URL whose host is not `open.spotify.com` is
  rejected with `found:false` and an explicit host-mismatch message, **before any network request is
  made** — verified live below, not just asserted.
- **`maxConcurrency`** — integer, 1 to 15, default `5`. How many targets this run reads in parallel.

Two runnable input examples, built directly from `.actor/input_schema.json` — these are input
illustrations, not run output; the Real happy, partial and failure output section below shows the actual
Dataset rows these kinds of inputs produced, from real runs of this exact build.

```json
{
  "targets": ["https://open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02"],
  "maxConcurrency": 5
}
```

*(This is the Actor's own prefilled default input — a real, verified artist URL, run at the default
concurrency. This is what a buyer sees and can run with zero edits.)*

```json
{
  "targets": [
    "https://open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02",
    "https://open.spotify.com/album/4yP0hdKOZPNshxUOjY0cZj",
    "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
    "https://open.spotify.com/artist/00000000000000000000ZZ"
  ],
  "maxConcurrency": 1
}
```

*(Mixes all three supported object types with one deliberately nonexistent artist id in the same run — to
show that a buyer does not need to pre-filter their own list before sending it in; the nonexistent one
simply comes back as a free, honest `found:false` row with a real `HTTP 404` behind it, alongside the
three real results. All four URLs in this example are the same ones this page's live evidence was
gathered against.)*

### Real happy, partial and failure output

Every row in the Dataset shares one flat, 15-field shape regardless of outcome — `input`, `found`,
`type`, `spotifyId`, `canonicalUrl`, `name`, `releaseType`, `releaseYear`, `trackCount`,
`monthlyListeners`, `itemCount`, `savesCount`, `error`, `requestUserAgent`, `checkedAt`. Every JSON
block below is copied verbatim from an actual local run of this exact
`main.js` build (`node main.js` with `CRAWLEE_STORAGE_DIR` set to a local storage folder), executed while
this page was being written, against the live Spotify site. Nothing here is invented or reconstructed
from the schema — every field value below is what the Dataset actually contained after that run
finished.

#### Happy path — a real artist card, real monthly-listener figure

```json
{
  "input": "https://open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02",
  "found": true,
  "type": "artist",
  "spotifyId": "06HL4z0CvFAxyc27GXpf02",
  "canonicalUrl": "https://open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02",
  "name": "Taylor Swift",
  "releaseType": null,
  "releaseYear": null,
  "trackCount": null,
  "monthlyListeners": "100.9M",
  "itemCount": null,
  "savesCount": null,
  "error": null,
  "requestUserAgent": "Mozilla/5.0 (compatible; spotify-artist-intel/0.1; +https://apify.com/zinin/spotify-artist-intel)",
  "checkedAt": "2026-08-17T20:32:57.777Z"
}
```

`requestUserAgent` is the literal header this run sent — a plain, declared, non-browser string with no
`AppleWebKit`, no `Chrome/`, no `Safari/` token anywhere in it. This is not a static claim: it is read
back from this exact run, and this Actor's acceptance suite fails immediately if this field ever contains
a real browser signature, independent of what Spotify's page happened to return that day.

#### Happy path — a real album card, release year and track count

```json
{
  "input": "https://open.spotify.com/album/4yP0hdKOZPNshxUOjY0cZj",
  "found": true,
  "type": "album",
  "spotifyId": "4yP0hdKOZPNshxUOjY0cZj",
  "canonicalUrl": "https://open.spotify.com/album/4yP0hdKOZPNshxUOjY0cZj",
  "name": "After Hours",
  "releaseType": "album",
  "releaseYear": 2020,
  "trackCount": 14,
  "monthlyListeners": null,
  "itemCount": null,
  "savesCount": null,
  "error": null,
  "requestUserAgent": "Mozilla/5.0 (compatible; spotify-artist-intel/0.1; +https://apify.com/zinin/spotify-artist-intel)",
  "checkedAt": "2026-08-17T20:32:58.331Z"
}
```

Both rows above are billed, delivered cards — `found:true`, real names, real type-specific fields.

#### Partial/disclosed path — the same playlist, five repeated fetches, two different honest shapes

This is the single most important thing to understand before buying playlist data from this Actor.
Spotify's own playlist page does not consistently carry the structured "N items · N saves" text this
Actor's parser reads. Five back-to-back fetches of the exact same URL,
`https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M`, made in immediate succession while writing
this page, produced **three** rows with the structured counts present and **two** rows where they came
back `null` — on the identical URL, the identical request, seconds apart:

```json
{
  "input": "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
  "found": true,
  "type": "playlist",
  "spotifyId": "37i9dQZF1DXcBWIGoYBM5M",
  "canonicalUrl": "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
  "name": "Today’s Top Hits",
  "releaseType": null,
  "releaseYear": null,
  "trackCount": null,
  "monthlyListeners": null,
  "itemCount": 50,
  "savesCount": "33.9M",
  "error": null,
  "requestUserAgent": "Mozilla/5.0 (compatible; spotify-artist-intel/0.1; +https://apify.com/zinin/spotify-artist-intel)",
  "checkedAt": "2026-08-17T20:33:07.527Z"
}
```

The fourth fetch of the same URL, roughly two seconds later, with `found` and `name` still both correct,
but `itemCount`/`savesCount` both honestly `null`:

```json
{
  "input": "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
  "found": true,
  "type": "playlist",
  "spotifyId": "37i9dQZF1DXcBWIGoYBM5M",
  "canonicalUrl": "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
  "name": "Today’s Top Hits",
  "releaseType": null,
  "releaseYear": null,
  "trackCount": null,
  "monthlyListeners": null,
  "itemCount": null,
  "savesCount": null,
  "error": null,
  "requestUserAgent": "Mozilla/5.0 (compatible; spotify-artist-intel/0.1; +https://apify.com/zinin/spotify-artist-intel)",
  "checkedAt": "2026-08-17T20:33:09.912Z"
}
```

Both rows are billed — `found:true` is the only billing gate this Actor has, and both rows genuinely
found the playlist and extracted its name. The difference between them is not a parsing failure on this
Actor's side; it is Spotify's own page inconsistently carrying the structured description text this
parser depends on for the count fields specifically. See Evidence and boundaries for the full accounting
of all five fetches and why this Actor does not treat a `null` count as an error.

#### Failure path — a non-Spotify host, refused before any request

```json
{
  "input": "https://evil.example/artist/fake",
  "found": false,
  "type": null,
  "spotifyId": null,
  "canonicalUrl": null,
  "name": null,
  "releaseType": null,
  "releaseYear": null,
  "trackCount": null,
  "monthlyListeners": null,
  "itemCount": null,
  "savesCount": null,
  "error": "host must be open.spotify.com — refusing to request \"evil.example\"",
  "requestUserAgent": null,
  "checkedAt": "2026-08-17T20:32:59.384Z"
}
```

`requestUserAgent` is `null` here, not the actor's declared string — this is deliberate confirmation that
no request was ever built or sent for this row, not just that the response was discarded.

#### Failure path — a genuine `HTTP 404`, not a silent-empty result

```json
{
  "input": "https://open.spotify.com/artist/00000000000000000000ZZ",
  "found": false,
  "type": "artist",
  "spotifyId": "00000000000000000000ZZ",
  "canonicalUrl": null,
  "name": null,
  "releaseType": null,
  "releaseYear": null,
  "trackCount": null,
  "monthlyListeners": null,
  "itemCount": null,
  "savesCount": null,
  "error": "http 404",
  "requestUserAgent": "Mozilla/5.0 (compatible; spotify-artist-intel/0.1; +https://apify.com/zinin/spotify-artist-intel)",
  "checkedAt": "2026-08-17T20:32:59.385Z"
}
```

`requestUserAgent` is populated here, unlike the blocked-host row above — this request genuinely reached
Spotify's servers and Spotify itself answered `404`. Both rows are free, but for different reasons: one
never left this Actor's process, the other was refused by the source.

### Field dictionary

| Field | Meaning | Important boundary |
|---|---|---|
| `input` | Exactly the URL you supplied, unmodified | Echoed even when the value was rejected before any request |
| `found` | The billing boundary | `true` only when a name was actually extracted from the page's own `ld+json` data |
| `type` | `artist` / `album` / `playlist`, read from the URL path | Set as soon as the URL parses, even on a row that later fails (e.g. the 404 example above still carries `type:"artist"`) |
| `spotifyId` | The object's id, taken from the URL | Same availability as `type` |
| `canonicalUrl` | The `@id`/`url` field from the page's own structured data, or the request URL as a fallback | `null` when no data was extracted |
| `name` | Title of the artist, album or playlist | `null` when `found:false` |
| `releaseType` / `releaseYear` / `trackCount` | Album-only fields | `null` for artist/playlist rows, and `null` for an album row if the source's description text didn't carry that specific piece |
| `monthlyListeners` | Artist-only — Spotify's own displayed figure (e.g. `"100.9M"`) | Not an exact internal count; a display string, not a number |
| `itemCount` / `savesCount` | Playlist-only | **Can legitimately be `null` even on a `found:true` row** — see the five-fetch evidence above; this is the source's own inconsistency, never invented when absent |
| `error` | Reason for `found:false` | `null` on every `found:true` row; states the real HTTP status or the local validation reason |
| `requestUserAgent` | The literal `User-Agent` header this run sent to Spotify | `null` only when no request was made at all (blocked-host row); otherwise always this Actor's fixed, declared non-browser string — checked, never a browser signature, by this build's own acceptance suite |
| `checkedAt` | ISO-8601 timestamp of this row's outcome | Per-row, not per-run |

### Evidence and boundaries

**The core mechanic, measured both directions.** A bare (non-browser) request to
`open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02` returns `HTTP 200`, roughly 260 KB, with a
`MusicGroup` `application/ld+json` block containing "100.9M monthly listeners." The identical URL
requested with a real Chrome User-Agent returns `HTTP 200`, roughly 156 KB, and **zero** `ld+json`
blocks — an empty client-side shell titled "Spotify – Web Player" that expects a real browser to run its
JavaScript and never gets one. This Actor's request layer sends the first kind of request, always, and
never the second — confirmed on every successful row above via `requestUserAgent`.

**Why this rule gets its own recon subsection: a first-pass mistake, disclosed plainly.** The first
attempt at researching this source rejected the idea outright — "no server-side content here" — because
every one of five separate test requests carried a browser User-Agent. Five repeats of one broken test
were mistaken for five independent confirmations of an absence. One single bare `curl`, with no `-A`
flag at all, immediately returned the full 260 KB of real data. The specific failure mode is worth
naming precisely: it is not that the earlier tests were wrong about what they measured, it's that all
five were the same corrupted measurement repeated, not five different ones. This is why
`acceptance.json`'s golden #2 checks this Actor's own outgoing `User-Agent` header directly, as an
invariant independent of any single day's response — a future edit that "helpfully" swaps in a browser
signature (the normal move almost everywhere else in this fleet) fails immediately, regardless of what
Spotify's page happens to return that day.

**The playlist instability, the full accounting of all five fetches, not just two.** Five consecutive
requests to the identical URL
(`https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M`), made back-to-back while writing this page:
fetch 1 returned the structured "50 items · 33.9M saves" form; fetch 2, structured; fetch 3, structured;
fetch 4, the bare curator-blurb form with `itemCount`/`savesCount` both `null`; fetch 5, also the blurb
form. Three of five structured, two of five not — on the same URL, same request shape, seconds apart, no
code change between fetches. `found` and `name` succeeded on all five without exception. This Actor's
own extraction code does not distinguish "the structured text is momentarily absent" from any other
description-text shape — it reads whichever description Spotify's page served that specific request, and
reports honestly whatever it finds, including nothing, for the two count fields specifically. This is
consistent with, but not identical in ratio to, an earlier recon note that reported four-of-five
structured on a different set of fetches — the point both measurements agree on is that this instability
is real and repeatable, not a one-off; the exact ratio on any given attempt is not something this Actor
promises to reproduce.

**Why `null` counts on a playlist are never treated as an error.** Given the instability measured above,
this Actor's design choice is to let `itemCount`/`savesCount` be honestly empty rather than either (a)
guessing a value from a cached prior fetch, or (b) marking the whole row `found:false` because two of
thirteen fields didn't populate. A buyer who needs those two specific fields for a specific playlist
should expect to re-run this Actor and treat a `null` result as "try again," not as "this playlist has no
saves."

**The negative case, honest and unambiguous — contrasted deliberately with a sibling Actor's source.** A
syntactically well-formed but nonexistent artist id
(`open.spotify.com/artist/00000000000000000000ZZ`) returns a genuine `HTTP 404`, roughly 6 KB, with zero
`ld+json` blocks — checked and recorded via the HTTP status alone, before any attempt to parse a body.
This is a meaningfully different shape from the "silent-empty" pattern this same hub's YouTube-playlist
Actor has to guard against, where a nonexistent playlist id returns `HTTP 200` with a real page and zero
results. Spotify does not do that for a nonexistent artist/album/playlist id — it answers with an honest
404\. This Actor's own code does not carry over the "200 might mean empty" suspicion from that sibling
Actor's source; the two sources genuinely behave differently, and pretending otherwise here would be
inventing a defense against a failure mode this specific source does not produce.

**The SSRF/host guard, confirmed live, not just read from source.** The blocked-host example above shows
the real outcome of a `targets` value pointing at a non-Spotify host: `found:false`, an explicit
host-mismatch message, and `requestUserAgent:null` — proof no request was built or sent, not just that a
response was discarded. Every target's host is checked against `open.spotify.com` before any network
call is made, and the DNS-rebinding/private-address pinning logic in `main.js`
(`resolveVerifiedAddresses`) guards the one real request path as defense in depth, following the same
pattern used across this hub's other Actors.

**Rights posture toward AI training crawlers, named plainly, not omitted.** `open.spotify.com/robots.txt`
grants `Allow: /` under the general `User-agent: *` rule (excluding only `/local/`, `/download/` and
`/embed/`, none of which this Actor ever requests) — but separately, and by name, issues `Disallow: /` to
`GPTBot`, `anthropic-ai` and `ClaudeBot`. This Actor does not identify itself as any of those crawlers
and is not one — it sends its own declared, non-browser identifier string, and operates strictly under
the general `User-agent: *` rule. The platform's position is stated here without softening: Spotify
permits ordinary crawling of these public card pages, and separately, explicitly does not want its
content used to train AI models. This Actor's product is the publicly displayed metadata of one page at
a time — a name, a listener count, a track count — delivered to a buyer who supplied the URL; it is not,
and does not present itself as, a corpus for model training.

### Decision routing

| Row shape | Billable? | What it means |
|---|---|---|
| `found:true`, all relevant fields populated | **Yes** | The full card was read cleanly |
| `found:true`, playlist with `itemCount`/`savesCount` both `null` | **Yes** | Name was extracted; the two count fields were honestly absent from Spotify's own page on this specific fetch — see Evidence and boundaries |
| `found:false`, non-Spotify host | No | Refused locally before any request; `requestUserAgent:null` proves no request was sent |
| `found:false`, genuine `HTTP 404` | No | Spotify itself answered "this object does not exist" — not a silent-empty result |
| `found:false`, `HTTP 200` but no usable `ld+json` | No | A possible page-format change on Spotify's side; reported honestly, never guessed at |

A workflow needs one rule to be correct: bill and use only `found:true` rows, and for playlist data
specifically, treat a `null` `itemCount`/`savesCount` on an otherwise successful row as "not available on
this fetch," not as a defect to report.

### Commercial playbooks

#### Artist watchlist for A\&R or catalog teams

An analyst maintains a list of artist URLs (prospects, catalog artists, competitors) and runs this Actor
on a schedule to track the monthly-listener figure Spotify itself displays, without opening each artist
page by hand.

#### Release-metadata tracking for agencies

An agency tracking a client's or a competitor's discography feeds in album URLs as they release, pulling
release year and track count into a catalog record without re-typing it from the page.

#### Playlist and campaign monitoring

A brand or campaign team tracking a specific editorial or owned playlist's item and save counts over
time, understanding upfront (per Evidence and boundaries) that these two fields can legitimately come
back empty on any given fetch and planning a retry-tolerant pipeline around that fact rather than
treating a single `null` result as a broken run.

### Integration recipes

#### One-shot batch lookup

Feed a list of artist/album/playlist URLs collected from any source — a spreadsheet, a search result
set, a manual list — into one run with `maxConcurrency` set to match the batch size (up to 15). Filter
the resulting Dataset to `found:true` before loading it into a catalog or spreadsheet.

#### Agent/MCP pattern

An agent that already has a specific Spotify URL (via search, a page it read, or a user-supplied link)
passes it directly into `targets`, checks `found` before treating any row as usable, and for playlist
rows specifically, does not treat a `null` `itemCount`/`savesCount` alongside a real `name` as a failure
to retry immediately — it is a valid, disclosed outcome of this specific source's own inconsistency.

#### Spreadsheet/catalog append pattern

Dataset rows are appended keyed by `spotifyId` for `found:true` rows, and by `input` + `checkedAt` for
`found:false` rows (which carry no reliable `spotifyId` on a blocked-host row). `null` fields are
preserved as `null`, not coerced into an empty string, so a downstream query can tell "Spotify did not
show this field on this fetch" apart from "this field was never populated."

### Operating guide

**Never add a browser User-Agent upstream of this Actor.** This is the single most important operating
note on this page. If you fork this code, wrap it in a proxy that rewrites headers, or route it through
any tooling that "normalizes" outgoing requests to look like a browser, this Actor's entire data path
breaks silently — you would get `HTTP 200` responses with zero usable data, not an error. The
`requestUserAgent` field on every row exists specifically so you can verify this did not happen to your
own run.

**Reading a playlist's `null` counts correctly.** A `found:true` playlist row with `itemCount:null` and
`savesCount:null` is not a partial failure to alert on — it is Spotify's own page not carrying the
structured description text on that specific fetch. If your use case needs those two fields reliably,
build a re-fetch into your pipeline rather than treating a single `null` result as final; the five-fetch
measurement above showed 3-of-5 structured on one run, which is a real ratio to plan around, not a
guarantee to expect exactly.

**Choosing `maxConcurrency`.** The default of `5` is a conservative starting point. Raising it (up to
`15`) speeds up a run with many targets; it does not change what any individual target's request
returns, since each target is fetched with exactly one request regardless of concurrency.

**Telling a blocked host apart from a genuine 404 in your own code.** Check `requestUserAgent`: `null`
means this Actor never sent a request for that row (host guard fired); a populated value alongside
`found:false` means the request reached Spotify and Spotify itself answered negatively (usually a 404).
Both are free, but only the second one means "Spotify says this object doesn't exist."

#### Related tools

Related tools for adjacent workflows in social platforms and regional directories.

| Actor | What it does |
|---|---|
| [YouTube Channel Intel](https://apify.com/zinin/youtube-channel-intel) | Pair it in the social platforms and regional directories workflow: Public intel card for any YouTube channel: subscribers, total views, video count, join date, country, full... |
| [YouTube Playlist Scraper](https://apify.com/zinin/youtube-playlist-scraper) | Pair it in the social platforms and regional directories workflow: Scrape any YouTube playlist by id or URL — including other people's public playlists, not just your own... |
| [YouTube Channel Lookalike Finder](https://apify.com/zinin/youtube-channel-lookalike-finder) | Pair it in the social platforms and regional directories workflow: Find the similar/lookalike channels YouTube itself shows on any channel's About page: channelId, name,... |
| [Bluesky Profile Scraper](https://apify.com/zinin/bluesky-profile-scraper) | Pair it in the social platforms and regional directories workflow: Batch-fetch public Bluesky profiles by handle or DID via the official keyless AppView API — display name,... |
| [Substack Publication Scraper](https://apify.com/zinin/substack-publication-scraper) | Pair it in the social platforms and regional directories workflow: Read recent posts from any Substack publication's own domain — title, date, full HTML body, comment count... |

### FAQ

#### Why does this Actor not use a browser User-Agent?

Because Spotify's card page only renders data server-side for a plain, non-browser request. A real
browser signature gets an empty client-side shell that expects JavaScript this Actor never runs — the
data disappears entirely. This is the opposite of most scraping advice, and it's deliberate — see
Evidence and boundaries for the measurement in both directions, and the recon mistake that made this
worth stating this plainly.

#### Why did my playlist row come back with `itemCount`/`savesCount` set to `null`?

Because Spotify's own page did not carry the structured "N items · N saves" description text on that
specific fetch — a live, repeated instability measured directly while writing this page: three of five
back-to-back fetches of the same URL had the structured text, two did not. `found`/`name` still succeed
in both cases; this row is still billed. Re-running the same target is a reasonable next step if you need
those two fields specifically.

#### Does this Actor search Spotify by name?

No — give it the exact artist/album/playlist URL, and it reads that page. Spotify's own search isn't
part of this Actor; see Limits.

#### What happens if the object doesn't exist?

A free, honest row: `found:false`, `error` naming a real `HTTP 404` from Spotify itself. This is not a
silent-empty result — see Evidence and boundaries for why this source behaves differently here than a
sibling YouTube Actor in the same hub.

#### What this Actor is NOT.

It does not stream or download audio, does not list a full discography, and does not read every track in
a playlist — only the card-level summary shown on the page itself. It does not search by artist name.

Found a wrong result, or need a check this Actor doesn't run? Open an issue on this Actor's page.

### Sources and rights

The pages this Actor reads are reachable by a plain, unauthenticated request, no browser session — that is precisely why it sends a non-browser User-Agent rather than pretending to be a browser.

**The only source.** The public HTML page at `https://open.spotify.com/artist/<id>`, `/album/<id>` and
`/playlist/<id>`, fetched with a plain, declared, non-browser `User-Agent`. No Spotify Web API, no
OAuth, no login session, no Web Playback SDK.

**`robots.txt`, checked fresh, not cached.** `open.spotify.com/robots.txt`: `User-agent: *` →
`Allow: /`, with only `/local/`, `/download/` and `/embed/` disallowed — none of which this Actor ever
requests. Separately, and by name, `GPTBot`, `anthropic-ai` and `ClaudeBot` are each issued `Disallow:
/`. This Actor is not any of those crawlers, identifies itself with its own declared, non-browser string,
and operates strictly under the general `User-agent: *` rule.

**The AI-training posture, stated without softening.** Spotify's own `robots.txt` explicitly closes the
door to named AI-training crawlers while leaving general crawling open. This Actor's product is the
publicly displayed metadata of individual pages — a name, a listener count, a release year, an item
count — delivered one target at a time to a buyer who already supplied the URL. It is not a bulk corpus
export and does not present itself as training data.

**No explicit reuse licence — unlike a government open-data feed.** Spotify publishes no equivalent of
an Open Government Licence or a CC0 grant for the content of its card pages. This Actor's engineering
posture is limited to what `robots.txt` permits to be crawled and what a genuinely non-browser request
receives; it does not constitute legal advice about how you may use the resulting data downstream, and
this page makes no claim of an explicit redistribution licence the way a government open-data source
would carry one. If your use case depends on a specific reuse right beyond what a robots.txt-permitted,
non-browser fetch implies, that determination is yours to make, not this Actor's to certify.

### Limits

- Reads exactly three object types — artist, album, playlist — from exactly one host,
  `open.spotify.com`. No track pages, no user-profile pages, no podcast/episode pages.
- No search by artist, album or playlist name. A full URL is required; the type is read from the URL
  path, never guessed, and a bare ID without a URL is rejected.
- **Playlist `itemCount`/`savesCount` can legitimately be `null` even on a `found:true` row** — a live,
  measured instability in Spotify's own page (3 of 5 repeated fetches structured, 2 of 5 not, on the
  identical URL). This is not a parsing defect; it is disclosed here precisely so a buyer does not
  mistake it for one. See Evidence and boundaries for the full accounting.
- `monthlyListeners`/`savesCount` are Spotify's own rounded display text (e.g. `"100.9M"`), never an
  exact internal number.
- No audio, no streaming, no preview clips — out of scope and out of the rights this Actor operates
  under (`/embed/` is disallowed by `robots.txt`).
- No full discography listing — an artist row gives the card-level snapshot only, not every release.
- No pagination of playlist contents — this Actor reads the card summary (item count, save count), not
  the list of tracks inside a playlist.
- This Actor sends a fixed, deliberately non-browser `User-Agent` on every request, by design — anything
  in your own infrastructure upstream of this Actor that rewrites or normalizes outgoing headers toward
  a browser signature will silently break extraction, not raise an error. See Operating guide.
- This Actor makes no claim about downstream redistribution rights over fetched card data beyond what is
  stated under Sources and rights — that determination is the buyer's to make.

### Support boundary

Actor support covers this Actor's own input validation, the User-Agent invariant, the SSRF/host guard,
the honest-404 negative case, and the Dataset schema described on this page — all backed by a live,
verified run referenced above. It does not cover whether a specific artist, album or playlist is a good
research target, does not restore access if Spotify changes this page's markup or its anti-bot posture in
a way that breaks extraction, does not guarantee a specific ratio of structured-vs-blurb playlist
description text on any given fetch (a disclosed, measured instability, not a bug this Actor can fix),
and does not provide legal guidance on how fetched card data may be reused downstream. When reporting a
problem, include the run id, the exact `input` URL, the `found`/`error` returned, and the `checkedAt`
timestamp of the affected row.

# Actor input Schema

## `targets` (type: `array`):

Full Spotify URLs, one per line — https://open.spotify.com/artist/<id>, /album/<id> or /playlist/<id>. A bare ID without the URL is not accepted (the type is read from the path).

## `maxConcurrency` (type: `integer`):

How many targets to read in parallel.

## Actor input object example

```json
{
  "targets": [
    "https://open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02"
  ],
  "maxConcurrency": 5
}
```

# Actor output Schema

## `results` (type: `string`):

API URL for the default dataset items produced by this run.

## `runSummary` (type: `string`):

One JSON object written once at the end of this run: requestedCount, deliveredRows, paidRows, freeRows, failedCount, incompleteRows, replaySafe + replaySafeReason, fatalError + fatalErrorReason, generatedAt.

# 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 = {
    "targets": [
        "https://open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("zinin/spotify-artist-intel").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 = { "targets": ["https://open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02"] }

# Run the Actor and wait for it to finish
run = client.actor("zinin/spotify-artist-intel").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 '{
  "targets": [
    "https://open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02"
  ]
}' |
apify call zinin/spotify-artist-intel --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,zinin/spotify-artist-intel"
        }
    }
}

```

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/us9IDOpyTV6dtKhkI/builds/N2id5vHBFASI8xb6u/openapi.json
