DEV Community

Viktor
Viktor

Posted on

Pinterest Scraper in Go: Board RSS, Pin Pages, and a Saves Tracker in SQLite (2026)

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:

  1. Reads the latest pins from any public board or profile
  2. Opens each pin and pulls out title, outbound link, domain, creation date, repins, and saves
  3. 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
Enter fullscreen mode Exit fullscreen mode

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"`
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode

Two field notes:

  • repinCount is not the save count. On one pin I saw repinCount: 9 next to saves: 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 Saves is a pointer: I'd rather store NULL than 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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.
  2. 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.
  3. 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.
  4. Bandwidth. 25 pins is roughly 30 MB of HTML. Fine for a few boards, painful for thousands.
  5. Markup drift. v3GetPinQueryv2 is 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)
}
Enter fullscreen mode Exit fullscreen mode

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 NULL for 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)