--- name: npm-download-stats-scraper description: Read npm download volume for any set of packages via the Apify Actor arman-bd/npm-download-stats-scraper. Returns one record per package with the window total, the day-by-day series, average per day, peak day and peak count, and optionally each package's share of the packages in the same run, already sorted into a ranking. Use when a task needs library benchmarking, adoption trend charts, OSS traction reporting, or dependency weighting by real ecosystem usage. Not for versions, dependencies, maintainers or any other registry metadata, and not a count of users. --- # npm Download Stats Scraper Apify Actor `arman-bd/npm-download-stats-scraper`. Give it package names and a window, get one dataset record per package, sorted by total downloads so the dataset is already a leaderboard. Counts only: for versions, dependencies and maintainers, use the npm Package Scraper instead. The Actor takes no credentials. ## When to use it - Benchmarking a library against its alternatives, with each one's share of the set. - Finding adoption inflection points by pulling a year of daily data and diffing week over week. - OSS traction reporting on a schedule, from the canonical count. - Weighting supply-chain or SBOM triage by how much the ecosystem actually pulls a package. - Feeding a chart directly: `dailySeries` needs no transformation. ## When not to use it - Package metadata. No versions, dependencies, maintainers, licences or repository links are returned. - Counting users or installations. Every CI run, mirror and container build counts, so the number is a relative signal over time, not an installed base. - History older than roughly 18 months. The window is silently trimmed rather than refused. - Per-version or per-country breakdowns. Neither is available here. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/npm-download-stats-scraper').call({ packages: ['react', 'vue', 'svelte', '@angular/core'], period: 'last-year', granularity: 'range', compareMode: true, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~npm-download-stats-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"packages":["express","fastify","koa"],"period":"last-month","granularity":"point","compareMode":true}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/npm-download-stats-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `packages` | string[] | yes | | Package names, scoped or plain. Duplicates removed before any request. An empty list aborts the run. | | `period` | string | no | `"last-month"` | One of `last-day`, `last-week`, `last-month`, `last-year`; or a single `YYYY-MM-DD`; or a range `YYYY-MM-DD:YYYY-MM-DD`. Anything else aborts the run before a request is made, with the valid forms in the error. | | `granularity` | string | no | `"range"` | `range` adds the day-by-day series. `point` returns the window total only. Any value other than `point` is treated as `range`. | | `compareMode` | boolean | no | `false` | Fills in `shareOfComparisonSet`. Only meaningful with two or more packages. | **The batching is what decides run cost, and scoping is what decides batching.** Plain names go 128 to a request, so a 500-package comparison is four requests and a couple of seconds. Scoped names starting with `@` cannot be batched and cost one request each, so a list of 200 scoped packages is 200 requests. Mixing the two is fine; the Actor splits the list itself and reports the total in `RUN_SUMMARY.requestsMade`. `granularity` does not change the request count, only the size of each record, so pick `range` whenever the question is trend-shaped and `point` when you only want the ranking. ## Output One record per package that returned data, sorted by `totalDownloads` descending. | Field | Type | Notes | |---|---|---| | `package` | string | Name as the source reports it. The join key. | | `period` | string | The normalised window you asked for. Not necessarily the window served, see `start` and `end`. | | `granularity` | string | `range` or `point`, echoed. | | `start` | string \| null | First day actually covered, `YYYY-MM-DD`. | | `end` | string \| null | Last day actually covered. | | `days` | number \| null | Length of the served window, inclusive of both ends. | | `totalDownloads` | number | Downloads across the whole window. | | `dailySeries` | object[] | `[{ day, downloads }]`, one entry per day. Empty array in `point` mode. | | `averagePerDay` | number \| null | `totalDownloads` divided by `days`, rounded to an integer. | | `peakDay` | string \| null | Busiest day in the series. `null` in `point` mode. | | `peakDownloads` | number \| null | That day's count. `null` in `point` mode. | | `shareOfComparisonSet` | number \| null | Fraction, not a percentage, rounded to 4 decimal places. `null` unless `compareMode` is on. | | `url` | string | The package's page. Built from the name you passed. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, series trimmed: ```json { "package": "express", "period": "last-week", "granularity": "range", "start": "2026-07-30", "end": "2026-08-05", "days": 7, "totalDownloads": 130006647, "dailySeries": [ { "day": "2026-07-30", "downloads": 22186799 }, { "day": "2026-07-31", "downloads": 19639130 }, { "day": "2026-08-01", "downloads": 10836680 }, { "day": "2026-08-02", "downloads": 10368617 }, { "day": "2026-08-03", "downloads": 21441612 }, { "day": "2026-08-04", "downloads": 22925745 }, { "day": "2026-08-05", "downloads": 22608064 } ], "averagePerDay": 18572378, "peakDay": "2026-08-04", "peakDownloads": 22925745, "shareOfComparisonSet": 0.8015, "url": "https://www.npmjs.com/package/express", "scrapedAt": "2026-08-06T11:40:59.376Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** With `compareMode` on it also tells you whether the shares you are about to quote were computed over the set you meant. ```json { "packagesRequested": 4, "seriesSaved": 3, "packagesFailed": 1, "failures": [{ "package": "not-a-real-pkg", "error": "no download data returned" }], "requestsMade": 2, "filters": { "period": "last-year", "granularity": "range", "compareMode": true }, "finishedAt": "2026-08-06T11:40:59.380Z" } ``` `seriesSaved` short of `packagesRequested` is explained entirely by `failures`, one `{ package, error }` per name that returned nothing. This matters more than usual in `compareMode`: shares are computed over the packages that resolved, so a dropped package silently inflates everyone else's `shareOfComparisonSet`. Check `packagesFailed` is `0` before quoting a share. `requestsMade` tells you how the list was split into batches. ## Behaviour to plan around - **The window served can be shorter than the window asked for.** History runs to roughly 18 months, and a longer range is quietly trimmed rather than refused. Always read `start`, `end` and `days` off the record; never assume `period` describes what you got. - **`shareOfComparisonSet` is relative to this run, not to the registry.** Four packages in a run means the four shares sum to 1. Adding a fifth package changes every other package's share. It is a fraction, so multiply by 100 to display. - **The dataset arrives pre-sorted by `totalDownloads`.** No ranking step is needed, and the input order is not preserved. Join back on `package`. - **`point` mode nulls three fields.** `dailySeries` is empty, `peakDay` and `peakDownloads` are `null`. `averagePerDay` still works, because it is derived from the total and the day count. - **Weekly seasonality is large and real.** A developer-tooling package routinely halves at the weekend: in the sample above, 10.8M on the Saturday against 22.9M on the Tuesday. Comparing two arbitrary days, or a `last-week` window that straddles a different number of weekends, will produce swings that mean nothing. Compare like-for-like windows, or smooth over seven days. - **A malformed period fails the whole batch, not one package.** The request carries up to 128 names, so when the source rejects it every name in that batch lands in `failures` with the same error. Do not read that as 128 missing packages. - **A missing package never aborts the run.** Unknown names come back empty inside a batch response, land in `failures`, and everything else is still saved. The Actor only throws when nothing at all resolved. - **Transient errors are retried** three times with linear backoff. A rejected period or a package with no data is final for that batch and is not retried. - **Scoped names cost one request each.** They cannot be batched. Keep scoped packages in their own run if request count matters to you. ## Recipes **Weekly leaderboard.** Point granularity keeps the dataset small; the sort makes it a ranking already. ```json { "packages": ["express", "fastify", "koa", "hapi", "@nestjs/core"], "period": "last-week", "granularity": "point", "compareMode": true } ``` Take the rows in order and quote `shareOfComparisonSet` beside each. Confirm `packagesFailed` is `0` first, or the shares are computed over the wrong set. **Trend chart for a competitive review.** A year of daily data, four frameworks. ```json { "packages": ["react", "vue", "svelte", "@angular/core"], "period": "last-year", "granularity": "range", "compareMode": true } ``` Plot `dailySeries` directly, smoothed over seven days so the weekend dip does not dominate the shape. **A specific release quarter.** An explicit range answers "what did the launch do" without you having to slice a longer series. ```json { "packages": ["zod", "yup", "joi", "valibot"], "period": "2026-01-01:2026-06-30", "granularity": "range", "compareMode": true } ``` Compare `averagePerDay` before and after the release date, and check `start` in case the range was trimmed at the far end. **Daily snapshot to build your own history.** One row per package per day, appended by a schedule. ```json { "packages": ["express", "fastify"], "period": "last-day", "granularity": "point" } ``` Key on `package` plus `end`, which is the day the count belongs to, so a re-run does not double-count.