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:
- Respect
robots.txtwith a tiny matcher, and stop cleanly at bot challenges - Read a designer profile: name, headline, bio, location, followers, skills
- Page through a designer's shots with likes, views, and publish dates
- List a designer's collections, and read the shots inside one
- 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.txtallows 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
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/*)
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":[]}
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
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)
}
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
}
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)
}
}
$ 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
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])
}
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
}
$ ./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,
...
}
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
}
$ ./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
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
}
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
}
$ ./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
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
}
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
}
$ ./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
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
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()
}
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
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
202plusx-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>.rssand/shots.rssreturn 404, and/shots/popular.rssis challenged. - Two profile templates. Followers live in different places, and likes are missing on one. Test with more than one designer.
-
Lazy-loaded images.
srcis a 1×1 GIF; usedata-src. -
Counts are rounded.
23.3kviews. Keep the raw string. -
Dates are display strings.
published_atis"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"]}}
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=Nstill 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)
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.