mirror of
https://github.com/muesli/mastotool
synced 2025-02-26 16:47:53 +01:00
Initial import
This commit is contained in:
parent
3a7dcf6be8
commit
e5504270b2
77
README.md
77
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
|
||||
|
||||
[](https://godoc.org/github.com/mueslistatootstics)
|
||||
[](https://travis-ci.org/muesli/statootstics)
|
||||
[](http://goreportcard.com/report/muesli/statootstics)
|
||||
|
65
config.go
Normal file
65
config.go
Normal 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
198
main.go
Normal 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
295
stats.go
Normal 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)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user