Pinterest is quietly one of the best datasets for anyone working in e-commerce, home decor, food, or fashion. Saves are a pretty honest signal of what people actually want to buy or make, and they show up weeks before a trend hits other platforms.
Getting that data out is a different story. In this post I'll build a small Go tool that:
- Reads the latest pins from any public board or profile
- Opens each pin and pulls out title, outbound link, domain, creation date, repins, and saves
- Stores a snapshot on every run in SQLite, so you can see which pins are picking up saves
Everything here was tested against live Pinterest pages in October 2026. I'll also point out the parts that are flaky, because a few of them surprised me.
Before you start: stick to public data, keep request rates low, and read Pinterest's Terms of Service. If you only need data for accounts you own or manage, the official API v5 is the cleaner route (more on that below).
Your options, briefly
- Official API v5. Needs a registered app, OAuth, and approval; new apps start on limited trial access. It's built around accounts that authorize your app, so it isn't a way to read arbitrary public boards.
-
Internal
/resource/...endpoints. The web app's own JSON calls. They work until they don't: parameters change without notice and they're much more likely to get you rate limited. - RSS + pin pages (this post). Every public profile and board still has an RSS feed, and every pin page ships its data as embedded JSON. No login, no headless browser.
Step 1: The RSS feed gives you pin URLs
This part is pleasantly boring. Two URL patterns:
https://www.pinterest.com/<username>/feed.rss # a profile's latest pins
https://www.pinterest.com/<username>/<board-slug>.rss # one board
Each feed returns the 25 most recent pins with a title, link, thumbnail, and pubDate. That limit matters, and I'll come back to it. Parsing it in Go needs nothing beyond the standard library:
type rssFeed struct {
Channel struct {
Title string `xml:"title"`
Items []rssItem `xml:"item"`
} `xml:"channel"`
}
type rssItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
PubDate string `xml:"pubDate"`
}
Step 2: A polite fetch helper
Pin pages are big (more than 1 MB of HTML each), and Pinterest does return 429 if you hammer it. Retries with exponential backoff and jitter keep things civil:
// fetch does a polite GET with retries and exponential backoff.
func fetch(url string) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < 4; attempt++ {
if attempt > 0 {
wait := time.Duration(1<<attempt) * time.Second
time.Sleep(wait + time.Duration(rand.Intn(1000))*time.Millisecond)
}
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
resp, err := client.Do(req)
if err != nil {
lastErr = err
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
switch {
case resp.StatusCode == 200:
return body, nil
case resp.StatusCode == 429 || resp.StatusCode >= 500:
lastErr = fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
continue
default:
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
}
return nil, lastErr
}
Setting Accept-Language matters more than you'd think. Pinterest localizes heavily, and without it you may get titles and SEO descriptions in whatever language it guesses from your IP.
func boardFeed(user, board string) ([]rssItem, error) {
url := fmt.Sprintf("https://www.pinterest.com/%s/%s.rss", user, board)
if board == "" {
url = fmt.Sprintf("https://www.pinterest.com/%s/feed.rss", user)
}
body, err := fetch(url)
if err != nil {
return nil, err
}
var feed rssFeed
if err := xml.Unmarshal(body, &feed); err != nil {
return nil, fmt.Errorf("parse rss: %w", err)
}
return feed.Channel.Items, nil
}
Step 3: Where the pin data actually lives
My first attempt parsed the application/ld+json block on the pin page. It has a nice SocialMediaPosting object with a "Saves" counter. It worked on the first pin I tried, and then failed on 25 out of 25 pins in my first real run.
Fetching the same pin six times in a row explained it: the JSON-LD block was there once. Pinterest streams the page, and which blocks make it into the HTML varies from request to request.
What is consistently there is one or more <script data-relay-completed-request="true"> tags. Each one calls window.__PWS_RELAY_REGISTER_COMPLETED_REQUEST__(key, json), and the JSON contains a v3GetPinQueryv2.data object with the pin. A single pin is often split across several of these blocks, and some fields (especially aggregatedPinData.aggregatedStats.saves) only appear in one of them. So the trick is to parse all of them and merge:
type Pin struct {
ID string
URL string
Title string
Link string // outbound destination, if any
Domain string
Image string
Author string
Board string
Published string
Repins int
Saves *int // nil when Pinterest didn't include it in this response
}
var relayRe = regexp.MustCompile(`(?s)<script data-relay-completed-request="true"[^>]*>window\.__PWS_RELAY_REGISTER_COMPLETED_REQUEST__\("[^"]*",(.*?)\);?</script>`)
type pinData struct {
EntityID string `json:"entityId"`
Title string `json:"title"`
SeoTitle string `json:"seoTitle"`
Link string `json:"link"`
Domain string `json:"domain"`
CreatedAt string `json:"createdAt"`
RepinCount int `json:"repinCount"`
ImageLarge string `json:"imageLargeUrl"`
Pinner *struct {
Username string `json:"username"`
} `json:"pinner"`
Board *struct {
URL string `json:"url"`
} `json:"board"`
AggregatedPinData *struct {
AggregatedStats struct {
Saves *int `json:"saves"`
} `json:"aggregatedStats"`
} `json:"aggregatedPinData"`
}
type relayPayload struct {
Data struct {
Pin *struct {
Data *pinData `json:"data"`
} `json:"v3GetPinQueryv2"`
} `json:"data"`
}
var errNoPinData = errors.New("no pin data in page")
func first(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// scrapePin merges every relay block on the page: Pinterest splits one pin
// across several blocks, and some fields (like saves) only show up in one.
func scrapePin(url string) (*Pin, error) {
body, err := fetch(url)
if err != nil {
return nil, err
}
p := &Pin{URL: url}
found := false
for _, m := range relayRe.FindAllSubmatch(body, -1) {
var r relayPayload
if json.Unmarshal(m[1], &r) != nil || r.Data.Pin == nil || r.Data.Pin.Data == nil {
continue
}
d := r.Data.Pin.Data
found = true
p.ID = first(p.ID, d.EntityID)
p.Title = first(p.Title, d.Title, d.SeoTitle)
p.Link = first(p.Link, d.Link)
p.Domain = first(p.Domain, d.Domain)
p.Published = first(p.Published, d.CreatedAt)
p.Image = first(p.Image, d.ImageLarge)
if d.RepinCount > p.Repins {
p.Repins = d.RepinCount
}
if d.Pinner != nil {
p.Author = first(p.Author, d.Pinner.Username)
}
if d.Board != nil {
p.Board = first(p.Board, d.Board.URL)
}
if d.AggregatedPinData != nil && d.AggregatedPinData.AggregatedStats.Saves != nil {
p.Saves = d.AggregatedPinData.AggregatedStats.Saves
}
}
if !found {
return nil, errNoPinData
}
return p, nil
}
Two field notes:
-
repinCountis not the save count. On one pin I sawrepinCount: 9next tosaves: 417. The aggregated saves number is the one that matches what users see. -
Saves are often missing. In a 25-pin run, saves came back on only about a third of the pins. That's why
Savesis a pointer: I'd rather storeNULLthan a fake zero that wrecks your growth charts.
Step 4: Store snapshots, not rows
The value is in the change over time, so pins go in once and every run appends a snapshot:
const schema = `
CREATE TABLE IF NOT EXISTS pins (
id TEXT PRIMARY KEY, url TEXT, title TEXT, link TEXT, domain TEXT,
image TEXT, author TEXT, board TEXT, published TEXT, first_seen TEXT
);
CREATE TABLE IF NOT EXISTS snapshots (
pin_id TEXT, taken_at TEXT, repins INTEGER, saves INTEGER,
PRIMARY KEY (pin_id, taken_at)
);`
func save(db *sql.DB, p *Pin) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(`INSERT INTO pins (id,url,title,link,domain,image,author,board,published,first_seen)
VALUES (?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING`,
p.ID, p.URL, p.Title, p.Link, p.Domain, p.Image, p.Author, p.Board, p.Published, now)
if err != nil {
return err
}
_, err = db.Exec(`INSERT INTO snapshots (pin_id,taken_at,repins,saves) VALUES (?,?,?,?)`,
p.ID, now, p.Repins, p.Saves) // a nil *int is stored as NULL
return err
}
And the main loop, with a random 2 to 4 second pause between pins:
func main() {
if len(os.Args) < 2 {
log.Fatal("usage: pinscrape <user> [board-slug]")
}
user, board := os.Args[1], ""
if len(os.Args) > 2 {
board = os.Args[2]
}
db, err := sql.Open("sqlite", "pins.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(schema); err != nil {
log.Fatal(err)
}
items, err := boardFeed(user, board)
if err != nil {
log.Fatal(err)
}
log.Printf("feed returned %d pins", len(items))
for _, it := range items {
p, err := scrapePin(it.Link)
if err != nil {
log.Printf("skip %s: %v", it.Link, err)
continue
}
if err := save(db, p); err != nil {
log.Printf("db: %v", err)
}
saves := "?"
if p.Saves != nil {
saves = fmt.Sprint(*p.Saves)
}
fmt.Printf("%-20s saves=%-6s repins=%-4d %s\n", p.ID, saves, p.Repins, p.Title)
time.Sleep(time.Duration(2000+rand.Intn(2000)) * time.Millisecond)
}
}
Install the SQLite driver (pure Go, no cgo) and run it against a board:
go mod init pinscrape
go get modernc.org/sqlite
go build -o pinscrape .
./pinscrape pinterest # profile feed
./pinscrape <username> <board-slug> # a single board
Real output from Pinterest's own profile feed (trimmed):
feed returned 25 pins
424605071146015538 saves=418 repins=2 Burgundy food table
424605071146015535 saves=? repins=9 The Christmas Centerpiece That Makes a Table Feel Instantly Magical
skip https://www.pinterest.com/pin/424605071146015533/: no pin data in page
424605071146015511 saves=6462 repins=5 Aesthetic gift wrapping idea
424605071146015505 saves=3063 repins=6 Christmas champagne
Run it from cron once a day, then ask SQLite which pins are gaining traction:
SELECT p.title,
MIN(s.saves) AS first_saves,
MAX(s.saves) AS latest_saves,
MAX(s.saves) - MIN(s.saves) AS gained
FROM snapshots s
JOIN pins p ON p.id = s.pin_id
WHERE s.saves IS NOT NULL
GROUP BY p.id
ORDER BY gained DESC
LIMIT 20;
MIN and MAX skip NULLs, which is exactly what you want when saves only show up on some runs.
Where this approach hits a wall
- 25 pins per feed, no pagination. RSS only shows the newest 25. To build history for a big board you have to start tracking early and keep running; you can't backfill old pins this way.
- No keyword search. RSS covers profiles and boards. Searching Pinterest for "christmas tablescape" means the search page, which renders client-side and leans on the internal endpoints.
- Flaky fields. Around 1 in 8 pin pages in my test had no pin data at all, and saves were missing more often than not. Retry missing pins on the next run instead of hammering them immediately.
- Bandwidth. 25 pins is roughly 30 MB of HTML. Fine for a few boards, painful for thousands.
-
Markup drift.
v3GetPinQueryv2is an internal query name. When it changes, your scraper returns zero pins. Alert on that instead of silently storing nothing.
When you'd rather not maintain it
If you need keyword search, deeper pagination, or more volume than a cron job can handle politely, a scraping API takes over the fetching. I use ScrapeAtlas* for this. Its /v1/pinterest/search endpoint takes a query and an optional cursor, and /v1/pinterest/board takes a board url. Both return a pins array plus a cursor for the next page:
type apiResp struct {
Status string `json:"status"` // success, partial, failed, challenged
Cursor *string `json:"cursor"`
Pins []struct {
ID string `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
CreatedAt string `json:"created_at"`
} `json:"pins"`
}
func searchPins(query, cursor string) (*apiResp, error) {
q := url.Values{"query": {query}}
if cursor != "" {
q.Set("cursor", cursor)
}
req, _ := http.NewRequest("GET", "https://api.scrapeatlas.com/v1/pinterest/search?"+q.Encode(), nil)
req.Header.Set("x-api-key", os.Getenv("SCRAPEATLAS_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var r apiResp
return &r, json.NewDecoder(resp.Body).Decode(&r)
}
Check status either way: partial means you got usable pins but some fields were missing (the response's coverage.warnings tells you which, for example missing_pin_counts). The same pins and snapshots tables work for both sources.
Wrapping up
- A handful of boards, tracked daily: RSS plus pin pages, as above, works well and costs nothing.
-
Make missing data explicit: store
NULLfor saves you didn't get and merge every relay block on the page. - Search or scale: that's where the DIY version stops being fun, and an API or a lot of proxy work comes in.
If your run starts returning "no pin data" on every pin, Pinterest has probably renamed the query. Leave a comment and I'll update the parser.
*I build it.
Top comments (0)