DEV Community

Viktor
Viktor

Posted on

How to Scrape Dribbble with Go: Designer Profiles, Shots, Collections, and a Portfolio Tracker (2026)

Dribbble is where a lot of designers keep their portfolio, so it's a natural data source if you're scouting talent, studying which kinds of shots get attention, or keeping an eye on your own numbers. The first thing most people search for is "Dribbble API". There is one. It just doesn't do what most people expect.

So this post covers both: what the official Dribbble API gives you, and how to build a small Dribbble scraper in Go for the public pages it doesn't cover. The scraper uses the standard library plus golang.org/x/net/html, and it will:

  1. Respect robots.txt with a tiny matcher, and stop cleanly at bot challenges
  2. Read a designer profile: name, headline, bio, location, followers, skills
  3. Page through a designer's shots with likes, views, and publish dates
  4. List a designer's collections, and read the shots inside one
  5. Snapshot a few portfolios into CSV and report what changed

Everything below was run on October 30, 2026, and the output is real.

Before you start: Dribbble's Terms of Service list "copying, distributing, or disclosing any part of the Website ... by any automated or non-automated 'scraping'" as a prohibited activity. robots.txt allows the pages used here, but robots rules and terms are different things. Read the terms and weigh the legal risk for your use case before running anything, and prefer the official API wherever it covers what you need. Treat this as a learning project or small-scale research: keep request rates low, don't republish people's work, don't resell the data, and don't use it to mass-message designers. If you only need your own data, use the official API.

Is there a Dribbble API?

Yes: the Dribbble API v2 (https://api.dribbble.com/v2/, docs at developer.dribbble.com). You register an application, do the OAuth flow, and send Authorization: Bearer <token>. The limit is 60 requests per minute and 1,440 per day per authenticated user, with X-RateLimit-* headers and Link header pagination.

The catch is in the endpoint list. v2 has GET /user, GET /user/shots, GET /shots/:id, create/update/delete shot, projects, and jobs. All of them are about the authenticated user. The docs for "Get a shot" say it plainly: "This endpoint returns only shots owned by the currently authenticated user." The old v1 endpoints for any user's shots, followers, and popular lists are gone. Without a token, GET https://api.dribbble.com/v2/user returns 401, and /v2/users/simplebits/shots is a 404.

You want Use
Your own shots, profile, projects (e.g. to show your Dribbble work on your site) Dribbble API v2
Other designers' public profiles, shots, likes/views, collections The public website, which is what this scraper reads

How dribbble.com looks from a scraper's point of view

I checked the site with curl and with Go's HTTP client first. Here is what came back:

path                                            status  notes
/robots.txt                                     200
/simplebits                                     200     ~300 KB, shots embedded as JSON
/simplebits?page=2                              200     next 24 shots
/simplebits/about                               200     bio, location, skills, counts
/simplebits/collections                         200     6 collections per page
/simplebits/collections/4582-Data-Viz           200     24 shots per page
/shots/27755136-Measure-Twice-Print             202     AWS WAF challenge, empty page
/shots/popular, /search/logo                    202     AWS WAF challenge
/shots/popular.rss                              202     AWS WAF challenge
/simplebits.rss, /shots.rss                     404     the old RSS feeds are gone
Enter fullscreen mode Exit fullscreen mode

The status codes were the same with a Chrome User-Agent, curl's default, and Go's Go-http-client/1.1. So three findings shaped the code.

1. robots.txt is clear about what's off-limits. Profiles, collections, and shot pages are allowed. These are disallowed for every crawler:

Disallow: /*/followers$
Disallow: /*/following$
Disallow: /*/tags$
Disallow: /*/tags/*
Disallow: /shots/*/comments
Disallow: /shots/*/fans
Disallow: /shots/*/rebounds
Disallow: /shots/*/buckets
Disallow: /shots/week/*   (and /day/*, /month/*, /year/*)
Enter fullscreen mode Exit fullscreen mode

So no follower lists, no who-liked-this lists, no comments. The scraper below checks every path against these rules before it sends a request.

2. Profile and collection pages are server-rendered, and the shots come as JSON. The profile page includes a <script> with var newestShots = [...], one object per shot in the grid:

{"id":27755136,"title":"Measure Twice Print","path":"/shots/27755136-Measure-Twice-Print",
 "published_at":"September 23, 2026","is_rebound":null,"rebounds_count":0,
 "attachments_count":0,"view_count":"23.3k","likes_count":"108","liked":null,"ga":[]}
Enter fullscreen mode Exit fullscreen mode

That's easier and sturdier than scraping the grid's CSS. Logged out, the page shows a "Sign up to continue" button where infinite scroll would be, but the classic ?page=2 link is still in the HTML and works.

3. Individual shot pages, popular, and search sit behind an AWS WAF challenge. They return HTTP 202 with x-amzn-waf-action: challenge and a small JavaScript page that a real browser solves. I didn't try to get around it, and you shouldn't either. Everything in this post comes from profile and collection pages, which already show titles, likes, views, and dates for every shot. The scraper treats a 202 as "stop", not "retry".

One more detail: the page's own config included "isRobot": true for my requests. Dribbble knows exactly what you are, so be polite.

Step 1: Project setup and a polite fetch helper

mkdir dribbblescraper && cd dribbblescraper
go mod init dribbblescraper
go get golang.org/x/net/html
Enter fullscreen mode Exit fullscreen mode

getHTML checks robots.txt, keeps at least two seconds between requests, backs off on 429/5xx, and returns ErrChallenged on a WAF challenge. The User-Agent is honest. Put your own contact URL in it.

package main

import (
    "bufio"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "regexp"
    "strings"
    "time"
)

const (
    base      = "https://dribbble.com"
    userAgent = "dribbblescraper/0.1 (+https://example.com/contact)"
)

var (
    client = &http.Client{Timeout: 25 * time.Second}

    // ErrChallenged means AWS WAF answered with a JavaScript challenge
    // (HTTP 202, empty-ish body, x-amzn-waf-action: challenge). We stop
    // instead of trying to solve it.
    ErrChallenged = errors.New("blocked by AWS WAF challenge")
    ErrDisallowed = errors.New("path disallowed by robots.txt")

    lastRequest time.Time
    minGap      = 2 * time.Second
)

// getHTML fetches a dribbble.com path after checking robots.txt, waits at
// least minGap between requests, and backs off on 429/5xx.
func getHTML(path string) (string, error) {
    if !robots.Allowed(path) {
        return "", fmt.Errorf("%w: %s", ErrDisallowed, path)
    }
    for attempt := 0; attempt < 4; attempt++ {
        if wait := minGap - time.Since(lastRequest); wait > 0 {
            time.Sleep(wait)
        }
        lastRequest = time.Now()

        req, _ := http.NewRequest("GET", base+path, nil)
        req.Header.Set("User-Agent", userAgent)
        req.Header.Set("Accept", "text/html")
        req.Header.Set("Accept-Language", "en-US,en;q=0.9")
        res, err := client.Do(req)
        if err != nil {
            time.Sleep(time.Duration(attempt+1) * 3 * time.Second)
            continue
        }
        body, err := io.ReadAll(io.LimitReader(res.Body, 8<<20))
        res.Body.Close()
        if err != nil {
            return "", err
        }
        switch {
        case res.Header.Get("x-amzn-waf-action") != "" || res.StatusCode == 202:
            return "", fmt.Errorf("%w: %s", ErrChallenged, path)
        case res.StatusCode == 429 || res.StatusCode >= 500:
            fmt.Fprintf(os.Stderr, "HTTP %d on %s, backing off\n", res.StatusCode, path)
            time.Sleep(time.Duration(attempt+1) * 15 * time.Second)
            continue
        case res.StatusCode == 404:
            return "", fmt.Errorf("not found: %s", path)
        case res.StatusCode != 200:
            return "", fmt.Errorf("GET %s: HTTP %d", path, res.StatusCode)
        }
        return string(body), nil
    }
    return "", errors.New("gave up after retries: " + path)
}
Enter fullscreen mode Exit fullscreen mode

The robots matcher is about 50 lines. It reads the User-agent: * group and turns each rule into a regexp, so * matches anything and a trailing $ anchors the end:

// --- a tiny robots.txt matcher (User-agent: * group, * and $ wildcards) ---

type robotsRules struct{ allow, disallow []*regexp.Regexp }

var robots = &robotsRules{}

func loadRobots() error {
    req, _ := http.NewRequest("GET", base+"/robots.txt", nil)
    req.Header.Set("User-Agent", userAgent)
    res, err := client.Do(req)
    if err != nil {
        return err
    }
    defer res.Body.Close()
    if res.StatusCode != 200 {
        return fmt.Errorf("robots.txt: HTTP %d", res.StatusCode)
    }
    inStar := false
    sc := bufio.NewScanner(res.Body)
    for sc.Scan() {
        line := strings.TrimSpace(strings.SplitN(sc.Text(), "#", 2)[0])
        k, v, ok := strings.Cut(line, ":")
        if !ok {
            continue
        }
        k, v = strings.ToLower(strings.TrimSpace(k)), strings.TrimSpace(v)
        switch {
        case k == "user-agent":
            inStar = v == "*"
        case inStar && k == "disallow" && v != "":
            robots.disallow = append(robots.disallow, robotsPattern(v))
        case inStar && k == "allow" && v != "":
            robots.allow = append(robots.allow, robotsPattern(v))
        }
    }
    return sc.Err()
}

// robotsPattern turns "/*/followers$" into ^/[^?]*?/followers$ style regexps.
func robotsPattern(p string) *regexp.Regexp {
    anchored := strings.HasSuffix(p, "$")
    p = strings.TrimSuffix(p, "$")
    parts := strings.Split(p, "*")
    for i := range parts {
        parts[i] = regexp.QuoteMeta(parts[i])
    }
    re := "^" + strings.Join(parts, ".*")
    if anchored {
        re += "$"
    }
    return regexp.MustCompile(re)
}

func (r *robotsRules) Allowed(path string) bool {
    // Match on the path without the query string for $-anchored rules.
    p, _, _ := strings.Cut(path, "?")
    for _, a := range r.allow {
        if a.MatchString(p) {
            return true
        }
    }
    for _, d := range r.disallow {
        if d.MatchString(p) {
            return false
        }
    }
    return true
}
Enter fullscreen mode Exit fullscreen mode

A test pins down the rules we care about. It runs against the live robots.txt:

package main

import (
    "errors"
    "testing"
)

func TestRobots(t *testing.T) {
    if err := loadRobots(); err != nil {
        t.Skip("no network:", err)
    }
    cases := map[string]bool{
        "/simplebits":                     true,
        "/simplebits?page=2":              true,
        "/simplebits/about":               true,
        "/simplebits/collections":         true,
        "/simplebits/followers":           false,
        "/simplebits/following":           false,
        "/simplebits/tags":                false,
        "/shots/123-Foo/comments":         false,
        "/shots/123-Foo/fans":             false,
        "/shots/week/2026-10-01":          false,
        "/shots/27755136-Measure-Twice-P": true,
    }
    for path, want := range cases {
        if got := robots.Allowed(path); got != want {
            t.Errorf("Allowed(%q) = %v, want %v", path, got, want)
        }
    }
}

func TestShotPageIsChallenged(t *testing.T) {
    if err := loadRobots(); err != nil {
        t.Skip("no network:", err)
    }
    _, err := getHTML("/shots/27755136-Measure-Twice-Print")
    t.Logf("shot page: %v", err)
    if err != nil && !errors.Is(err, ErrChallenged) {
        t.Errorf("unexpected error: %v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode
$ go test -v ./...
=== RUN   TestRobots
--- PASS: TestRobots (0.05s)
=== RUN   TestShotPageIsChallenged
    robots_test.go:37: shot page: blocked by AWS WAF challenge: /shots/27755136-Measure-Twice-Print
--- PASS: TestShotPageIsChallenged (0.07s)
PASS
Enter fullscreen mode Exit fullscreen mode

Step 2: Small HTML helpers

golang.org/x/net/html gives you a parse tree but no selectors, so a few helpers go a long way. parseCount matters more than it looks: Dribbble shows views as 23.3k, and counts on the about page as 117,603.

package main

import (
    "strconv"
    "strings"

    "golang.org/x/net/html"
)

func parseDoc(s string) (*html.Node, error) { return html.Parse(strings.NewReader(s)) }

func attr(n *html.Node, key string) string {
    for _, a := range n.Attr {
        if a.Key == key {
            return a.Val
        }
    }
    return ""
}

func hasAttr(n *html.Node, key string) bool {
    for _, a := range n.Attr {
        if a.Key == key {
            return true
        }
    }
    return false
}

func hasClass(n *html.Node, class string) bool {
    if n.Type != html.ElementNode {
        return false
    }
    for _, c := range strings.Fields(attr(n, "class")) {
        if c == class {
            return true
        }
    }
    return false
}

// findAll walks the tree and returns every node matching pred.
func findAll(n *html.Node, pred func(*html.Node) bool) []*html.Node {
    var out []*html.Node
    var walk func(*html.Node)
    walk = func(n *html.Node) {
        if pred(n) {
            out = append(out, n)
        }
        for c := n.FirstChild; c != nil; c = c.NextSibling {
            walk(c)
        }
    }
    walk(n)
    return out
}

func first(n *html.Node, pred func(*html.Node) bool) *html.Node {
    if all := findAll(n, pred); len(all) > 0 {
        return all[0]
    }
    return nil
}

func byClass(c string) func(*html.Node) bool {
    return func(n *html.Node) bool { return hasClass(n, c) }
}

func byTag(tag string) func(*html.Node) bool {
    return func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == tag }
}

// text returns the whitespace-collapsed text content of a node (nil-safe).
func text(n *html.Node) string {
    if n == nil {
        return ""
    }
    var b strings.Builder
    var walk func(*html.Node)
    walk = func(n *html.Node) {
        if n.Type == html.TextNode {
            b.WriteString(n.Data + " ")
        }
        if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style" || n.Data == "svg") {
            return
        }
        for c := n.FirstChild; c != nil; c = c.NextSibling {
            walk(c)
        }
    }
    walk(n)
    return strings.Join(strings.Fields(b.String()), " ")
}

// parseCount turns "117,603", "23.3k", "1.2m" or "381" into an int.
func parseCount(s string) int {
    s = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(s, ",", "")))
    mult := 1.0
    switch {
    case strings.HasSuffix(s, "k"):
        mult, s = 1_000, strings.TrimSuffix(s, "k")
    case strings.HasSuffix(s, "m"):
        mult, s = 1_000_000, strings.TrimSuffix(s, "m")
    }
    f, err := strconv.ParseFloat(s, 64)
    if err != nil {
        return 0
    }
    return int(f*mult + 0.5)
}

// leadingCount reads the number at the start of "223 Shots" or "117,603 followers".
func leadingCount(s string) int {
    f := strings.Fields(s)
    if len(f) == 0 {
        return 0
    }
    return parseCount(f[0])
}
Enter fullscreen mode Exit fullscreen mode

Step 3: A designer profile

The /about page has everything: name, headline, bio, location, "Member since", Pro badge, skills, and follower counts. The trap is that there are (at least) two profile templates. On simplebits (Dan Cederholm, a Dribbble co-founder) the masthead shows "117,603 followers / 1,820 following / 46,450 likes". On Betraydan the masthead shows location and specializations instead, and there's no likes count at all. Both templates have a .profile-stats-section with count + meta spans, so the code reads followers from there and only takes likes when they're shown.

package main

import (
    "net/url"
    "regexp"
    "strings"

    "golang.org/x/net/html"
)

type Profile struct {
    Username    string   `json:"username"`
    Name        string   `json:"name"`
    Headline    string   `json:"headline"`
    Bio         string   `json:"bio"`
    Location    string   `json:"location"`
    MemberSince string   `json:"member_since"`
    Pro         bool     `json:"pro"`
    Followers   int      `json:"followers"`
    Following   int      `json:"following"`
    Likes       int      `json:"likes"` // 0 when the template doesn't show it
    Skills      []string `json:"skills"`
}

var usernameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)

// normalizeUser accepts "simplebits", "@simplebits" or "https://dribbble.com/simplebits/about".
func normalizeUser(s string) string {
    s = strings.TrimPrefix(strings.TrimSpace(s), "@")
    if u, err := url.Parse(s); err == nil && u.Host != "" {
        s = strings.Split(strings.Trim(u.Path, "/"), "/")[0]
    }
    if !usernameRe.MatchString(s) {
        return ""
    }
    return s
}

// fetchProfile reads /<user>/about, which has everything the main profile page
// has in its masthead plus bio, location, "Member since" and skills.
func fetchProfile(user string) (Profile, error) {
    p := Profile{Username: user}
    page, err := getHTML("/" + user + "/about")
    if err != nil {
        return p, err
    }
    doc, err := parseDoc(page)
    if err != nil {
        return p, err
    }
    p.Name = text(first(doc, byClass("masthead-profile-name")))
    p.Headline = text(first(doc, byClass("masthead-intro")))

    if p.Headline == "" { // the second profile template shows specializations instead
        p.Headline = text(first(doc, byClass("masthead-profile-specializations")))
    }

    // Dribbble has (at least) two profile templates. Both have a
    // ".profile-stats-section" with <span class="count"> + <span class="meta">
    // pairs; only one also has "N likes" in ".masthead-stats".
    if sec := first(doc, byClass("profile-stats-section")); sec != nil {
        for _, a := range findAll(sec, byTag("a")) {
            n := leadingCount(text(first(a, byClass("count"))))
            switch text(first(a, byClass("meta"))) {
            case "followers":
                p.Followers = n
            case "following":
                p.Following = n
            }
        }
    }
    if stats := first(doc, byClass("masthead-stats")); stats != nil {
        for _, st := range findAll(stats, byClass("stat")) {
            if t := text(st); strings.HasSuffix(t, "likes") {
                p.Likes = leadingCount(t)
            }
        }
    }

    var bio []string
    for _, b := range findAll(doc, byClass("bio-text")) {
        bio = append(bio, text(b))
    }
    p.Bio = strings.Join(bio, "\n")

    // The info section is rendered twice (small screens and large), so take the first.
    p.Location = text(first(doc, byClass("location")))
    p.MemberSince = strings.TrimPrefix(text(first(doc, byClass("created"))), "Member since ")
    p.Pro = first(doc, func(n *html.Node) bool { return hasClass(n, "info-item") && hasClass(n, "pro") }) != nil

    if sec := first(doc, byClass("profile-skills-section")); sec != nil {
        for _, a := range findAll(sec, byTag("a")) {
            if s := attr(a, "data-skill"); s != "" {
                p.Skills = append(p.Skills, s)
            }
        }
    }
    return p, nil
}
Enter fullscreen mode Exit fullscreen mode
$ ./dribbblescraper profile simplebits
{
  "username": "simplebits",
  "name": "Dan Cederholm",
  "headline": "Making type \u0026 goods at SimpleBits®. Dribbble Co-Founder.",
  "bio": "Making type and goods at SimpleBits® since 2002.\nJoin my Secret Club™ for all my fonts, early releases, private community, discounts, and other good stuff.\nsimplebits dot com",
  "location": "Salem, MA",
  "member_since": "Jul 2009",
  "pro": true,
  "followers": 117603,
  "following": 1820,
  "likes": 46450,
  "skills": ["branding", "fonts", "icon", "illustration", "logo", "logo design", "type design", "ui"]
}

$ ./dribbblescraper profile https://dribbble.com/Betraydan/about
{
  "username": "Betraydan",
  "name": "Daniel Klopper",
  "headline": "Product Design, UI / Visual Design, Mobile Design",
  "location": "Cape Town, South Africa",
  "member_since": "Jul 2011",
  "pro": false,
  "followers": 19180,
  "following": 303,
  "likes": 0,
  ...
}
Enter fullscreen mode Exit fullscreen mode

My first version only read the masthead and returned followers: 0 for Betraydan. Test against more than one profile before you trust a parser.

Two things you won't get logged out: social links and email. They sit behind "Get in touch", which opens a signup dialog. That's fine. That's the designer's choice.

Step 4: Shots, page by page

extractJSONVar finds var newestShots = and hands the rest of the page to json.Decoder. The decoder stops at the end of the JSON value, so there's no need for a fragile regexp to find the closing ].

Thumbnails are lazy-loaded: the <img src> is a 1×1 GIF, and the real URL is in data-src (and in a <noscript> copy). If your scraper saves src, you get 24 transparent pixels.

package main

import (
    "encoding/json"
    "fmt"
    "strings"
    "time"
)

// Shot is what the profile page ships in `var newestShots = [...]`,
// plus the thumbnail we read from the HTML grid.
type Shot struct {
    ID          int64  `json:"id"`
    Title       string `json:"title"`
    Path        string `json:"path"`
    PublishedAt string `json:"published_at"` // "September 23, 2026"
    Rebounds    int    `json:"rebounds_count"`
    Attachments int    `json:"attachments_count"`
    ViewsRaw    string `json:"view_count"`  // "23.3k" (rounded!)
    LikesRaw    string `json:"likes_count"` // "108"
    Views       int    `json:"-"`
    Likes       int    `json:"-"`
    Thumbnail   string `json:"-"`
}

func (s Shot) URL() string { return base + s.Path }

func (s Shot) Published() time.Time {
    t, _ := time.Parse("January 2, 2006", s.PublishedAt)
    return t
}

// extractJSONVar finds `var <name> = ` in a page and decodes the JSON value
// that follows. json.Decoder stops at the end of the value, so we don't need
// to guess where the array ends.
func extractJSONVar(page, name string, out any) error {
    marker := "var " + name + " = "
    i := strings.Index(page, marker)
    if i < 0 {
        return fmt.Errorf("%q not found (markup changed?)", marker)
    }
    return json.NewDecoder(strings.NewReader(page[i+len(marker):])).Decode(out)
}

// fetchShotsPage reads one page of /<user>?page=N. Logged out, each page has 24 shots.
func fetchShotsPage(user string, page int) (shots []Shot, hasNext bool, err error) {
    body, err := getHTML(fmt.Sprintf("/%s?page=%d", user, page))
    if err != nil {
        return nil, false, err
    }
    if err := extractJSONVar(body, "newestShots", &shots); err != nil {
        return nil, false, err
    }

    // Thumbnails are lazy-loaded: src is a 1x1 GIF, the real URL is in data-src.
    thumbs := map[string]string{}
    if doc, err := parseDoc(body); err == nil {
        for _, li := range findAll(doc, byClass("shot-thumbnail")) {
            id := attr(li, "data-thumbnail-id")
            if img := first(li, byTag("img")); img != nil && id != "" {
                src := attr(img, "data-src")
                if src == "" {
                    src = attr(img, "src")
                }
                thumbs[id] = strings.Split(src, "?")[0]
            }
        }
    }
    for i := range shots {
        shots[i].Views = parseCount(shots[i].ViewsRaw)
        shots[i].Likes = parseCount(shots[i].LikesRaw)
        shots[i].Thumbnail = thumbs[fmt.Sprint(shots[i].ID)]
    }
    hasNext = strings.Contains(body, `class="next_page"`) && strings.Contains(body, `rel="next"`)
    return shots, hasNext, nil
}

// fetchShots pages through a profile until maxPages, an empty page, or no "Next" link.
func fetchShots(user string, maxPages int) ([]Shot, error) {
    var all []Shot
    seen := map[int64]bool{}
    for page := 1; page <= maxPages; page++ {
        batch, hasNext, err := fetchShotsPage(user, page)
        if err != nil {
            return all, err
        }
        added := 0
        for _, s := range batch {
            if !seen[s.ID] { // pages can overlap if a new shot is posted mid-crawl
                seen[s.ID] = true
                all = append(all, s)
                added++
            }
        }
        if added == 0 || !hasNext {
            break
        }
    }
    return all, nil
}
Enter fullscreen mode Exit fullscreen mode
$ ./dribbblescraper shots -pages 3 -out simplebits.csv simplebits
72 shots from 3 page(s) of simplebits
  2026-09-23    108 ♥   23300 views  Measure Twice Print
  2026-09-23    108 ♥   32200 views  Hubano 2.0
  2026-07-21     46 ♥    7400 views  Serif Pin
  2026-07-21     47 ♥    5600 views  Kyroh Typeface
  2026-06-10     48 ♥   16800 views  Beeswacks Typeface

$ head -2 simplebits.csv
id,published,title,likes,views,views_raw,rebounds,url,thumbnail
27755136,2026-09-23,Measure Twice Print,108,23300,23.3k,0,https://dribbble.com/shots/27755136-Measure-Twice-Print,https://cdn.dribbble.com/userupload/49110833/file/d68cabe4e8579fe05889da8d308f0ea0.jpg
Enter fullscreen mode Exit fullscreen mode

Note the views_raw column. 23.3k becomes 23,300, but the real number is somewhere between 23,250 and 23,349. Keep the raw string, and don't report view growth smaller than the rounding step.

Step 5: Collections

Collections are curated sets of other people's shots. A designer's /collections page lists them 6 at a time, and ?page=2 gives the next 6. Each one is an li.shots-group-item with a name, a shot count, and a designer count:

package main

import (
    "fmt"
    "net/url"
    "regexp"
    "strings"

    "golang.org/x/net/html"
)

type Collection struct {
    ID        string `json:"id"`
    Name      string `json:"name"`
    URL       string `json:"url"`
    Shots     int    `json:"shots"`
    Designers int    `json:"designers"`
}

// fetchCollections reads /<user>/collections?page=N (6 per page, logged out).
func fetchCollections(user string, maxPages int) ([]Collection, error) {
    var all []Collection
    for page := 1; page <= maxPages; page++ {
        body, err := getHTML(fmt.Sprintf("/%s/collections?page=%d", user, page))
        if err != nil {
            return all, err
        }
        doc, err := parseDoc(body)
        if err != nil {
            return all, err
        }
        items := findAll(doc, byClass("shots-group-item"))
        for _, li := range items {
            c := Collection{ID: attr(li, "data-id"), Name: text(first(li, byClass("collection-name")))}
            if a := first(li, byClass("shots-group")); a != nil {
                c.URL = base + attr(a, "href")
            }
            c.Shots = leadingCount(text(first(li, byClass("shots-count"))))
            c.Designers = leadingCount(text(first(li, byClass("designers-count"))))
            all = append(all, c)
        }
        if len(items) == 0 {
            break
        }
    }
    return all, nil
}
Enter fullscreen mode Exit fullscreen mode

A single collection page uses newer markup (shot2-item), 24 shots per page. Here the title and path are attributes on a <drb-shot-metadata> custom element, which is nicer than digging through text. The author is the profile link inside the details block.

type CollectionShot struct {
    ID         string
    Title      string
    URL        string
    Author     string // profile path, e.g. "Betraydan"
    AuthorName string
    Likes      int
    Views      int
    Thumbnail  string
}

type CollectionPage struct {
    Name      string
    Curator   string
    Shots     int
    Designers int
    Items     []CollectionShot
}

var collectionPath = regexp.MustCompile(`^/[A-Za-z0-9_-]+/collections/\d+(-[^/]*)?$`)

// fetchCollection reads one collection (24 shots per page) from a full URL.
func fetchCollection(raw string, maxPages int) (CollectionPage, error) {
    var out CollectionPage
    u, err := url.Parse(raw)
    if err != nil || !collectionPath.MatchString(u.Path) {
        return out, fmt.Errorf("want https://dribbble.com/<user>/collections/<id>-<slug>, got %q", raw)
    }
    seen := map[string]bool{}
    for page := 1; page <= maxPages; page++ {
        body, err := getHTML(fmt.Sprintf("%s?page=%d", u.Path, page))
        if err != nil {
            return out, err
        }
        doc, err := parseDoc(body)
        if err != nil {
            return out, err
        }
        if page == 1 {
            out.Name = text(first(doc, byClass("collection-page__title")))
            out.Curator = text(first(doc, byClass("curator__name")))
            for _, v := range findAll(doc, byClass("collection-page__meta-details-value")) {
                t := text(v)
                switch {
                case strings.HasSuffix(t, "Shots"):
                    out.Shots = leadingCount(t)
                case strings.HasSuffix(t, "Designers"):
                    out.Designers = leadingCount(t)
                }
            }
        }
        added := 0
        for _, it := range findAll(doc, byClass("shot2-item")) {
            id := attr(it, "data-id")
            if id == "" || seen[id] {
                continue
            }
            seen[id] = true
            added++
            s := CollectionShot{ID: id}
            if m := first(it, byTag("drb-shot-metadata")); m != nil {
                s.Title, s.URL = attr(m, "title"), base+attr(m, "path")
            }
            if img := first(it, byTag("img")); img != nil {
                s.Thumbnail = strings.Split(attr(img, "src"), "?")[0]
            }
            if d := first(it, byClass("shot2-item-details__user-container")); d != nil {
                if a := first(d, byTag("a")); a != nil {
                    s.Author = strings.Trim(attr(a, "href"), "/")
                }
                s.AuthorName = text(first(d, byClass("shot2-item-details__user-name")))
            }
            // Stats: the like count sits in [data-shot-like-count]; views are the
            // text of the last ".shot2-item-stats__item".
            stats := findAll(it, byClass("shot2-item-stats__item"))
            if lc := first(it, func(n *html.Node) bool { return hasAttr(n, "data-shot-like-count") }); lc != nil {
                s.Likes = parseCount(text(lc))
            }
            if len(stats) > 0 {
                s.Views = parseCount(text(stats[len(stats)-1]))
            }
            out.Items = append(out.Items, s)
        }
        if added == 0 {
            break
        }
    }
    return out, nil
}
Enter fullscreen mode Exit fullscreen mode
$ ./dribbblescraper collections simplebits
  Data Viz                223 shots ·  160 designers  https://dribbble.com/simplebits/collections/4582-Data-Viz
  Glyphs                  176 shots ·  111 designers  https://dribbble.com/simplebits/collections/4581-Glyphs
  iOS Icons               284 shots ·  156 designers  https://dribbble.com/simplebits/collections/4144-iOS-Icons
  Forms                    40 shots ·   33 designers  https://dribbble.com/simplebits/collections/3834-Forms
  Pixels                   28 shots ·   17 designers  https://dribbble.com/simplebits/collections/3568-Pixels
  Sketches                 87 shots ·   60 designers  https://dribbble.com/simplebits/collections/3542-Sketches
  Cool Jobs                35 shots ·   32 designers  https://dribbble.com/simplebits/collections/3297-Cool-Jobs
  Great Illustration      496 shots ·  256 designers  https://dribbble.com/simplebits/collections/3295-Great-Illustration
  Great Type              202 shots ·  117 designers  https://dribbble.com/simplebits/collections/3294-Great-Type
  Great Marks             221 shots ·  143 designers  https://dribbble.com/simplebits/collections/2754-Great-Marks

$ ./dribbblescraper collection -pages 2 https://dribbble.com/simplebits/collections/4582-Data-Viz
Data Viz, curated by Dan Cederholm: 223 shots by 160 designers (48 fetched)
    381 ♥   17700 views  Analytics                    by Betraydan
    559 ♥   30100 views  Managing Dashboard           by CindyWang
    548 ♥   26900 views  Paper infographic*           by kellianderson
    136 ♥   33600 views  Maxim Magazine Space Oddities Infographic by DKNG
     15 ♥    1700 views  1% of the 1% Data Viz        by AmyCesal
Enter fullscreen mode Exit fullscreen mode

Curated collections are a good discovery layer. "Data Viz" alone links to 160 designers, which is a much better list than anything you'd get from search (which is challenged anyway).

Step 6: A portfolio tracker

Point-in-time numbers aren't very interesting. Changes are. track appends one row per profile to profiles.csv and one row per shot to shots.csv, all with the same snapshot_at. report compares each designer's last two snapshots: follower change, the shots that gained the most likes, and shots that are new since the previous run.

package main

import (
    "encoding/csv"
    "fmt"
    "os"
    "path/filepath"
    "sort"
    "strconv"
    "strings"
    "time"
)

// appendCSV appends rows to path, writing the header first if the file is new.
func appendCSV(path string, header []string, rows [][]string) error {
    _, statErr := os.Stat(path)
    f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
    if err != nil {
        return err
    }
    defer f.Close()
    w := csv.NewWriter(f)
    if os.IsNotExist(statErr) {
        w.Write(header)
    }
    w.WriteAll(rows)
    return w.Error()
}

// track takes one snapshot of each profile and its newest shots.
func track(dir string, users []string, pages int) error {
    if err := os.MkdirAll(dir, 0o755); err != nil {
        return err
    }
    now := time.Now().UTC().Format(time.RFC3339)
    for _, user := range users {
        p, err := fetchProfile(user)
        if err != nil {
            fmt.Fprintf(os.Stderr, "%s: %v\n", user, err)
            continue
        }
        shots, err := fetchShots(user, pages)
        if err != nil {
            fmt.Fprintf(os.Stderr, "%s shots: %v\n", user, err)
        }
        err = appendCSV(filepath.Join(dir, "profiles.csv"),
            []string{"snapshot_at", "username", "followers", "following", "likes", "shots_seen"},
            [][]string{{now, user, strconv.Itoa(p.Followers), strconv.Itoa(p.Following), strconv.Itoa(p.Likes), strconv.Itoa(len(shots))}})
        if err != nil {
            return err
        }
        var rows [][]string
        for _, s := range shots {
            rows = append(rows, []string{now, user, strconv.FormatInt(s.ID, 10), s.Title,
                s.Published().Format("2006-01-02"), strconv.Itoa(s.Likes), strconv.Itoa(s.Views), s.URL()})
        }
        if err := appendCSV(filepath.Join(dir, "shots.csv"),
            []string{"snapshot_at", "username", "shot_id", "title", "published", "likes", "views", "url"}, rows); err != nil {
            return err
        }
        fmt.Printf("%-16s followers %d · likes %d · %d shots snapshotted\n", user, p.Followers, p.Likes, len(shots))
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

report reads both CSVs back, groups rows by user and snapshot time, and diffs the last two snapshots:

func readCSV(path string) ([][]string, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    rows, err := csv.NewReader(f).ReadAll()
    if len(rows) > 0 {
        rows = rows[1:] // drop header
    }
    return rows, err
}

// report compares the two most recent snapshots per user.
func report(dir string) error {
    profiles, err := readCSV(filepath.Join(dir, "profiles.csv"))
    if err != nil {
        return err
    }
    shots, err := readCSV(filepath.Join(dir, "shots.csv"))
    if err != nil {
        return err
    }
    snaps := map[string][]string{} // user -> snapshot times, oldest first
    follow := map[string]int{}     // user|time -> followers
    for _, r := range profiles {
        snaps[r[1]] = append(snaps[r[1]], r[0])
        follow[r[1]+"|"+r[0]], _ = strconv.Atoi(r[2])
    }
    likes := map[string]int{} // user|time|shot -> likes
    titles := map[string]string{}
    for _, r := range shots {
        likes[r[1]+"|"+r[0]+"|"+r[2]], _ = strconv.Atoi(r[5])
        titles[r[2]] = r[3]
    }
    users := make([]string, 0, len(snaps))
    for u := range snaps {
        users = append(users, u)
    }
    sort.Strings(users)
    for _, u := range users {
        ts := snaps[u]
        if len(ts) < 2 {
            fmt.Printf("%s: only one snapshot so far\n", u)
            continue
        }
        prev, cur := ts[len(ts)-2], ts[len(ts)-1]
        fmt.Printf("%s  %s → %s\n  followers %+d\n", u, prev[:16], cur[:16], follow[u+"|"+cur]-follow[u+"|"+prev])
        type gain struct {
            id string
            d  int
        }
        var gains []gain
        var fresh []string
        for k, v := range likes {
            parts := strings.SplitN(k, "|", 3)
            user, t, id := parts[0], parts[1], parts[2]
            if user != u || t != cur {
                continue
            }
            if old, ok := likes[u+"|"+prev+"|"+id]; ok {
                if v != old {
                    gains = append(gains, gain{id, v - old})
                }
            } else {
                fresh = append(fresh, titles[id]) // not in the previous snapshot
            }
        }
        sort.Slice(gains, func(i, j int) bool { return gains[i].d > gains[j].d })
        if len(gains) == 0 {
            fmt.Println("  no like changes on tracked shots")
        }
        for _, g := range gains[:min(3, len(gains))] {
            fmt.Printf("  %+5d likes  %s\n", g.d, titles[g.id])
        }
        for _, t := range fresh {
            fmt.Printf("  new shot: %s\n", t)
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode
$ ./dribbblescraper track -out portfolio -pages 1 simplebits Betraydan
simplebits       followers 117603 · likes 46450 · 24 shots snapshotted
Betraydan        followers 19180 · likes 0 · 24 shots snapshotted

$ ./dribbblescraper report -out portfolio      # one minute later
Betraydan  2026-10-30T04:08 → 2026-10-30T04:09
  followers +0
  no like changes on tracked shots
simplebits  2026-10-30T04:08 → 2026-10-30T04:09
  followers +0
  no like changes on tracked shots
Enter fullscreen mode Exit fullscreen mode

Nothing changed in a minute, which is the honest result. Run it once a day from cron and the report becomes useful:

# 09:00 every day, a handful of profiles, 1 page of shots each
0 9 * * * cd /home/me/dribbblescraper && ./dribbblescraper track -out portfolio -pages 1 simplebits Betraydan >> track.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Two profiles with one page each is 4 requests, about 8 seconds with the 2-second gap. Keep it that small.

Step 7: The CLI

package main

import (
    "encoding/csv"
    "encoding/json"
    "flag"
    "fmt"
    "os"
    "strconv"
    "strings"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Println("usage: dribbblescraper profile|shots|collections|collection|track|report [flags] args")
        os.Exit(1)
    }
    cmd := os.Args[1]
    fs := flag.NewFlagSet(cmd, flag.ExitOnError)
    pages := fs.Int("pages", 2, "max pages to fetch")
    out := fs.String("out", "", "CSV output file (shots) or directory (track/report)")
    fs.Parse(os.Args[2:])

    if err := loadRobots(); err != nil {
        fmt.Fprintln(os.Stderr, "can't read robots.txt, refusing to crawl:", err)
        os.Exit(1)
    }
    if err := run(cmd, fs.Args(), *pages, *out); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func run(cmd string, args []string, pages int, out string) error {
    if len(args) == 0 && cmd != "report" {
        return fmt.Errorf("%s: missing argument", cmd)
    }
    if (cmd == "profile" || cmd == "shots" || cmd == "collections") && normalizeUser(args[0]) == "" {
        return fmt.Errorf("%q is not a Dribbble username or profile URL", args[0])
    }
    switch cmd {
    case "profile":
        p, err := fetchProfile(normalizeUser(args[0]))
        if err != nil {
            return err
        }
        b, _ := json.MarshalIndent(p, "", "  ")
        fmt.Println(string(b))

    case "shots":
        user := normalizeUser(args[0])
        shots, err := fetchShots(user, pages)
        if err != nil && len(shots) == 0 {
            return err
        }
        fmt.Printf("%d shots from %d page(s) of %s\n", len(shots), pages, user)
        for _, s := range shots[:min(5, len(shots))] {
            fmt.Printf("  %s  %5d ♥ %7d views  %s\n", s.Published().Format("2006-01-02"), s.Likes, s.Views, s.Title)
        }
        if out != "" {
            return writeShotsCSV(out, shots)
        }

    case "collections":
        cs, err := fetchCollections(normalizeUser(args[0]), pages)
        if err != nil && len(cs) == 0 {
            return err
        }
        for _, c := range cs {
            fmt.Printf("  %-22s %4d shots · %4d designers  %s\n", c.Name, c.Shots, c.Designers, c.URL)
        }

    case "collection":
        c, err := fetchCollection(args[0], pages)
        if err != nil && len(c.Items) == 0 {
            return err
        }
        fmt.Printf("%s, curated by %s: %d shots by %d designers (%d fetched)\n", c.Name, c.Curator, c.Shots, c.Designers, len(c.Items))
        for _, s := range c.Items[:min(5, len(c.Items))] {
            fmt.Printf("  %5d ♥ %7d views  %-28s by %s\n", s.Likes, s.Views, s.Title, s.Author)
        }

    case "track":
        if out == "" {
            out = "portfolio"
        }
        var users []string
        for _, a := range args {
            if u := normalizeUser(a); u != "" {
                users = append(users, u)
            }
        }
        return track(out, users, pages)

    case "report":
        if out == "" {
            out = "portfolio"
        }
        return report(out)

    default:
        return fmt.Errorf("unknown command %q", cmd)
    }
    return nil
}

func writeShotsCSV(path string, shots []Shot) error {
    f, err := os.Create(path)
    if err != nil {
        return err
    }
    defer f.Close()
    w := csv.NewWriter(f)
    w.Write([]string{"id", "published", "title", "likes", "views", "views_raw", "rebounds", "url", "thumbnail"})
    for _, s := range shots {
        w.Write([]string{strconv.FormatInt(s.ID, 10), s.Published().Format("2006-01-02"), strings.TrimSpace(s.Title),
            strconv.Itoa(s.Likes), strconv.Itoa(s.Views), s.ViewsRaw, strconv.Itoa(s.Rebounds), s.URL(), s.Thumbnail})
    }
    w.Flush()
    return w.Error()
}
Enter fullscreen mode Exit fullscreen mode
go build -o dribbblescraper .
./dribbblescraper profile simplebits
./dribbblescraper shots -pages 3 -out simplebits.csv simplebits
./dribbblescraper collection https://dribbble.com/simplebits/collections/4582-Data-Viz
./dribbblescraper track -out portfolio simplebits Betraydan && ./dribbblescraper report -out portfolio
Enter fullscreen mode Exit fullscreen mode

Pitfalls, in one place

  • The Dribbble API is for your own account. v2 only returns the authenticated user's shots. No public endpoints for other designers.
  • Terms of Service. Dribbble's terms prohibit scraping. robots.txt allowing a page doesn't change that. Keep it small, slow, and non-commercial.
  • Shot pages, popular, and search are behind AWS WAF. You get 202 plus x-amzn-waf-action: challenge. Stop there. Profile and collection grids already have titles, likes, and views.
  • robots.txt forbids followers, following, tags, comments, fans, and rebounds. Check every path, not just the first one.
  • The old RSS feeds are gone. /<user>.rss and /shots.rss return 404, and /shots/popular.rss is challenged.
  • Two profile templates. Followers live in different places, and likes are missing on one. Test with more than one designer.
  • Lazy-loaded images. src is a 1×1 GIF; use data-src.
  • Counts are rounded. 23.3k views. Keep the raw string.
  • Dates are display strings. published_at is "September 23, 2026", with no time or time zone. Parse with "January 2, 2006" and treat it as a day.
  • Pages can shift. If a designer posts while you crawl, page 2 can repeat a shot from page 1. De-duplicate by shot ID.
  • The markup will change. The shots JSON and the custom elements (drb-shot-metadata, data-shot-like-count) are steadier than layout classes, but none of this is a contract.

When you'd rather not maintain the parsers

If you need more than a few designers, or you just don't want to fix two profile templates every time Dribbble ships a redesign, I also run ScrapeAtlas*, which has four Dribbble endpoints that return JSON for the same public pages:

Endpoint Params Returns
GET /v1/dribbble/profile username (or a profile URL) profile: name, headline, bio, location, followersCount, followingCount, likesCount, skills, languages, website, socialLinks, isPro, isTeam
GET /v1/dribbble/shots username, page (1-100) shots[] with title, thumbnailUrl, videoUrl, author, likesCount, viewsCount, plus pagination.nextPage
GET /v1/dribbble/collections username, page collections[] with name, shotsCount, designersCount, thumbnailUrls
GET /v1/dribbble/collection url (a /<user>/collections/<id>-<slug> URL), page collection (name, description, owner, counts) and one page of shots[]

Auth is an x-api-key header. Each successful request costs one credit, and failed requests cost nothing:

req, _ := http.NewRequest("GET", "https://api.scrapeatlas.com/v1/dribbble/shots?username=simplebits&page=2", nil)
req.Header.Set("x-api-key", os.Getenv("SCRAPEATLAS_API_KEY"))
res, err := http.DefaultClient.Do(req)
// Response: {"success":true,"status":"success"|"partial", "shots":[...],
//            "pagination":{"page":2,"nextPage":3},
//            "coverage":{"complete":false,"warnings":["metrics_unavailable"]}}
Enter fullscreen mode Exit fullscreen mode

Read status and coverage.warnings. When a page doesn't show likes or views, likesCount/viewsCount come back as null with a metrics_unavailable warning instead of a made-up 0. That's the same honesty you want from your own scraper. It reads the same public pages as the DIY version, so it doesn't give you follower lists, comments, or anything robots.txt rules out.

For a handful of designers you track daily, though, the DIY scraper above is enough.

Wrapping up

  • The official Dribbble API is your-account-only. Great for showing your own shots on your site, no help for research.
  • Profiles and collections are server-rendered. Shots come as embedded JSON (newestShots), and ?page=N still works logged out.
  • Respect the walls. robots.txt rules out followers, tags, comments, and fans; AWS WAF guards shot pages and search. Stop, don't bypass.
  • Expect two templates, lazy images, and rounded counts.
  • Track changes, not snapshots. A tiny daily cron job turns a scraper into a portfolio tracker.

What would you track on Dribbble: your own shots, a list of designers you'd like to hire, or which topics get the most likes? Tell me in the comments.


*I build it.

Top comments (1)

Collapse
 
alexcodebytes profile image
Oleksandr •

The 'robots.txt allowing a page doesn't change the Terms of Service' is the most important line in this post. Too many people treat robots.txt as permission, but it's just a technical signal. The legal risk is separate, and it's good that you're explicit about it.