# Pinterest Scraper — Search, Boards & Profiles (`subimpact/pinterest-scraper`) Actor

Scrape Pinterest pins from keyword search, boards and profiles. Full-res originals, video variants, board/pinner metadata, repin counts. Pure HTTP - no browser, no login, no API key, no proxy.

- **URL**: https://apify.com/subimpact/pinterest-scraper.md
- **Developed by:** [subimpact](https://apify.com/subimpact) (community)
- **Categories:** Social media, Automation, E-commerce
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.99 / 1,000 pin scrapeds

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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

## Pinterest Scraper

Scrape Pinterest pins from **keyword searches**, **any public board**, or **any public profile**.

Pure HTTP against Pinterest's own `/resource/` API — no browser, no login, no API key, no proxy
required. A single `GET https://www.pinterest.com/` mints the whole session.

### Input

| Field | Type | Default | Notes |
|---|---|---|---|
| `mode` | `search` | `board` | `profile` | `search` | Which surface to scrape |
| `queries` | string | — | Comma-separated keywords (mode=search) |
| `boardUrls` | string | — | Comma-separated board URLs or `/user/board/` paths (mode=board) |
| `boardIds` | string | — | Comma-separated numeric board ids (mode=board, skips URL resolution) |
| `usernames` | string | — | Comma-separated usernames (mode=profile) |
| `maxPinsPerSource` | int | `500` | Cost ceiling per source. Hard cap 5000 |
| `imageSize` | `orig` | `736x` | `474x` | `orig` | Which variant lands in `imageUrl` |
| `downloadMedia` | bool | `false` | Store the file in the key-value store, billed per file |
| `enrichDetails` | bool | `false` | One extra request per pin for extra fields |
| `includeRaw` | bool | `true` | Keep the untouched pin object in `raw` |
| `proxyConfiguration` | object | off | Not needed — kept as an escape hatch |

`boardUrls` are resolved to a numeric `board_id` automatically by reading the board page.
`boardIds` skips that request, so use it if you already have the id.

### Read this before using search mode

**Pinterest's search payload contains images only.** A search result carries exactly seven fields:

```
id, node_id, type, images, image_signature, dominant_color, tracking_params
```

No `title`, no `description`, no `board`, no `pinner`, no `repin_count`. Those columns come back
`null` in search mode. Board and profile payloads *are* rich.

If you need text and attribution from search results, enable **`enrichDetails`** — but see the
caveat under *Pin detail* below. The Actor logs a warning when you run search mode without it.

### Output

One row per pin, in this column order:

```
runId, mode, source, page, pinId, pinUrl, title, description, link, domain,
imageSignature, imageOrigUrl, image736Url, image474Url, isVideo, videoUrls,
videoStatus, isRepin, repinCount, commentCount, boardId, boardName, boardUrl,
boardPinCount, boardFollowerCount, pinnerId, pinnerUsername, pinnerFullName,
pinnerFollowerCount, dominantColor, altText, isPromoted, priceValue, createdAt,
scrapedAt, image, imageUrl, videoUrl, mediaKey, mediaBytes, raw
```

Fields that are **always `null`** because Pinterest omits them from feed payloads:
`boardPinCount`, `boardFollowerCount`, `pinnerFollowerCount`. They are kept in the schema because
board *search* results do carry `pin_count`.

`title` and `description` are blank on the large majority of real pins — Pinterest simply does not
require them. **`altText` is usually the most descriptive text available**: it falls back to
Pinterest's auto-generated caption (`auto_alt_text`), e.g. *"an empty room with a radiator on the
wall"*.

### How it works

#### Session

`GET https://www.pinterest.com/` with a browser User-Agent sets `csrftoken` and `_pinterest_sess`,
and its SSR HTML embeds the build token at `"appVersion":"…"` inside `<script id="__PWS_DATA__">`.
Both are read at runtime, never hardcoded.

Every `/resource/` JSON response also echoes the live token at `client_context.app_version`, so the
client refreshes its own token from ordinary traffic for free.

#### The 403 headers

Requests to `/resource/{Name}/get/` need the right headers or Pinterest answers
`403 Invalid Resource Request`. Header ablation against the live endpoint (2026-09-08):

| Request | Result |
|---|---|
| User-Agent only | **403** |
| User-Agent + `x-pinterest-pws-handler` | 200 |
| Everything except `x-pinterest-pws-handler` | **403** |
| Everything, with a deliberately bogus `x-app-version` | 200 |
| Everything except `x-app-version` | 200 |
| Everything except `Cookie` | 200 |
| Everything except `X-Requested-With` | 200 |

**`x-pinterest-pws-handler: www/search/[scope].js` is the single load-bearing header.**
`x-app-version` is *not* validated today. The Actor still reads it at runtime and sends the full
browser-like header set, because it costs nothing and any of these can start being enforced.

#### Endpoints

| Mode | Resource | `data` shape |
|---|---|---|
| search pins | `BaseSearchResource` (`scope: "pins"`) | dict → `data.results[]` |
| search boards | `BaseSearchResource` (`scope: "boards"`) | dict → `data.results[]` |
| board pins | `BoardFeedResource` | **list** → `data[]` |
| profile pins | `UserPinsResource` | **list** → `data[]` |
| pin detail | `PinResource` | dict → single object |

That list-vs-dict split is the main parsing trap; `unwrapResource()` normalizes all of them.

#### Pagination

Page 2+ is the **same options object** plus `bookmarks: [<previous bookmark>]`. Paging stops when
the bookmark is missing, is `-end-`, repeats itself, or a page yields no new pin ids.

Correctness is measured with a **distinct-id rule**, not a sum of page sizes: Pinterest's page sizes
drift (24 / 25 / 26 per page) and a bookmark may legitimately re-serve an item.

#### Story modules

Board and search feeds interleave `type: "story"` items — "Related interests" rails, not pins. They
have no `images`, and their `title` is an **object**, so `String(item.title)` would write
`"[object Object]"` into your dataset. They are filtered out. `fixtures/board-small.json` is 7 raw
items = 5 pins + 2 story modules.

#### Video pins

`is_video` is **absent** from search payloads even on actual video pins, so detection uses
`videos.video_list`. That map holds `V_HLSV4` / `V_HLSV3_MOBILE` (`.m3u8` playlists, not
downloadable files) and `V_720P` (a real `.mp4`). `videoUrls` is sorted MP4-first, and
`downloadMedia` never tries to download a playlist.

#### Images

`images.orig.url` is the full-resolution original and is directly fetchable.

**Never build a variant URL by swapping the size segment of another one.** On ~11% of pins
(14 of 127 in the fixtures) the original is stored under a different extension than its
derivatives — `orig` is `.png` while `736x`/`474x` are `.jpg`. Always read the URL out of the pin's
`images` map. When a download fails the Actor falls back through the other variants it was given.

A `Referer: https://www.pinterest.com/` header is sent to `i.pinimg.com` because that is what a
browser does, but it is **not currently required** — referer / UA-only / no-headers all returned
identical statuses on 2026-09-08.

#### Pin detail (`enrichDetails`)

Unauthenticated `PinResource` returns a **reduced** field set — 27 keys with no `id`, `title`,
`description` or `images`. What you get is `seo_title`, `seo_description`, `image_medium_url`,
`pin_join` annotations and `closeup_unified_attribution`. Enrichment fills gaps only; it never
overwrites good feed data, and the pin id is recovered by base64-decoding `node_id` (`Pin:<id>`).

In practice it does recover real text — a search pin with a `null` title came back as
*"Cybersecurity aesthetic wallpaper | Cybersecurity wallpaper iphone, …"*.

#### Rate limiting

Requests are paced to ~3/s. Retries use exponential backoff on 403/429/5xx; a 403 also refreshes
the session before retrying. No 429 was observed at this rate.

### Fallback surfaces

Documented, **not** used by the main pump — both cap out and cannot paginate:

- **Pidgets** — `https://api.pinterest.com/v3/pidgets/users/{user}/pins/` returns clean JSON
  (no JSONP padding to strip). Caps at 50 pins. Board variant:
  `pidgets/boards/{user}/{board}/pins/`.
- **RSS** — `https://www.pinterest.com/{user}/feed.rss` returns real XML, 23 items, no pagination.
  Note its `<title>` elements are blank, same as the API.

Parsers for both ship in `src/parse.js` (`parsePidgets`, `parseRssItems`).

### Dead surfaces — do not use

- `PinSearchResource`, `SearchResource`, `/_ngjs/resource/*` → `403 Invalid Resource Request`
- `POST /_/graphql/` → needs a live browser session
- Board RSS (`/{user}/{board}/feed.rss`) and any `.rss` suffix → SPA HTML, not XML
- Profile/board SSR pages → the pin grid is client-rendered; no pin data in the HTML
  (the board page *does* still contain the board id, which is how `boardUrls` resolution works)

### Pay-per-event

| Event | When |
|---|---|
| `apify-actor-start` | Once at start |
| `pin-scraped` | Per pin pushed, charged **incrementally per batch** |
| `media-downloaded` | Per file stored, only when `downloadMedia` is on |

Every charge is wrapped in try/catch — a failed charge warns and the run continues.

### Development

```bash
npm install
npm test          # node --test, 90 tests, fully offline
```

Tests run against `fixtures/`, captured live on 2026-09-08. They never touch the network, and the
assertions are byte-exact so a failure means the code changed, not the site.

# Actor input Schema

## `mode` (type: `string`):

search = keyword search (use queries). board = every pin on a board (use boardUrls or boardIds). profile = every pin a user has published (use usernames).

## `queries` (type: `string`):

Comma-separated search keywords, e.g. `ai wallpaper, minimal interior`. Required when mode=search. NOTE: Pinterest's search payload contains images only — no title, description, board or pinner. Turn on "Enrich pin details" if you need those fields.

## `boardUrls` (type: `string`):

Comma-separated board URLs or paths, e.g. `https://www.pinterest.com/les_bons_details/interior-design/` or `/les_bons_details/interior-design/`. The numeric board id is resolved automatically from the board page. Used when mode=board.

## `boardIds` (type: `string`):

Comma-separated numeric board ids, e.g. `286612032474627954`. Faster than boardUrls (skips the id-resolution request). Used when mode=board.

## `usernames` (type: `string`):

Comma-separated Pinterest usernames, e.g. `ohuhu, nasa`. A leading @ and full profile URLs are accepted. Required when mode=profile.

## `maxPinsPerSource` (type: `integer`):

Maximum pins to collect per query / board / profile. This is your cost ceiling: pins scraped × the per-pin price. Hard cap: 5000.

## `imageSize` (type: `string`):

Which image variant to put in `imageUrl` (and to download when media download is on). `orig` is the full-resolution original.

## `downloadMedia` (type: `boolean`):

Download each pin's image (or MP4 for video pins) into the run's key-value store and link it from the dataset row. Billed per file downloaded. Off by default — the image URLs in the dataset are directly usable.

## `enrichDetails` (type: `boolean`):

Fetch each pin's detail endpoint for extra fields. Costs one extra HTTP request per pin, so runs take noticeably longer. Worth it in search mode; usually redundant in board/profile mode, where pins already arrive with full metadata. Without a login, the detail endpoint returns a reduced field set (SEO title/description, creator) — not the complete pin record.

## `includeRaw` (type: `boolean`):

Keep the untouched Pinterest pin object in the `raw` column. Turn off for smaller datasets.

## `proxyConfiguration` (type: `object`):

Not required — Pinterest's resource API responds normally to datacenter IPs. Kept as an escape hatch in case an exit IP gets throttled.

## Actor input object example

```json
{
  "mode": "search",
  "queries": "ai wallpaper",
  "maxPinsPerSource": 500,
  "imageSize": "orig",
  "downloadMedia": false,
  "enrichDetails": false,
  "includeRaw": true,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `pinId` (type: `string`):

Pinterest pin identifier

## `pinUrl` (type: `string`):

Canonical pin URL

## `title` (type: `string`):

Pin title (search results are image-only; use enrichDetails)

## `description` (type: `string`):

Pin description

## `link` (type: `string`):

Outbound URL the pin points to

## `domain` (type: `string`):

Link domain

## `imageOrigUrl` (type: `string`):

Full-resolution original image URL

## `imageUrl` (type: `string`):

Image at the requested size

## `isVideo` (type: `string`):

Whether the pin is a video pin

## `videoUrls` (type: `string`):

Direct video file URLs

## `repinCount` (type: `string`):

Number of repins at scrape time

## `commentCount` (type: `string`):

Number of comments at scrape time

## `boardName` (type: `string`):

Board the pin lives on

## `boardUrl` (type: `string`):

Board URL

## `pinnerUsername` (type: `string`):

Creator username

## `pinnerFullName` (type: `string`):

Creator display name

## `altText` (type: `string`):

Accessibility alt text

## `isPromoted` (type: `string`):

Whether the pin is a promoted ad

## `createdAt` (type: `string`):

Creation time on Pinterest

## `scrapedAt` (type: `string`):

Run timestamp

## `source` (type: `string`):

Query, board URL or username that produced this pin

# 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 = {
    "mode": "search",
    "queries": "ai wallpaper",
    "maxPinsPerSource": 500,
    "imageSize": "orig"
};

// Run the Actor and wait for it to finish
const run = await client.actor("subimpact/pinterest-scraper").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 = {
    "mode": "search",
    "queries": "ai wallpaper",
    "maxPinsPerSource": 500,
    "imageSize": "orig",
}

# Run the Actor and wait for it to finish
run = client.actor("subimpact/pinterest-scraper").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 '{
  "mode": "search",
  "queries": "ai wallpaper",
  "maxPinsPerSource": 500,
  "imageSize": "orig"
}' |
apify call subimpact/pinterest-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,subimpact/pinterest-scraper"
        }
    }
}
```

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/V0wqOBRDmp4ZiAFED/builds/NJcOlZXN6UYGhRo2D/openapi.json
