You have a Pinterest pin URL and you want title, description, and save count. You don’t need a browser to scrape it. A logged-out pin page holds all three in a JSON-LD block, plus the pinner and the outbound link. Profile and board RSS feeds list pin URLs over plain HTTP.
But none of that reaches search. Pinterest builds the search grid in the browser and puts a login modal over it for logged-out visitors. The tiles that do render have no titles and no links.
TL;DR
A public pin parses over plain HTTP, while keyword search and full profile crawls need a managed API.
- The gate on search reads account state, not a bot score, so a stealth browser changes nothing.
- Older guides fail for two reasons:
pinWrappermatches nothing, and the record isn’t in the Redux store. - A datacenter IP gets the pin page without its JSON-LD block, so run from a residential address.
- Both free methods over one account’s boards reached 435 unique pins, out of more than 9,000 held there.
What you can extract from Pinterest
A pin record holds title, description, image or video URLs, hashtags, a category, a comment count, and the pinner’s profile URL. Profiles list follower counts, board names, and the pins a user has published.
The difficulty is access, not the data model. The Pinterest developer guidelines prohibit “any automated means or form of scraping or data extraction to access information from Pinterest”. The robots file is an allowlist, not a blocklist: it names hundreds of crawlers one by one and gives User-agent: * a Disallow: /. Any client it doesn’t name is not allowed.
So collect public data only, keep request rates low, and don’t sign in with a personal account to automate it. Pinner names and profile URLs are personal data under the GDPR, and the same guidelines prohibit using Pinterest material to train AI models. For anything beyond that, consult a professional.
What a pin page returns to plain HTTP
For a pin URL you already have, you don’t need a browser. A logged-out pin page embeds a JSON-LD SocialMediaPosting block, and that block is the record.
You need Python 3 and nothing else to start: json, re and urllib are all standard library. The first pip install comes later, when you turn a profile into board feeds.
Parse the block and you get the pin’s data in a few lines:
import json, re, urllib.request
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
BOILERPLATE = "Discover (and save!) your own Pins"
def scrape_pin(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
resp = urllib.request.urlopen(req, timeout=30)
html = resp.read().decode("utf-8", "ignore")
block = re.search(
r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>',
html, re.S)
if not block: # deleted pin: no JSON-LD block on the page
return None
data = json.loads(block.group(1))
# Recipe pins use name/description, everything else headline/articleBody
description = data.get("articleBody") or data.get("description")
if description and BOILERPLATE in description:
description = None # Pinterest filler text, not the pin's own words
saves = next((s.get("userInteractionCount")
for s in data.get("interactionStatistic") or []), None)
return {
"schema": data.get("@type"),
"title": data.get("headline") or data.get("name"),
"description": description,
"pinner": (data.get("author") or {}).get("name"),
"image": data.get("image"),
"date": data.get("datePublished"),
"source_link": (data.get("sharedContent") or {}).get("url"),
"saves": saves,
}
if __name__ == "__main__":
print(json.dumps(scrape_pin(
"https://www.pinterest.com/pin/99360735523397542/"), indent=2))
Run it and a live pin returns a complete record:
{
"schema": "SocialMediaPosting",
"title": "12 Bombshell Hair Color Ideas To Try This Summer | Ecemella",
"description": "A person with long, straight reddish-brown hair wearing a black leather jacket...",
"pinner": "Sarah Komnick",
"image": "https://i.pinimg.com/originals/0d/b9/56/0db956e2c07e1580514b1c19587b4152.jpg",
"date": "2023-05-24T22:32:51.000Z",
"source_link": "https://www.ecemella.com/summer-hair-color-ideas/",
"saves": 196
}
All of that comes from one request.
The record is in the JSON-LD block, not in the page’s Redux store. A second script, __PWS_INITIAL_PROPS__, sets initialReduxState.pins to an empty object. A scraper that reads the store finds nothing. And a deleted pin has no JSON-LD block at all, so scrape_pin returns None rather than a partial record.
Pinterest serves more than one JSON-LD type, and ignoring schema breaks a scraper silently. Across 105 pins, 100 returned SocialMediaPosting and 5 returned Recipe, and every one included a JSON-LD block.
The 105 came from two sources: 90 from the RSS feeds of 10 accounts, which returned no video pins at all. The other 15 came from a keyword search through the Scraper API, of which 10 were video. Video pins use the same SocialMediaPosting schema and parse without error.
The split between the two schemas is total rather than partial, which makes it easy to miss. In the 90-pin feed sample, all 85 social posts included headline, articleBody, datePublished, and sharedContent, and none of the 5 recipes included any of them. A recipe pin uses name and description instead. Reading headline alone returns nothing for the whole category, and leaves you with no date and no outbound link.
Video pins have a different problem. Their JSON-LD image is a .jpg still frame, and the block holds no contentUrl, duration, or embedUrl.
The video itself is an HLS manifest, the HTTP Live Streaming playlist format, on v1.pinimg.com. It appears in the page HTML rather than the structured block, so a video pin parses successfully and returns a thumbnail. Records from the Scraper API include video_length and a media URL directly, which identifies video pins without a second request.
The mix depends on the account, and it can reverse. Sampled separately, one food account’s feed returned 19 of 25 pins as Recipe. A headline-only parse would have returned an empty title for three quarters of them. Recipe pins add recipeYield and aggregateRating if those are useful to you.
On recipe pins the description is often a Pinterest filler string rather than anything the pinner wrote. The code returns None instead of treating it as content.
What actually breaks at volume
Pinterest puts Akamai in front of its pin pages, but nothing in the responses suggests Bot Manager is active on them. Twelve rapid requests sent with the default urllib User-Agent all returned 200. The responses set only csrftoken, _pinterest_sess, _auth, and _routing_id. None of the Akamai Bot Manager cookies appeared, and no sensor payload was served, so there was no TLS challenge to solve.
Across six client identities, the same pin served its JSON-LD block every time. Those included a Chrome User-Agent, curl, Googlebot, and an empty string. So the parse does not need a fake User-Agent.
That is true from a residential address. Across five pins, a residential fetch included the JSON-LD block every time and a datacenter fetch never did. Both returned the right page, the right title, and a similar number of scripts.
Pinterest states the difference in the page itself, under the context object in __PWS_DATA__. is_hosting read false on the residential side and true on the hosting side, with is_bot false on both. So the test is network reputation, not a bot score.
Run scrape_pin from a cloud VM and it returned None on every pin tested, with nothing on the page to say why. A fingerprinting client doesn’t change the address you run from.
Your choice of HTTP client matters more than any of this once you’re paying for traffic. The standard-library urllib sends no Accept-Encoding header, so the server replies uncompressed. The stdlib example downloads about 1.13 MB per pin.
The requests library negotiates gzip by default and transfers about 183 KB. A browser offering Brotli gets about 124 KB. That is the same page and the same data, with a 9x difference in transferred bytes decided by which client you chose.
That matters because proxies bill transferred bytes. At 10,000 pins the stdlib example moves 11.3 GB, compared with 1.83 GB for the same job through requests. At the list residential rate of $8/GB, that is about $90 instead of $15. Use the stdlib version to learn how it works, then switch to requests or curl_cffi for anything you actually run.
IP reputation gets worse at volume, not your TLS fingerprint. So rotating through residential addresses helps here, while a fingerprinting client mostly doesn’t. If you later find a Pinterest page that does check the handshake, curl_cffi (library docs) is the standard replacement and leaves the parse untouched:
from curl_cffi import requests as cffi # pip install curl_cffi
resp = cffi.get("https://www.pinterest.com/pin/99360735523397542/",
impersonate="chrome", timeout=30)
html = resp.text # feed this to the same JSON-LD parse
Two limits apply. It does not give access to the internal Pinterest endpoints. PinResource returned 403 to both plain urllib and a Chrome-impersonating client, so the fingerprint is not the problem there. And it can’t answer a JavaScript challenge if one is served.
The official oEmbed endpoint
Pinterest publishes an oEmbed endpoint for public pins. It is the lightest single-pin lookup here, and it answers with JSON rather than a page of HTML. This call uses curl, which is included with macOS, Linux and Windows 10 and later. You can also test it by pasting the URL straight into a browser first:
PIN="https://www.pinterest.com/pin/99360735523397542/"
curl "https://www.pinterest.com/oembed.json?url=$PIN"
It answers with attribution, a thumbnail, and a ready-made embed:
{"version":"1.0","type":"rich","provider_name":"Pinterest","title":" ",
"author_name":"Sarah Komnick",
"author_url":"https://www.pinterest.com/sarahkomnick/",
"thumbnail_url":"https://i.pinimg.com/236x/0d/b9/56/0db956e2c07e1580514b1c19587b4152.jpg",
"thumbnail_width":236,"thumbnail_height":314,
"html":"<iframe src=\"https://assets.pinterest.com/ext/embed.html?id=99360735523397542\" ...></iframe>"}
Of the routes you run yourself, this has the least exposure. oEmbed is the standard format sites publish so third parties can embed their content. It is a published interface, not an internal API.
It also accepts board and profile URLs, not only pins. It returned a real title for both, where the pin call left it empty. A description, a save count, and an outbound source link are not in the response.
Pinterest returns soft 404s for its pin pages. A deleted pin, a nonexistent numeric ID, and a malformed slug all returned HTTP 200. So did a truncated ID and an unused numeric ID, all with the JSON-LD block absent. Any check built on response.status measures nothing.
The oEmbed endpoint answers HTTP 400 instead. It did so in all 5 of those cases, while a live pin returned 200.
It transfers a median 583 bytes across 19 pins, compared with about 124 KB for the page. That makes it the cheap way to filter a URL list before you parse anything. 80 consecutive calls with no delay all returned 200, with no rate limiting and no change in latency.
Discover pin URLs without the search grid
Both the JSON-LD parse and oEmbed need a pin URL you already have. So where do the URLs come from?
One route looks obvious and isn’t. The Pinterest robots file declared 50 Sitemap: URLs under /v3_sitemaps/ when checked. The names describe exactly what a collector would want: image_pin_sitemap_recent_engagement_tier_1, video_product_pin_sitemap, several levels of board link sitemap. All 50 returned HTTP 404.
A declared sitemap is worth one request before you rely on it.
For a profile or a board, the URLs come from RSS. Pinterest still publishes unauthenticated feeds at /<username>/feed.rss and /<username>/<board-slug>.rss:
import re, urllib.request
import xml.etree.ElementTree as ET
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
def list_pins(feed_url):
req = urllib.request.Request(feed_url, headers={"User-Agent": UA})
xml = urllib.request.urlopen(req, timeout=30).read()
pins = []
for item in ET.fromstring(xml).findall(".//item"):
desc = item.findtext("description") or ""
thumb = re.search(r'src="([^"]+)"', desc)
pins.append({
"url": item.findtext("link"),
"published": item.findtext("pubDate"),
"thumbnail": thumb.group(1) if thumb else None,
})
return pins
if __name__ == "__main__":
feed = list_pins("https://www.pinterest.com/boredpanda/animals.rss")
print(f"found {len(feed)} pins")
print(feed[0])
One board feed returned a usable list of real pin URLs:
found 26 pins
{'url': 'https://www.pinterest.com/pin/150307706316766946/',
'published': 'Thu, 23 Jul 2026 12:14:04 GMT',
'thumbnail': 'https://i.pinimg.com/236x/18/a4/8b/18a48b04fbe868130b0a2ad14759fe87.jpg'}
Open one of those feed URLs in a browser and the structure is easy to read. Each <item> holds the pin link, a publish date, and a thumbnail in the description:

The empty <title> tags are the reason the feed is a source of URLs rather than metadata. One item in this feed has a real title; the rest are blank.
Chaining that with the per-pin parse gives you a complete pipeline that never loads a browser. Save scrape_pin and list_pins together as pinterest.py, and this imports them unchanged:
import time, json
import xml.etree.ElementTree as ET
from pinterest import scrape_pin, list_pins # from pinterest.py
def collect_feed(feed_url, delay=0.5):
records = []
try:
pins = list_pins(feed_url)
except (OSError, ET.ParseError) as err: # skip a bad feed, keep going
print(f"skipped feed {feed_url}: {err}")
return records
for pin in pins:
time.sleep(delay) # before the request, so skips stay polite too
try:
record = scrape_pin(pin["url"])
except (OSError, ValueError) as err: # transient: keep what you have
print(f"skipped {pin['url']}: {err}")
continue
if record is None: # deleted pin: the page soft-404s to 200
continue
record["url"] = pin["url"]
record["feed_published"] = pin["published"]
records.append(record)
return records
rows = collect_feed("https://www.pinterest.com/foodnetwork/feed.rss")
print(f"{len(rows)} pins enriched")
with open("feed-pins.json", "w") as f:
json.dump(rows, f, indent=2)
On a food account it printed 25 pins enriched in 46 seconds. All 25 had a title, and the schema mix was 19 recipes to 6 social posts. Feeds were available on all 12 accounts tested, from single creators to large brands. Profile feeds returned 22 to 25 items and board feeds 23 to 26.
The feed has a fixed limit rather than growing with the board. A board reporting close to 6,000 pins returned 24 items, the same as a board holding 39. Below that limit you get close to what is there: a board holding 6 pins returned 5.
Turn a profile into a set of board feeds
One account has more than one feed, and the profile page lists them publicly, with names and pin counts and no login required:

Search gets a login modal; a profile doesn’t. These board names are the slugs list_boards reads from the page HTML. The pin counts tell you which boards will reach the feed’s limit and which are small enough for it to return most of them.
Board slugs usually appear in the profile HTML over plain HTTP, so a regex turns an account name into a set of feed URLs:
import re, requests # pip install requests
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
RESERVED = {"pins", "boards", "followers", "following"} # routes, not boards
def list_boards(handle):
html = requests.get(f"https://www.pinterest.com/{handle}/",
headers={"User-Agent": UA}, timeout=30).text
found = re.findall(rf'"/{handle}/([A-Za-z0-9][A-Za-z0-9_-]*)/"', html)
slugs = set(found) - RESERVED
base = f"https://www.pinterest.com/{handle}"
return [f"{base}/{s}.rss" for s in sorted(slugs)]
feeds = list_boards("boredpanda")
print(f"{len(feeds)} board feeds")
print(feeds[0])
One account returned nine board feeds:
9 board feeds
https://www.pinterest.com/boredpanda/animal-memes.rss
Alongside the board list, logged-out profile HTML embeds a ProfilePage JSON-LD block. It parsed on all 8 accounts tested.
It gives the display name, the handle, the bio, the avatar URL, and the account’s creation date. A sameAs array points at the owner’s other profiles, which on these accounts meant Instagram and their own domain. Six of the 8 also included a follower count under interactionStatistic, the one for Etsy reading over 11 million. That is the same parse as the pin page, aimed at a different URL.
Running all 9 board feeds through list_pins returned 208 unique pin URLs from boredpanda, compared with the 25 a single feed gives. No pin appeared in two board feeds. Every account tested listed its boards this way: 10 out of 10, between 8 and 10 slugs each. The first board’s feed returned items on all 10.
Those 9 are the boards the profile page shows, not the account’s full set. The API profile record reports 51 for the same account. Plain HTTP reaches 9 of those 51, each through the same 25-item limit.
The animals feed used earlier is one it misses, so a board you already know about is still worth requesting directly. A live profile can also return no slugs at all. Treat an empty list as a signal to check the handle, not as proof the account has no boards.
Feed titles were empty on all except one item across the feeds tested. Let the per-pin parse supply the rest.
Two routes past the feed limit
The profiles scraper in the Scraper API reaches the board list from the other direction. boards_num reported 51 boards for boredpanda, so you know the size of the set before you start.
Every entry in its saved array includes a saved_collection_url, and that URL plus .rss returned a feed on all 6 boards tested. The array itself returned 10 of those 51, another partial list.
Strip the trailing slash before you append. saved_collection_url ends with one, and Pinterest treats the two resulting forms very differently. Ending in quotes.rss returns a 13 KB feed.
Ending in quotes/.rss returns HTTP 200 and the HTML app instead. That fails inside your XML parser with an invalid-token error pointing at a line of JavaScript. Build the URL with saved_collection_url.rstrip("/") + ".rss" and that form can’t reach the parser.
The older widget backend at widgets.pinterest.com/v3/pidgets/ also answers unauthenticated requests, and on most boards it returned more than RSS did. Its board endpoint returned 50 pins where the same board’s feed returned 26, each with a description. It also reports a board-level pin_count, so you get a sense of what you are missing.
Fifty is a limit rather than a page size. page, offset, limit, count and page_size were all ignored, each returning the same 50. Across nine boards on boredpanda the widget backend reached 362 unique pins, compared with 208 from the board feeds. The two together reached 435, out of more than 9,000 held on those boards.
Three things before you rely on it. Its repin_count is not the JSON-LD save count. Read together on the pin used throughout this guide, it returned 77 where the JSON-LD returned 196. Only the JSON-LD figure is the one Pinterest labels Saves.
Outbound links vary by board: 50 of 50 pins on one, 5 of 43 on another. Descriptions arrived on almost every pin. And it is not always better: on one small board RSS returned 9 to its 8. The endpoint is undocumented, has no search, and has no compatibility promise.
Past that fiftieth pin, or on a schedule, the profile crawl in the Scraper API runs across the account as a background job. It returns a structured record for each pin it finds.
Discovery in the Scraper API works at the profile level, so pass the profile URL and filter the records to the board you want. On two boards tested, a board URL returned the profile rather than the board named in the request.
Why the free methods stop at search
Fetch a search results page and the HTML has zero JSON-LD blocks. The grid is built by later browser requests, and those are gated for logged-out sessions. As of September 2026, the __PWS_DATA__ block lists ten lop_unauth_lockdown_* experiments, _main, _closeup and _search among them.
Two of the ten name the routes this guide uses: lop_unauth_lockdown_profile and lop_unauth_lockdown_board. Logged-out gating is a running experiment, not a permanent part of the site. So treat profile and board access as something to watch rather than assume.
The same block shows what the gate checks, because Pinterest records its own view of your client in it. Under context, pin, search and profile pages all include the same 15 classification fields. Those include is_bot, is_proxy, is_vpn, is_tor, is_hosting, is_unauth_botspam_asn and is_authenticated.
Requested from an ordinary residential address, is_bot returned "false" and every proxy and hosting flag returned false. The search grid was gated anyway. The lockdown experiments read logged-out state, not a bot score.
is_hosting separated the fetches that returned a pin’s JSON-LD from the ones that arrived without it. So read context from the <script id="__PWS_DATA__"> tag on a single pin page. That is the cheapest way to confirm the address you collect from looks the way you expect. Do it before you waste a run blaming a low record count on your parser.
It is undocumented and internal, not a promise Pinterest makes, so treat it as a check you repeat. The object also holds an unauth_id and your IP’s country and region. Mask those before the data leaves your machine.
That check is the JSON-LD parse aimed at the other script tag:
import json, re, urllib.request
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
FLAGS = ("is_bot", "is_proxy", "is_vpn", "is_tor", "is_hosting",
"is_unauth_botspam_asn", "is_authenticated")
def how_pinterest_sees_you(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
resp = urllib.request.urlopen(req, timeout=30)
html = resp.read().decode("utf-8", "ignore")
block = re.search(
r'<script id="__PWS_DATA__" type="application/json">(.*?)</script>',
html, re.S)
if not block:
return None
ctx = json.loads(block.group(1)).get("context") or {}
# this object also holds unauth_id and your IP geo: don't return those
return {k: ctx.get(k) for k in FLAGS}
print(how_pinterest_sees_you(
"https://www.pinterest.com/pin/99360735523397542/"))
Run it from a home connection and from a cloud VM and one field moves:
# residential
{'is_bot': 'false', 'is_proxy': False, 'is_vpn': False, 'is_tor': False,
'is_hosting': False, 'is_unauth_botspam_asn': False, 'is_authenticated': False}
# same pin, same parse, hosting range
{'is_bot': 'false', 'is_proxy': False, 'is_vpn': False, 'is_tor': False,
'is_hosting': True, 'is_unauth_botspam_asn': False, 'is_authenticated': False}
is_bot arrives as the string "false" while the other flags are real booleans, so compare it as a string. is_hosting is the field that moves, and it decides whether the pin page includes a JSON-LD block.
The internal JSON endpoints older guides call are closed as a class, not one at a time. PinResource, BoardFeedResource, UserResource, BaseSearchResource and RelatedModulesResource all returned HTTP 403 to unauthenticated requests. That was true with or without a TLS fingerprint that matches a real browser.
Requests pass through Akamai as a CDN. The same cookie check on search and profile pages showed no Bot Manager markers there either.
Why the official Pinterest API isn’t a scraping tool
Pinterest does run a REST API, and it’s tempting to try it first. It won’t collect public content at any useful scale, and the reason is scope, not quality.
The API v5 is built for managing your own account: your boards, your pins, your ads, your analytics. The org_read category applies to “Fetching user accounts, boards, board sections, or Pins” for the authenticated account. Apart from the partner search beta, no endpoint returns a public pin you don’t own.
The one keyword search is GET /v5/search/partner/pins, a beta “not available to all apps” that returns the top 10 pins. The data most scraping projects want, other people’s public pins and profiles, isn’t reachable through the API.
That same page also publishes the rate limits. As of September 2026, trial access allowed 1,000 requests per day across all endpoints. Standard access allowed 100 requests per second per user per app. Pinterest notes there that all of them are “subject to change without notice”.
For third-party public data at scale, the official API is the wrong tool. So collection beyond the plain-HTTP routes needs browser automation or a managed scraper.
Scrape Pinterest with Playwright
Search is the one route that needs a browser to render the page, and Playwright drives one from Python. If you haven’t used it before, the Bright Data blog has a longer Playwright walkthrough whose API works the same way.
Know what this route returns before you install it: image URLs and nothing else, with no titles, no descriptions and no pin links. If that isn’t enough for your use case, read Where the browser approach breaks and skip the install.
Install the library and a browser:
pip install playwright
playwright install chromium
This script opens a search page, scrolls, and reads the pin tiles. One detail matters first: the tile selector changed. Older guides target div[data-test-id='pinWrapper'], which matches nothing on the logged-out site today. The current logged-out grid uses gated-pin-rep for each tile and gated-pin-image for its image:
import asyncio, json
from playwright.async_api import async_playwright
BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
async def scrape_pins(query, scrolls=6):
url = f"https://www.pinterest.com/search/pins/?q={query}&rs=typed"
seen = set()
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page(user_agent=BROWSER_UA,
viewport={"width": 1280, "height": 900})
await page.goto(url, wait_until="domcontentloaded")
for _ in range(scrolls):
await asyncio.sleep(1.5)
images = await page.query_selector_all(
"[data-test-id='gated-pin-image'] img")
for img in images:
src = await img.get_attribute("src")
if src and "/236x/" in src: # pin thumbnails, not UI images
seen.add(src)
await page.mouse.wheel(0, 4000)
await browser.close()
return sorted(seen)
async def main():
results = await scrape_pins("minimalist%20office")
print(f"collected {len(results)} thumbnail URLs")
with open("office-pins.json", "w") as f:
json.dump(results, f, indent=2)
if __name__ == "__main__":
asyncio.run(main())
Setting a User-Agent is the most repeated piece of Pinterest scraping advice, and it doesn’t do what people claim. No override, a current Chrome string, a two-year-old Chrome string, and a literal python-requests/2.32.3 all return HTTP 200 and the same tile count. Each one returned no pin links and the same login modal. Keep a sensible one anyway, since it costs nothing and identifies your traffic honestly.
The set matters because Pinterest reuses tile nodes as you scroll. Run it and one logged-out session prints:
collected 16 thumbnail URLs
So Playwright returns thumbnail URLs from search, and nothing beyond them.
Get the full-size image from a Pinterest thumbnail
The thumbnails those runs return are 236 pixels wide. If you already have the pin’s JSON-LD or an API record, the full-size URL is in it and no rewriting is needed. Working from a thumbnail instead, the full-size file is at the same path with the size segment replaced. That swap is usually written as a one-line string replace, and that alone mostly fails.
Across 40 thumbnails from live feeds, replacing /236x/ with /originals/ and keeping the .jpg extension worked 5 times. It returned HTTP 403 the other 35. Pinterest re-encodes: the thumbnail is served as .jpg while the stored original is often .png. Trying the extensions one after another worked for all 40.
The file you download is someone else’s photograph, so whatever you do with it is a licensing question rather than a scraping one:
import re, urllib.request
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
def full_size(thumb_url):
"""Pinterest re-encodes, so the original's extension often differs."""
base = re.sub(r"/\d+x\d*/", "/originals/", thumb_url).rsplit(".", 1)[0]
for ext in ("jpg", "png", "webp", "gif"):
candidate = f"{base}.{ext}"
req = urllib.request.Request(candidate, method="HEAD",
headers={"User-Agent": UA})
try:
if urllib.request.urlopen(req, timeout=15).status == 200:
return candidate
except Exception:
continue
return None
Don’t change the order, though, because which extension works varies by account. One sample of 40 returned .png 35 times and .jpg 5. A later sample of 40 across four accounts returned .webp 19, .jpg 15 and .png 6. Within that, one account was entirely .jpg, another entirely .webp, and a third mostly .png.
Plan for about two HEAD requests per image: on that four-account sample the loop averaged 2.1 lookups before it found the file.
Where the browser approach breaks
Playwright misses the rest of the record. Every tile’s alt text is the literal string Pin, so the tiles have no titles. None of them wrap a /pin/ link either, so the grid gives you nothing to follow.
You get a short list of image URLs and nothing else: no descriptions, no pinner, no saves, no hashtags. Loading the same search in a normal logged-out browser shows why:

The tiles around the modal are the same gated-pin-rep elements the script reads. Each one holds an image and nothing else, which is why the run returns URLs and no titles.
Scrolling more changes nothing. The grid holds the same count after 3 scrolls as after 25, and the set of thumbnail URLs never grows past it.
That count is steady inside a session and varies between them. Repeat runs in one session returned 17 every time for minimalist office and 15 every time for fall fashion. Separate sessions on those same two queries returned 16, 17 and 18.
Expect a fixed count somewhere in the mid to high teens. Treat the exact number as specific to the session rather than a number to rely on. A login modal covers the feed and blocks the infinite scroll a signed-in session gets, with a real password field right there in the DOM.
Why a stealth browser doesn’t fix it
The natural next move is a stealth browser, and it’s the wrong diagnosis for a gated grid. Unmodified Playwright does show automation signals at the protocol level, most visibly the Runtime.enable call in the Chrome DevTools Protocol, and patched versions hide them. Patchright is a direct replacement for the Playwright package, and it is built to hide those signals without touching your script. Camoufox is a separate Firefox build rather than a patch to Playwright.
Pointed at the same query with a rendering browser and its own proxy pool, one commercial unblocker returned the logged-out page in full. That meant the sign-up prompts, the category buttons, the filter bar, and not one pin title or /pin/ link.
Run Patchright side by side with unmodified Playwright on the same two queries in one session and nothing moves. Both returned 16 tiles for minimalist office and 15 for fall fashion. Both returned zero /pin/ links, Pin as every tile’s alt text, and the same password field in the DOM.
An account-state gate limits the logged-out feed, not a bot score. No Bot Manager cookies appear on search or profile pages. The ten lop_unauth_lockdown experiments are live in __PWS_DATA__ as of September 2026, and the tile count is unchanged across four User-Agents.
What it takes to go further
To reach descriptions, links, and engagement across a search with your own browser, you need a session that Pinterest treats as a real logged-in user. That means signing in and holding cookies. Automating a personal login breaks the platform’s terms, which prohibit accessing Pinterest data “by using automated means (without our express prior permission)”.
Volume is the other constraint, and on this target it’s IP reputation rather than fingerprinting. Around 250 requests from one address produced no rate limiting and no challenge, so the limit is higher than a first attempt suggests. Running continuously from a single address changes that, and it’s the first of three problems a durable scraper has to handle.
What a durable scraper needs
Getting one screen of thumbnails is a demo. Collecting thousands of complete pin records on a schedule is an infrastructure problem.
- IP diversity. Residential proxies spread the same job across many consumer IPs instead of one datacenter address. Of the three, the evidence here makes this the one worth paying for, and not only at scale. A hosting-classified address gets pin pages with the JSON-LD block absent, so the parse only works from consumer IPs. Rotation matters beyond that, once a single address runs continuously.
- Fingerprint and challenge handling. A managed browser such as the Browser API is built to present a consistent real fingerprint. It also answers the challenges that plain headless Chromium leaves unanswered, which a pipeline needs the moment it points at a site that enforces one. The public Pinterest pages are the easy case: no Bot Manager cookies, no sensor payload, and no challenge across six client identities.
- Maintenance. Selectors and gating change without notice, as the
pinWrappertogated-pin-repshift and the lockdown experiment both show. Scrapling is built to find elements again by similarity when class names change often, which helps with the parser half of the problem.
You can build all of this, and the general patterns for keeping a scraper from getting blocked apply here the same as anywhere.
The trade-off is that this becomes ongoing engineering work, not a script you write once. When the maintenance costs more than owning the pipeline is worth, you can move it to a managed scraper API.
Scrape Pinterest with the Bright Data Scraper API
The Bright Data Pinterest Scraper API moves the browser, proxy, and unblocking layer to a managed service. You send a pin URL, a keyword, or a profile URL, and it returns a structured record. It’s part of the Web Scraper API, with a free monthly allowance and then pay-as-you-go per delivered record. Every Scraper API example in this guide ran inside that allowance.
Use the Pinterest scraper here rather than a general-purpose unblocker: it returns parsed records rather than raw HTML. General-purpose access modes are a separate route for any URL across sites. Which of them are available varies by account. It also varies by zone, the named configuration you create for each product.
Bright Data applies the robots allowlist described earlier in this guide at that layer. One of those modes declined a Pinterest request outright on exactly that basis.
Setup is two steps. The Bright Data account opens without a card, and you create the API key yourself in your account settings. A new account has no keys yet, so the API keys table starts empty:

Add a key and the dialog asks for a user, a permission level, and an expiry.
Pick the permission level meant for API access rather than billing or administration. The Bright Data authentication docs describe it as the one that “enables API usage on the proxy/API level”. For the expiry, choose a date past the end of your project so the key doesn’t expire mid-run.
The examples use the requests package, the same one used earlier for the board feeds:
pip install requests
That one package is the whole dependency list. Each collection mode shares this setup, and the console lists them together so you can see what each one does before writing anything:

The panel lists this scraper’s endpoints. The three used here are collect by URL, discover by keyword, and discover by profile.
Collect pins by URL
Send one or more pin URLs and read the records from the response. The include_errors=true flag makes the API report failed inputs instead of removing them. You filter the errors yourself, and in exchange you see the outcome of every input:
import requests
API_KEY = "YOUR_API_KEY" # create this in your Bright Data account settings
# copy the current dataset ID from the scraper's page
DATASET_ID = "gd_lk0sjs4d21kdr7cnlv"
resp = requests.post(
"https://api.brightdata.com/datasets/v3/scrape",
headers={"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"},
params={"dataset_id": DATASET_ID, "include_errors": "true"},
json={"input": [
{"url": "https://www.pinterest.com/pin/99360735523397542/"},
{"url": "https://www.pinterest.com/pin/3166662230556591/"},
]},
timeout=60,
)
# an HTTP error raises here, so resp holds records
resp.raise_for_status()
for line in resp.text.strip().splitlines():
print(line)
The response is newline-delimited JSON, one object per input. The live URL returned a record of around twenty fields, shortened here to the relevant ones. The removed pin returned an error:
{"url": "https://www.pinterest.com/pin/99360735523397542/", "post_id":
"99360735523397542", "title": "12 Bombshell Hair Color Ideas To Try This
Summer | Ecemella", "content": "Copper Red",
"date_posted": "2023-05-24T22:32:51.000Z", "user_name": "ecemella",
"user_url": "https://www.pinterest.com/ecemella", "likes": 2,
"categories": ["Beauty", "Hair"], "hashtags": ["Casual Fall Outfit
Inspiration", "Fall Fashion Outfit Ideas"], "image_video_url":
"https://i.pinimg.com/originals/0d/b9/56/0db956e2c07e1580514b1c19587b4152.jpg",
"video_length": 0, "post_type": "image", "comments_num": 0}
{"input": {"url": "https://www.pinterest.com/pin/3166662230556591/"},
"error": "Bad input. Page does not exist.", "error_code": "dead_page"}
The record matches the JSON-LD parse on title, date, and image, and adds the pinner handle, categories, and hashtags in one call. The save count and the outbound source link come from the pin page’s JSON-LD instead.
The failing URL is a real pin ID that Pinterest has since removed. The API returns a dead_page error for it rather than a crash, so you can log it and continue. Bright Data prices the Scraper API per successfully delivered record, so on that basis a dead pin should not add to the bill.
Pins are deleted often, so plan for that error in any batch. Match responses to inputs by each object’s own url or input field rather than by position. Across three identical runs, the error row arrived first twice and second once.
The scrape endpoint usually answers inline. When a job needs longer, it returns a snapshot_id and a still-in-progress message, with the records waiting under that ID. Check for a snapshot_id before you parse the response as records, whichever mode you requested.
Discover pins by keyword
Discovery mode is the route to keyword search, the one area the login gate closes to plain HTTP. This is the mode to use when you have a topic rather than a list of URLs. Add type=discover_new and discover_by=keyword, and limit the volume per keyword with limit_per_input:
import requests
API_KEY = "YOUR_API_KEY"
# copy the current dataset ID from the scraper's page
DATASET_ID = "gd_lk0sjs4d21kdr7cnlv"
resp = requests.post(
"https://api.brightdata.com/datasets/v3/scrape",
headers={"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"},
params={"dataset_id": DATASET_ID, "include_errors": "true",
"type": "discover_new", "discover_by": "keyword"},
json={"input": [{"keyword": "minimalist office"},
{"keyword": "fall fashion"}],
"limit_per_input": 4},
timeout=180,
)
# an HTTP error raises here, so resp holds records
resp.raise_for_status()
print(resp.text)
Each keyword returns up to the limit you set. Each record adds a discovery_input field naming the keyword that found it. Where the pin has a description, a content field holds the full text. One record from the minimalist office run, with the description trimmed:
{"url": "https://www.pinterest.com/pin/320037117294910377", "title":
"Minimal Cream Office With Ultrawide Monitor, Wood Desk and Natural Window
Light", "content": "A pale wood desk sits beside a bright window with an
ultrawide monitor... #MinimalCreamOffice #PaleWoodWorkspace",
"user_name": "theSetupDistrict", "categories": ["Home Decor"],
"date_posted": "2026-08-25T15:59:51.000Z",
"discovery_input": {"keyword": "minimalist office"}}
So a keyword alone is enough to get structured pin records. Most include a description and categories, neither of which appears in the logged-out search grid.
Crawl a whole profile with the async API
Crawling every pin on a profile is more than a single response can hold, so the API runs it as a background job. You start the job, receive an ID, and collect the records when it finishes. Switching the mode in the console shows the difference in the request itself:

Selecting the asynchronous mode rewrites the path for you: the scrape endpoint becomes trigger. The run then returns a snapshot_id to poll, instead of holding a connection open.
Use the trigger endpoint, which returns a snapshot_id immediately:
import requests
API_KEY = "YOUR_API_KEY"
# copy the current dataset ID from the scraper's page
DATASET_ID = "gd_lk0sjs4d21kdr7cnlv"
HEADERS = {"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"}
start = requests.post(
"https://api.brightdata.com/datasets/v3/trigger",
headers=HEADERS,
params={"dataset_id": DATASET_ID, "include_errors": "true",
"type": "discover_new", "discover_by": "profile_url"},
json={"input": [{"url": "https://www.pinterest.com/boredpanda/",
"start_date": "", "end_date": ""}],
"limit_per_input": 4},
timeout=60,
)
# an HTTP error raises here, so start.json() holds a real snapshot_id
start.raise_for_status()
snapshot_id = start.json()["snapshot_id"]
print("snapshot:", snapshot_id)
The empty start_date and end_date are optional, and leaving them blank collects without a date filter. Then poll the progress endpoint until the job is ready and download the snapshot. The progress response reports counts and any error codes, so you know what you’re getting before you fetch it:
import requests, time
API_KEY = "YOUR_API_KEY"
HEADERS = {"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"}
# printed by the trigger block; keep it to re-download a crashed run
snapshot_id = "sd_..."
progress_url = f"https://api.brightdata.com/datasets/v3/progress/{snapshot_id}"
while True:
poll = requests.get(progress_url, headers=HEADERS, timeout=30)
# an HTTP error raises here, so poll.json() is a real progress object
poll.raise_for_status()
progress = poll.json()
if progress["status"] in ("ready", "failed"):
break
time.sleep(10)
# records and error_codes say what the run produced; status only says it stopped
codes = progress.get("error_codes") or {}
if progress["status"] == "failed" or not progress.get("records"):
raise RuntimeError(
f"{snapshot_id} finished with no records to download: "
f"{progress['status']}, {codes}")
data = requests.get(
f"https://api.brightdata.com/datasets/v3/snapshot/{snapshot_id}",
headers=HEADERS, params={"format": "json"}, timeout=120,
).json()
# include_errors=true reports failed inputs as rows, so filter before counting
records = [row for row in data if not row.get("error_code")]
if codes:
print(f"skipped {sum(codes.values())} failed inputs: {codes}")
print(f"downloaded {len(records)} records")
That loop handles the mechanics. What the response says about the run needs more care.
Read the progress response before you download
The progress response includes errors and error_codes alongside status, and all three matter. ready alone means the job stopped, not that every input succeeded. A run can finish ready with records: 0 and a non-zero errors count.
A loop that tests only status will print downloaded 0 records and continue as though it succeeded. Raising on a zero-record run is the right default. Raising on errors alone is not: a crawl that skips a few removed pins and still returns records is working exactly as intended.
The download needs the same care, because include_errors=true puts failed inputs into the snapshot as rows next to real records. The raw row count can therefore be higher than the records you keep. A run reporting one record can return three rows: two dead_page entries with no url, and one complete pin. Filter on error_code before you count or store anything.
A profile run on boredpanda returns in about 21 seconds with the 4 records the limit allowed, each tagged with its discovery_input. Profile crawls run async by design, even when you call the synchronous scrape endpoint.
A keyword or profile crawl also reports dead pins as dead_page entries in the progress error_codes. A run that returns fewer valid records than your limit has usually found removed pins, not a bug in your code.
Check a run and collect the profile record
Every run is logged in the console, so you get a run history without building one:

A run whose ID you never logged is still here to re-download. Two of these ran with no errors at 100%; the third reads 60%, with two removed pins logged in its error column.
That middle run returned a single record because two of the pins it found had been removed. The console logs that as dead_page: 2 under an error column this crop doesn’t reach. Check that column on your own runs before you treat a few records as a broken request. The success rate is the console’s own figure for the pages a crawl visited, so read it alongside the record and error counts.
A second scraper returns the profile record itself, alongside the pins the profile crawl collects. Switch the dataset ID to the one on the Pinterest profiles scraper page, and post the profile URL with no discover_by. For the same account it returned a follower_count of about 3 million and a boards_num of 51 at the time of testing. It also returned a saved array of boards with their pin counts, 10 of the 51.
That scraper returns a snapshot_id rather than inline records, so poll it the same way. It took just under four minutes in testing, compared with the pin crawl’s roughly twenty seconds, so set the poll interval in minutes.
What the records contain
Ten fields appeared on all 30 records tested: url, post_id, date_posted, user_name, user_url, post_type, image_video_url, video_length, likes, and comments_num. Those were the stable core at the time. Four more are common rather than guaranteed: title in 27 of the 30, categories in 24, content in 22, and hashtags in 16. Read that second group with .get() rather than direct indexing.
Save counts are the engagement number most Pinterest analysis wants, and you get them from a two-source join on post_id. The API supplies discovery and the structured record. The pin page’s JSON-LD supplies the save count, which it returned on 5 of the 6 pins checked.
Check the actual numbers before you build a metric on them. Sampling eight recent pins from each of three accounts returned medians of 1, 1 and 2, while individual pins reached the hundreds. So the counter is per pin rather than per image. likes returned 0 on 5 of those same 6, which matches Pinterest having removed likes as a public metric years ago.
Over the same 6 pins, titles agreed across the two sources every time, and dates on 3 of 6. Pinner names usually agree too, but they are the field not to trust. The pin used throughout this guide is a case where they differ: its oEmbed response and its API record name different people.
The API record names the account behind the site the pin links to, while the JSON-LD and oEmbed name the individual who saved it. When a pin is re-saved from another account the two sources can name different people. Join the two datasets on the pin URL or post_id, never on the pinner.
Where the dates differed, the API’s date_posted was on or near the day the pin appeared in the feed. The JSON-LD datePublished was older, in one case by several years. Decide which definition your analysis needs before you mix the two.
Which Pinterest scraping method should you use?
The choice depends on what you’re starting from and how much of the record you need:
| Factor | Plain HTTP (JSON-LD, oEmbed, RSS) | Playwright (DIY) | Bright Data Scraper API |
|---|---|---|---|
| Setup | requests and the standard library |
Install Playwright and a browser | API key plus requests |
| Data returned | Full record for a known pin, plus a structured profile record | Image URLs only from search or a profile | Title, description, pinner, hashtags, categories, media; saves come from the pin page, joined on post_id |
| Discovery | Board feeds and the widget backend, no search | Gated tiles on one search | Pins by URL, keyword, or full profile |
| Blocking | Your own IPs, no TLS challenge observed | You manage proxies and maintenance | Managed proxy and unblocking layer |
| Maintenance | Yours when a feed or the JSON-LD block changes | Yours when Pinterest changes markup or gating | The provider’s, through the same markup and gating changes |
| Cost | Free from one IP, 1.24 GB at 10,000 pin pages and far less through oEmbed | Free, plus 43.5 GB of proxy traffic at 10,000 pins | A free monthly allowance, then priced per successfully delivered record |
Plain HTTP reaches more than most guides suggest. It returns any live pin you name, a structured profile record, and recent pins from every board an account shows. Running both free methods over every board on one account returned 435 unique pins.
The constraint is how much you can reach, and it is narrower than it looks. Those 435 are out of more than 9,000 pins held on the nine boards they came from, under 5%. No free method reaches keyword search, and Playwright adds only gated thumbnails. The Scraper API is for keyword discovery, full profile crawls, and recurring collection.
What each method costs at 10,000 pins
Payload size sets the bill, measured by the bytes actually transferred. The table assumes a client that negotiates compression. A pin page measuring about 1.13 MB decompressed transfers around 124 KB with Brotli, or 183 KB with the gzip header requests sends. Run the stdlib example instead and every figure in the pin page HTML row multiplies by roughly 9.
The bytes are measured here; the rates are not. Two rates set every dollar in the table. Both are Bright Data list prices from September 2026, so the bandwidth rows and the record row use one basis:
- Residential bandwidth, $8/GB. The pay-as-you-go list rate on the proxy network pricing page, which falls on the largest published commitment plan.
- Scraper API, $1.50 per 1,000 delivered records. The pay-as-you-go list rate on the Web Scraper API pricing page, which falls with volume on the higher published plans. The same page lists a free monthly allowance; the table uses the list price for every record.
Both pages also show promotions, and both change without this post changing. Read the current rates on those two pages before you estimate costs. Put what you are quoted into the multiplication in the last column.
| Method | Metered on | Bytes transferred per pin | Volume for 10,000 | Cost at those rates |
|---|---|---|---|---|
| RSS feed, per pin listed | bandwidth | 147 B | 0.0015 GB | × $8/GB = $0.01 |
| oEmbed JSON | bandwidth | 583 B | 0.006 GB | × $8/GB = $0.05 |
| Pin page HTML | bandwidth | 124 KB | 1.24 GB | × $8/GB = $9.92 |
| Scraper API | delivered records | same at any page size | 10,000 records | × $1.50/1k = $15.00 |
| Pin page through Playwright | bandwidth | 4.35 MB | 43.5 GB | × $8/GB = $348.00 |
The rows differ in what a failure costs. A deleted pin still answers HTTP 200 with a full page, so the bandwidth rows pay for its bytes. The record row is priced per delivered record.
What the dollar figures don’t show
Loading the same pin in headless Chromium downloads 4.35 MB across about 180 responses. That is roughly 35 times what a plain fetch moves for the same record.
About 3.1 MB of that is JavaScript, which is why the usual way to save bytes barely helps. Blocking images, fonts, and stylesheets reduces it to 3.8 MB and no lower, because the page still needs its scripts to render.
Time is the other cost. A pin page fetch ran a median of 0.8 to 1.0 seconds across two runs of 15 requests from a single machine. Full runs finish slower than that median suggests.
Three passes over a 25-pin feed with a half-second delay finished at 21 to 33 pins a minute. Run one at a time, that is 5 to 8 hours for 10,000 pins. Running them in parallel shortens that and concentrates your request rate on one address at the same time.
What the cost figures actually compare
oEmbed moves 583 bytes compared with the pin page’s 124 KB. Fetching a whole page to read one author name is waste.
A managed API buys two things: reach, and someone else doing the maintenance. It reaches the page types beyond plain HTTP, keyword search and full profile crawls. The saving comes from that, not from bytes transferred.
On the same basis the pin page row and the Scraper API row are close together, within about 1.5x. Both sides run promotions that move them down further. Which one is cheaper depends on rates neither this post nor you control.
The comparison with a headless browser is not close. At every published rate on that same proxy pricing page, 43.5 GB of traffic costs a multiple of what 10,000 records do.
The two figures do not measure the same thing, so read what each one includes. The traffic line is bandwidth and nothing else. It excludes proxy plan minimums and the hours spent building and maintaining the pipeline. It also assumes you already hold 10,000 pin URLs, which no free method supplies at that scale.
The record figure is broader: it includes the discovery that produced the URL list, and its rate improves with volume. Plan minimums apply on both sides, so confirm them for a large job the same way you would check a proxy quote.
Next steps
Start with the lightest tool for the data you need. If you have the pin URLs, the JSON-LD parse returns complete records over plain HTTP, and oEmbed alone gives attribution. If you’re tracking a creator or a board, chain the RSS feed into that parse and you never load a browser.
Keyword search and full profile crawls are the cases beyond those routes. The Pinterest Scraper API takes a keyword or a profile URL and returns structured records, with a free monthly allowance to test against.
For anything you plan to keep running, set three defaults from the start:
- Handle deleted pins on every batch. That means a
Nonefrom the JSON-LD parse, or adead_pageentry from the API. - Keep about half a second between requests. That pace ran 21 to 33 pins a minute across full runs, and produced no rate limiting across roughly 250 requests.
- Log the
snapshot_idfor async jobs. A crashed run can then be downloaded again.
To go beyond a single address, route through residential proxies and keep the parsing you already have. If you’d rather buy the data than collect it, the Pinterest dataset is already built and ready to download
FAQ
Can you scrape Pinterest?
Yes. A pin URL usually returns title, description, save count, pinner and outbound link from a JSON-LD block over plain HTTP, with no browser needed. Profile and board RSS feeds list recent pin URLs. Keyword search is the exception and needs a scraper API.
Does Pinterest block scrapers?
No, not on public pin pages from a residential address. Twelve requests with the default urllib User-Agent all returned HTTP 200, with no Akamai Bot Manager cookies. A datacenter IP is the exception: the page still returns 200, but without its JSON-LD block.
Why is my Pinterest scraper not working?
The record is in a JSON-LD block, not the Redux store, so initialReduxState.pins reads empty. On search grids the old pinWrapper selector matches nothing, since logged-out tiles now use gated-pin-rep. A deleted pin still answers HTTP 200 with no JSON-LD block at all.
Does Pinterest have a public API?
Yes, but it’s limited to your own account. Pinterest API v5 manages your boards, pins, and ads, not public pins from other users. Trial access has a daily request limit. The only keyword search is a beta not available to all apps, limited to the top 10 results.
Does Pinterest have an RSS feed?
Yes. Pinterest publishes unauthenticated feeds at /<username>/feed.rss and /<username>/<board-slug>.rss. Across 12 accounts they returned 22 to 26 recent items, with pin URLs, timestamps, and thumbnails but almost no titles. Feeds list new pins only, with no keyword search and no historical depth.
How do I download full-size Pinterest images?
Replace the size segment in the thumbnail URL with originals, then try .jpg, .png, .webp and .gif in turn. Keeping the thumbnail’s own .jpg returned 403 on 35 of 40 pins, because Pinterest re-encodes. Plan for about two HEAD requests per image.
How do I scrape a Pinterest board?
Strip any trailing slash from the board URL and add .rss. A trailing slash returns the HTML page instead of the feed. The feed stops near 25 items however large the board is. The widget backend at widgets.pinterest.com/v3/pidgets/ returns up to 50, and past that you crawl the whole profile.