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) +}