Skip to content

Instantly share code, notes, and snippets.

@blachniet
Last active October 17, 2025 07:06
Show Gist options
  • Select an option

  • Save blachniet/6a659f347219b6ca961262fdf22bb689 to your computer and use it in GitHub Desktop.

Select an option

Save blachniet/6a659f347219b6ca961262fdf22bb689 to your computer and use it in GitHub Desktop.
package main
import (
"encoding/csv"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
)
func usage() {
mt.Fprintf(flag.CommandLine.Output(), "stars2drops - convert GitHub stars to a Raindrop.io CSV file\n\n")
mt.Fprintf(flag.CommandLine.Output(), "You can upload the resulting CSV file to your Raindrop.io account here: https://app.raindrop.io/settings/import. See https://help.raindrop.io/import/#csv for more information on the CSV format.")
mt.Fprintf(flag.CommandLine.Output(), "\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "environment variables:\n")
fmt.Fprintf(flag.CommandLine.Output(), " GH_PAT\n")
fmt.Fprintf(flag.CommandLine.Output(), " \trequired - GitHub fine-grained token with read-only 'starring' permissions: https://github.com/settings/tokens?type=beta\n")
fmt.Fprintf(flag.CommandLine.Output(), "\nargs:\n")
flag.PrintDefaults()
}
func main() {
var folder string
var tags string
flag.StringVar(&folder, "folder", "Unsorted", "Raindrop.io folder to put bookmarks in")
flag.StringVar(&tags, "tags", "", "comma-separated list of tags to apply to all bookmarks")
flag.Usage = usage
flag.Parse()
pat := os.Getenv("GH_PAT")
if pat == "" {
log.Fatalln("err: GH_PAT environment varable not set")
}
stars, err := getStars(pat)
if err != nil {
log.Fatalln("err: retrieving stars: ", err)
}
writer := csv.NewWriter(os.Stdout)
err = writer.Write([]string{"url", "folder", "title", "description", "tags", "created"})
if err != nil {
log.Fatal("err: write csv: ", err)
}
for _, star := range stars {
err = writer.Write([]string{star.Repo.HTMLURL, folder, star.Repo.FullName, star.Repo.Description, tags, star.StarredAt})
if err != nil {
log.Fatalln("err: write csv: ", err)
}
}
writer.Flush()
if err = writer.Error(); err != nil {
log.Fatalln("err: write csv: ", err)
}
}
func getStars(pat string) ([]ghStar, error) {
var stars []ghStar
req, err := http.NewRequest(http.MethodGet, "https://api.github.com/user/starred", nil)
if err != nil {
return stars, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github.star+json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", pat))
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
client := &http.Client{}
res, err := client.Do(req)
if err != nil {
return stars, fmt.Errorf("do request: %w", err)
}
err = json.NewDecoder(res.Body).Decode(&stars)
if err != nil {
return stars, fmt.Errorf("decode json: %w", err)
}
res.Body.Close()
return stars, nil
}
type ghStar struct {
StarredAt string `json:"starred_at"`
Repo ghStarRepo `json:"repo"`
}
type ghStarRepo struct {
HTMLURL string `json:"html_url"`
FullName string `json:"full_name"`
Description string `json:"description"`
}
@VKambulov
Copy link

I'll leave this for posterity. Only the first page of stars was exported, and the result was displayed in the terminal. I had over 700 stars, and this was inconvenient. Here's a slightly modified version with pagination and export to a file:

package main

import (
	"encoding/csv"
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"regexp"
)

func usage() {
	fmt.Fprintf(flag.CommandLine.Output(), "stars2drops - convert GitHub stars to a Raindrop.io CSV file\n\n")
	fmt.Fprintf(flag.CommandLine.Output(), "You can upload the resulting CSV file to your Raindrop.io account: https://app.raindrop.io/settings/import\n")
	fmt.Fprintf(flag.CommandLine.Output(), "See https://help.raindrop.io/import/#csv for info on CSV format.\n\n")
	fmt.Fprintf(flag.CommandLine.Output(), "environment variables:\n")
	fmt.Fprintf(flag.CommandLine.Output(), " GH_PAT\n")
	fmt.Fprintf(flag.CommandLine.Output(), "   required - GitHub token with read-only 'starring' permissions\n\n")
	fmt.Fprintf(flag.CommandLine.Output(), "args:\n")
	flag.PrintDefaults()
}

func main() {
	var folder, tags, outFile string
	flag.StringVar(&folder, "folder", "Unsorted", "Raindrop.io folder to put bookmarks in")
	flag.StringVar(&tags, "tags", "", "comma-separated tags to apply to all bookmarks")
	flag.StringVar(&outFile, "out", "output.csv", "output file for CSV results")
	flag.Usage = usage
	flag.Parse()

	pat := os.Getenv("GH_PAT")
	if pat == "" {
		log.Fatalln("err: GH_PAT environment variable not set")
	}

	stars, err := getAllStars(pat)
	if err != nil {
		log.Fatalln("err: retrieving stars: ", err)
	}

	file, err := os.Create(outFile)
	if err != nil {
		log.Fatalln("err: cannot create output file: ", err)
	}
	defer file.Close()
	writer := csv.NewWriter(file)
	defer writer.Flush()
	err = writer.Write([]string{"url", "folder", "title", "description", "tags", "created"})
	if err != nil {
		log.Fatal("err: write csv header: ", err)
	}
	for _, star := range stars {
		err = writer.Write([]string{
			star.Repo.HTMLURL,
			folder,
			star.Repo.FullName,
			star.Repo.Description,
			tags,
			star.StarredAt,
		})
		if err != nil {
			log.Fatalln("err: write csv row: ", err)
		}
	}
	writer.Flush()
	if err = writer.Error(); err != nil {
		log.Fatalln("err: write csv: ", err)
	}
	fmt.Println("Data saved to", outFile)
}

func getAllStars(pat string) ([]ghStar, error) {
	var all []ghStar
	client := &http.Client{}
	nextURL := "https://api.github.com/user/starred?per_page=100"
	for {
		req, err := http.NewRequest("GET", nextURL, nil)
		if err != nil {
			return all, fmt.Errorf("build request: %w", err)
		}
		req.Header.Set("Accept", "application/vnd.github.star+json")
		req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", pat))
		req.Header.Set("X-GitHub-Api-Version", "2022-11-28")

		res, err := client.Do(req)
		if err != nil {
			return all, fmt.Errorf("do request: %w", err)
		}
		defer res.Body.Close()

		var stars []ghStar
		if err := json.NewDecoder(res.Body).Decode(&stars); err != nil {
			return all, fmt.Errorf("decode json: %w", err)
		}
		all = append(all, stars...)

		// Parse "Link" header for pagination
		link := res.Header.Get("Link")
		re := regexp.MustCompile(`<([^>]+)>;\s*rel="next"`)
		matches := re.FindStringSubmatch(link)
		if len(matches) == 2 {
			nextURL = matches[1]
		} else {
			break
		}
	}
	return all, nil
}

type ghStar struct {
	StarredAt string     `json:"starred_at"`
	Repo      ghStarRepo `json:"repo"`
}
type ghStarRepo struct {
	HTMLURL     string `json:"html_url"`
	FullName    string `json:"full_name"`
	Description string `json:"description"`
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment