Initial import

This commit is contained in:
Christian Muehlhaeuser 2018-11-22 04:35:51 +01:00
parent 3a7dcf6be8
commit e5504270b2
No known key found for this signature in database
GPG Key ID: 3CF9FA45CA1EBB7E
4 changed files with 634 additions and 1 deletions

View File

@ -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)

65
config.go Normal file
View File

@ -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
}

198
main.go Normal file
View File

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

295
stats.go Normal file
View File

@ -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, "<br>", "\n", -1)
content = strings.Replace(content, "<p>", "\n", -1)
content = strings.Replace(content, "</p>", "", -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)
}