diff --git a/README.md b/README.md
index ff96f89..cfcbc64 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,77 @@
-# statootstics
+statootstics
+============
+
Mastodon Statistics Generator
+
+## Installation
+
+Make sure you have a working Go environment (Go 1.7 or higher is required).
+See the [install instructions](http://golang.org/doc/install.html).
+
+To install statootstics, simply run:
+
+ go get github.com/muesli/statootstics
+
+## Usage
+
+```
+$ statootstics -help
+Usage of ./statootstics:
+ -columns int
+ displays tables with N columns (default 80)
+ -config string
+ uses the specified config file (default "mastodon.json")
+ -recent int
+ only account for the N most recent toots (excl replies & boosts)
+ -top int
+ shows the top N items in each category (default 10)
+
+$ statootstics
+Loading toots for some_user 100 of 100 [#>---------------------------] 100.00%
+
+Total toots: 100 (excluding replies & boosts)
+Toots per day: 1.00 (account created 100 days ago)
+Ratio toots/replies: 0.33
+New followers per day: 7.41
+New followings per day: 3.67
+Likes per toot: 9.00 (total likes: 900)
+Boosts per toot: 2.50 (total boosts: 250)
+
+Users you mentioned most Interactions
+----------------------------------------------------------------------------------
+abc 3
+
+Users you boosted most Interactions
+----------------------------------------------------------------------------------
+xyz 7
+
+Most replied-to toots Replies
+----------------------------------------------------------------------------------
+Some toot 20
+
+Most liked toots Likes
+----------------------------------------------------------------------------------
+Some toot 50
+
+Most boosted toots Boosts
+----------------------------------------------------------------------------------
+Some toot 10
+
+Highest scoring toots Score
+----------------------------------------------------------------------------------
+Some toot 80
+
+Tags used that got the most likes Likes
+----------------------------------------------------------------------------------
+Some tag 10
+
+Tags used that got the most boosts Boosts
+----------------------------------------------------------------------------------
+Some tag 5
+```
+
+## Development
+
+[![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://godoc.org/github.com/mueslistatootstics)
+[![Build Status](https://travis-ci.org/muesli/statootstics.svg?branch=master)](https://travis-ci.org/muesli/statootstics)
+[![Go ReportCard](http://goreportcard.com/badge/muesli/statootstics)](http://goreportcard.com/report/muesli/statootstics)
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..ac89da1
--- /dev/null
+++ b/config.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+ "encoding/json"
+ "io/ioutil"
+)
+
+type Option struct {
+ Name string
+ Value interface{}
+}
+
+type Config struct {
+ Options []Option
+}
+
+func LoadConfig(filename string) (Config, error) {
+ config := Config{}
+
+ j, err := ioutil.ReadFile(filename)
+ if err != nil {
+ return config, err
+ }
+
+ err = json.Unmarshal(j, &config)
+ return config, err
+}
+
+func (c Config) Save(filename string) error {
+ j, err := json.MarshalIndent(c, "", " ")
+ if err != nil {
+ return err
+ }
+ return ioutil.WriteFile(filename, j, 0644)
+}
+
+func (c Config) Value(name string) interface{} {
+ for _, v := range c.Options {
+ if v.Name == name {
+ return v.Value
+ }
+ }
+
+ return nil
+}
+
+func (c *Config) Set(name, value string) interface{} {
+ found := false
+ var opts []Option
+ for _, v := range c.Options {
+ if v.Name == name {
+ v.Value = value
+ found = true
+ }
+
+ opts = append(opts, v)
+ }
+
+ if !found {
+ opts = append(opts, Option{name, value})
+ }
+
+ c.Options = opts
+ return nil
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..3e8b6df
--- /dev/null
+++ b/main.go
@@ -0,0 +1,198 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "syscall"
+ "time"
+
+ mastodon "github.com/mattn/go-mastodon"
+ "github.com/muesli/goprogressbar"
+ "golang.org/x/crypto/ssh/terminal"
+)
+
+var (
+ client *mastodon.Client
+
+ topN = flag.Int("top", 10, "shows the top N items in each category")
+ maxToots = flag.Int("recent", 0, "only account for the N most recent toots (excl replies & boosts)")
+ columns = flag.Int("columns", 80, "displays tables with N columns")
+ configFile = flag.String("config", "mastodon.json", "uses the specified config file")
+ // user = flag.String("user", "@fribbledom@mastodon.social", "shows stats for this user")
+)
+
+func readPassword(prompt string) (string, error) {
+ var tty io.WriteCloser
+ tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
+ if err != nil {
+ tty = os.Stdout
+ } else {
+ defer tty.Close()
+ }
+
+ fmt.Fprint(tty, prompt+" ")
+ buf, err := terminal.ReadPassword(int(syscall.Stdin))
+ fmt.Fprintln(tty)
+
+ return string(buf), err
+}
+
+func registerApp(config *Config) error {
+ app, err := mastodon.RegisterApp(context.Background(), &mastodon.AppConfig{
+ Server: config.Value("instance").(string),
+ ClientName: "statootstics",
+ Scopes: "read write follow",
+ Website: "",
+ })
+ if err != nil {
+ return err
+ }
+
+ config.Set("id", app.ClientID)
+ config.Set("secret", app.ClientSecret)
+ return nil
+}
+
+func initClient() {
+ var err error
+ var instance, username, password, id, secret string
+ config, err := LoadConfig(*configFile)
+ if err == nil {
+ instance = config.Value("instance").(string)
+ username = config.Value("username").(string)
+ secret = config.Value("secret").(string)
+ id = config.Value("id").(string)
+ if config.Value("password") != nil {
+ password = config.Value("password").(string)
+ }
+ }
+
+ scanner := bufio.NewScanner(os.Stdin)
+ if len(instance) == 0 {
+ fmt.Print("Which instance to connect to (e.g. https://mastodon.social): ")
+ scanner.Scan()
+ if scanner.Err() != nil {
+ panic(err)
+ }
+ instance = scanner.Text()
+ }
+
+ if len(username) == 0 {
+ fmt.Print("Username (email): ")
+ scanner.Scan()
+ if scanner.Err() != nil {
+ panic(err)
+ }
+ username = scanner.Text()
+ }
+
+ config.Set("instance", instance)
+ config.Set("username", username)
+
+ if len(id) == 0 {
+ err = registerApp(&config)
+ if err != nil {
+ panic(err)
+ }
+
+ id = config.Value("id").(string)
+ secret = config.Value("secret").(string)
+ }
+ config.Save(*configFile)
+
+ if len(password) == 0 {
+ password, err = readPassword("Password:")
+ if err != nil {
+ panic(err)
+ }
+ }
+
+ client = mastodon.NewClient(&mastodon.Config{
+ Server: instance,
+ ClientID: id,
+ ClientSecret: secret,
+ })
+ err = client.Authenticate(context.Background(), username, password)
+ if err != nil {
+ panic(err)
+ }
+}
+
+func main() {
+ flag.Parse()
+
+ initClient()
+ self, err := client.GetAccountCurrentUser(context.Background())
+ if err != nil {
+ panic(err)
+ }
+ /*
+ accounts, err := client.AccountsSearch(context.Background(), *user, 1)
+ if err != nil {
+ panic(err)
+ }
+ self := accounts[0]
+ */
+
+ stats := &stats{
+ DaysActive: int(time.Since(self.CreatedAt).Hours() / 24),
+ Followers: self.FollowersCount,
+ Following: self.FollowingCount,
+ Toots: make(map[string]*tootStat),
+ Tags: make(map[string]*tootStat),
+ Replies: make(map[string]*tootStat),
+ Mentions: make(map[string]int64),
+ Boosts: make(map[string]int64),
+ }
+ pb := &goprogressbar.ProgressBar{
+ Text: fmt.Sprintf("Loading toots for %s", self.Username),
+ Total: self.StatusesCount,
+ PrependTextFunc: func(p *goprogressbar.ProgressBar) string {
+ return fmt.Sprintf("%d of %d", p.Current, int64(math.Max(float64(p.Current), float64(self.StatusesCount))))
+ },
+ Current: 0,
+ Width: 40,
+ }
+
+ var pg mastodon.Pagination
+ for {
+ pg.Limit = 40
+ statuses, err := client.GetAccountStatuses(context.Background(), self.ID, &pg)
+ if err != nil {
+ panic(err)
+ }
+
+ for _, s := range statuses {
+ err = parseToot(s, stats)
+ if err != nil {
+ panic(err)
+ }
+
+ pb.Current += 1
+ pb.LazyPrint()
+
+ if *maxToots > 0 && len(stats.Toots) >= *maxToots {
+ break
+ }
+ }
+
+ if *maxToots > 0 && len(stats.Toots) >= *maxToots {
+ break
+ }
+ if pg.MaxID == "" {
+ break
+ }
+ time.Sleep(1000 * time.Millisecond)
+ }
+
+ fmt.Printf("\n\n")
+ printAccountStats(stats)
+ printInteractionStats(stats)
+ printTootStats(stats)
+ printTagStats(stats)
+}
diff --git a/stats.go b/stats.go
new file mode 100644
index 0000000..b1fcd84
--- /dev/null
+++ b/stats.go
@@ -0,0 +1,295 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "html"
+ "sort"
+ "strconv"
+ "strings"
+
+ mastodon "github.com/mattn/go-mastodon"
+ "github.com/microcosm-cc/bluemonday"
+ "github.com/muesli/gotable"
+)
+
+var (
+ stripper = bluemonday.StrictPolicy()
+)
+
+const (
+ SortByLikes = iota
+ SortByBoosts
+ SortByScore
+ SortByReplies
+)
+
+type tootStat struct {
+ Likes int64
+ Boosts int64
+ Replies int64
+}
+
+type stats struct {
+ DaysActive int
+ Followers int64
+ Following int64
+ Toots map[string]*tootStat
+ Tags map[string]*tootStat
+ Replies map[string]*tootStat
+ Mentions map[string]int64
+ Boosts map[string]int64
+}
+
+func parseToot(status *mastodon.Status, stats *stats) error {
+ // handle mentions
+ for _, m := range status.Mentions {
+ stats.Mentions[m.Acct]++
+ }
+
+ // handle boosts
+ if status.Reblog != nil {
+ stats.Boosts[status.Reblog.Account.Acct]++
+ return nil
+ }
+
+ var replies int64
+
+ // parse tags
+ if status.InReplyToID == nil {
+ contexts, err := client.GetStatusContext(context.Background(), status.ID)
+ if err != nil {
+ panic(err)
+ }
+ replies = int64(len(contexts.Descendants))
+
+ for _, t := range status.Tags {
+ tag := strings.ToLower(t.Name)
+
+ stat, ok := stats.Tags[tag]
+ if ok {
+ stat.Likes += status.FavouritesCount
+ stat.Boosts += status.ReblogsCount
+ stat.Replies += replies
+ } else {
+ stat = &tootStat{
+ Likes: status.FavouritesCount,
+ Boosts: status.ReblogsCount,
+ Replies: replies,
+ }
+
+ stats.Tags[tag] = stat
+ }
+ }
+ }
+
+ // clean up toot for terminal output
+ content := strings.Replace(status.Content, "
", "\n", -1)
+ content = strings.Replace(content, "
", "\n", -1) + content = strings.Replace(content, "
", "", -1) + content = html.UnescapeString(stripper.Sanitize(content)) + content = strings.TrimSpace(strings.Replace(content, "\n", " ", -1)) + + // handle replies + if status.InReplyToID != nil { + stats.Replies[content] = &tootStat{ + Likes: status.FavouritesCount, + Boosts: status.ReblogsCount, + Replies: replies, + } + } else { + stats.Toots[content] = &tootStat{ + Likes: status.FavouritesCount, + Boosts: status.ReblogsCount, + Replies: replies, + } + } + + return nil +} + +type StatSorter struct { + SortKey int + Key []string + Stats []*tootStat +} + +func (a StatSorter) Len() int { + return len(a.Stats) +} + +func (a StatSorter) Swap(i, j int) { + a.Key[i], a.Key[j] = a.Key[j], a.Key[i] + a.Stats[i], a.Stats[j] = a.Stats[j], a.Stats[i] +} + +func (a StatSorter) Less(i, j int) bool { + switch a.SortKey { + case SortByReplies: + return a.Stats[i].Replies < a.Stats[j].Replies + case SortByLikes: + return a.Stats[i].Likes < a.Stats[j].Likes + case SortByBoosts: + return a.Stats[i].Boosts < a.Stats[j].Boosts + case SortByScore: + return (a.Stats[i].Boosts*3)+a.Stats[i].Likes < + (a.Stats[j].Boosts*3)+a.Stats[j].Likes + default: + panic("SortKey is incorrect") + } +} + +type kv struct { + Key string + Value int64 +} + +func printTable(cols []string, emptyText string, data []kv) { + sort.Slice(data, func(i, j int) bool { + return data[i].Value > data[j].Value + }) + + col1 := *columns - len(cols[1]) + col2 := len(cols[1]) + tab := gotable.NewTable(cols, + []int64{-int64(col1), int64(col2)}, + emptyText) + + for i, kv := range data { + if i >= *topN { + break + } + if len(kv.Key) > col1-4 { + kv.Key = kv.Key[:col1-4] + "..." + } + + tab.AppendRow([]interface{}{kv.Key, strconv.FormatInt(int64(kv.Value), 10)}) + } + tab.Print() + fmt.Println() +} + +func printTootTable(cols []string, emptyText string, toots []string, tootStats []*tootStat, sortKey int) { + sort.Sort(sort.Reverse(StatSorter{sortKey, toots, tootStats})) + + var ss []kv + for k, v := range toots { + switch sortKey { + case SortByReplies: + if tootStats[k].Replies == 0 { + continue + } + ss = append(ss, kv{v, tootStats[k].Replies}) + case SortByLikes: + if tootStats[k].Likes == 0 { + continue + } + ss = append(ss, kv{v, tootStats[k].Likes}) + case SortByBoosts: + if tootStats[k].Boosts == 0 { + continue + } + ss = append(ss, kv{v, tootStats[k].Boosts}) + case SortByScore: + score := (tootStats[k].Boosts * 3) + tootStats[k].Likes + if score == 0 { + continue + } + ss = append(ss, kv{v, score}) + } + } + + printTable(cols, emptyText, ss) +} + +func printAccountStats(stats *stats) { + var likes, boosts int64 + for _, t := range stats.Toots { + likes += t.Likes + boosts += t.Boosts + } + + fmt.Printf("Total toots: %d (excluding replies & boosts)\n", len(stats.Toots)) + fmt.Printf("Toots per day: %.2f (account created %d days ago)\n", + float64(len(stats.Toots))/float64(stats.DaysActive), + stats.DaysActive) + fmt.Printf("Ratio toots/replies: %.2f\n", + float64(len(stats.Toots))/float64(len(stats.Replies))) + fmt.Printf("New followers per day: %.2f\n", + float64(stats.Followers)/float64(stats.DaysActive)) + fmt.Printf("New followings per day: %.2f\n", + float64(stats.Following)/float64(stats.DaysActive)) + fmt.Printf("Likes per toot: %.2f (total likes: %d)\n", + float64(likes)/float64(len(stats.Toots)), + likes) + fmt.Printf("Boosts per toot: %.2f (total boosts: %d)\n", + float64(boosts)/float64(len(stats.Toots)), + boosts) + fmt.Println() +} + +func printInteractionStats(stats *stats) { + var ss []kv + for k, v := range stats.Mentions { + ss = append(ss, kv{k, v}) + } + printTable([]string{"Users you mentioned most", "Interactions"}, + "No interactions found.", + ss) + + ss = []kv{} + for k, v := range stats.Boosts { + ss = append(ss, kv{k, v}) + } + printTable([]string{"Users you boosted most", "Interactions"}, + "No interactions found.", + ss) +} + +func printTootStats(stats *stats) { + var toots []string + var tootStats []*tootStat + for toot, s := range stats.Toots { + toots = append(toots, toot) + tootStats = append(tootStats, s) + } + + // most replied-to toots + printTootTable([]string{"Most replied-to toots", "Replies"}, + "No toots found.", + toots, tootStats, SortByReplies) + + // most liked toots + printTootTable([]string{"Most liked toots", "Likes"}, + "No toots found.", + toots, tootStats, SortByLikes) + + // most boosted toots + printTootTable([]string{"Most boosted toots", "Boosts"}, + "No toots found.", + toots, tootStats, SortByBoosts) + + // highest scoring toots + printTootTable([]string{"Highest scoring toots", "Score"}, + "No toots found.", + toots, tootStats, SortByScore) +} + +func printTagStats(stats *stats) { + var tags []string + var tagStats []*tootStat + for tag, s := range stats.Tags { + tags = append(tags, tag) + tagStats = append(tagStats, s) + } + + // most liked tags + printTootTable([]string{"Tags used that got the most likes", "Likes"}, + "No toots found.", + tags, tagStats, SortByLikes) + + // most boosted tags + printTootTable([]string{"Tags used that got the most boosts", "Boosts"}, + "No toots found.", + tags, tagStats, SortByBoosts) +}