bluesky viewer in the terminal
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: split followers & following commands

+469 -458
+315 -458
cli/cmd/followers.go
··· 29 29 IsQuiet bool 30 30 } 31 31 32 - // enrichFollowerProfiles fetches full profiles and merges them with lightweight profiles 33 - func enrichFollowerProfiles(ctx context.Context, service *store.BlueskyService, profiles []store.ActorProfile, logger *log.Logger) ([]followerInfo, []string) { 34 - logger.Infof("Fetching detailed profiles for %d accounts...", len(profiles)) 35 - actors := make([]string, len(profiles)) 36 - for i, profile := range profiles { 37 - actors[i] = profile.Did 38 - } 39 - 40 - fullProfiles := service.BatchGetProfiles(ctx, actors, 10) 41 - logger.Infof("Fetched %d detailed profiles", len(fullProfiles)) 42 - 43 - followerInfos := make([]followerInfo, len(profiles)) 44 - for i, profile := range profiles { 45 - if fullProfile, ok := fullProfiles[profile.Did]; ok { 46 - followerInfos[i] = followerInfo{Profile: fullProfile} 47 - } else { 48 - followerInfos[i] = followerInfo{Profile: &profile} 49 - } 50 - } 51 - 52 - return followerInfos, actors 32 + type diffOutput struct { 33 + NewFollowers []string `json:"newFollowers"` 34 + Unfollows []string `json:"unfollows"` 35 + Summary struct { 36 + BaselineCount int `json:"baselineCount"` 37 + ComparisonCount int `json:"comparisonCount"` 38 + NetChange int `json:"netChange"` 39 + NewCount int `json:"newCount"` 40 + UnfollowCount int `json:"unfollowCount"` 41 + } `json:"summary"` 53 42 } 54 43 55 - // filterInactive filters follower infos to only include accounts inactive for N days 56 - func filterInactive(ctx context.Context, service *store.BlueskyService, cacheRepo *store.CacheRepository, followerInfos []followerInfo, actors []string, inactiveDays int, refresh bool, logger *log.Logger) []followerInfo { 57 - logger.Infof("Checking activity status (threshold: %d days)...", inactiveDays) 58 - 59 - lastPostDates := service.BatchGetLastPostDatesCached(ctx, cacheRepo, actors, 10, refresh) 60 - 61 - var filtered []followerInfo 62 - for i, info := range followerInfos { 63 - lastPost, ok := lastPostDates[actors[i]] 64 - info.LastPostDate = lastPost 65 - 66 - if !ok || lastPost.IsZero() { 67 - info.IsInactive = true 68 - info.DaysSincePost = -1 69 - } else { 70 - daysSince := int(time.Since(lastPost).Hours() / 24) 71 - info.DaysSincePost = daysSince 72 - info.IsInactive = daysSince > inactiveDays 73 - } 74 - 75 - if info.IsInactive { 76 - filtered = append(filtered, info) 77 - } 78 - followerInfos[i] = info 44 + // FollowersCommand returns the followers command with all subcommands 45 + func FollowersCommand() *cli.Command { 46 + return &cli.Command{ 47 + Name: "followers", 48 + Usage: "Manage and analyze followers", 49 + Commands: []*cli.Command{ 50 + { 51 + Name: "list", 52 + Usage: "List followers for a user", 53 + UsageText: "Fetch all followers with optional filters for inactivity, date range, and output format.", 54 + ArgsUsage: " ", 55 + Flags: []cli.Flag{ 56 + &cli.StringFlag{ 57 + Name: "user", 58 + Aliases: []string{"u"}, 59 + Usage: "User handle or DID (defaults to authenticated user)", 60 + }, 61 + &cli.IntFlag{ 62 + Name: "limit", 63 + Aliases: []string{"l"}, 64 + Usage: "Maximum number of followers to fetch (0 = all)", 65 + Value: 0, 66 + }, 67 + &cli.StringFlag{ 68 + Name: "since", 69 + Usage: "Filter followers created after date (YYYY-MM-DD)", 70 + }, 71 + &cli.IntFlag{ 72 + Name: "inactive", 73 + Usage: "Show only followers with no posts in N days", 74 + Value: 0, 75 + }, 76 + &cli.BoolFlag{ 77 + Name: "quiet", 78 + Usage: "Show only quiet posters (low posting frequency)", 79 + }, 80 + &cli.FloatFlag{ 81 + Name: "threshold", 82 + Usage: "Posts per day threshold for quiet posters (used with --quiet)", 83 + Value: 1.0, 84 + }, 85 + &cli.StringFlag{ 86 + Name: "output", 87 + Aliases: []string{"o"}, 88 + Usage: "Output format: table, json, csv", 89 + Value: "table", 90 + }, 91 + &cli.BoolFlag{ 92 + Name: "refresh", 93 + Usage: "Force refresh cached data (bypasses 24-hour cache)", 94 + }, 95 + }, 96 + Action: ListFollowersAction, 97 + }, 98 + { 99 + Name: "stats", 100 + Usage: "Show aggregate follower statistics", 101 + UsageText: "Calculate aggregate statistics including active/inactive counts, growth metrics, and optional ASCII chart.", 102 + ArgsUsage: " ", 103 + Flags: []cli.Flag{ 104 + &cli.StringFlag{ 105 + Name: "user", 106 + Aliases: []string{"u"}, 107 + Usage: "User handle or DID (defaults to authenticated user)", 108 + }, 109 + &cli.StringFlag{ 110 + Name: "since", 111 + Usage: "Calculate growth since date (YYYY-MM-DD)", 112 + }, 113 + &cli.IntFlag{ 114 + Name: "inactive", 115 + Usage: "Threshold for inactive status (days)", 116 + Value: 60, 117 + }, 118 + &cli.BoolFlag{ 119 + Name: "chart", 120 + Usage: "Display ASCII bar chart", 121 + }, 122 + }, 123 + Action: FollowersStatsAction, 124 + }, 125 + { 126 + Name: "diff", 127 + Usage: "Compare follower lists between two dates", 128 + UsageText: "Compare follower lists to identify new followers and unfollows. Without --until, compares snapshot to current live data.", 129 + ArgsUsage: " ", 130 + Flags: []cli.Flag{ 131 + &cli.StringFlag{ 132 + Name: "user", 133 + Aliases: []string{"u"}, 134 + Usage: "User handle or DID (defaults to authenticated user)", 135 + }, 136 + &cli.StringFlag{ 137 + Name: "since", 138 + Usage: "Start date (YYYY-MM-DD) or snapshot ID", 139 + Required: true, 140 + }, 141 + &cli.StringFlag{ 142 + Name: "until", 143 + Usage: "End date (YYYY-MM-DD) or snapshot ID (omit to compare with live data)", 144 + }, 145 + &cli.StringFlag{ 146 + Name: "output", 147 + Aliases: []string{"o"}, 148 + Usage: "Output format: table, json, csv", 149 + Value: "table", 150 + }, 151 + }, 152 + Action: FollowersDiffAction, 153 + }, 154 + { 155 + Name: "export", 156 + Usage: "Export followers to CSV or JSON", 157 + UsageText: "Export follower list to CSV or JSON for external analysis, archival, or backup purposes.", 158 + ArgsUsage: " ", 159 + Flags: []cli.Flag{ 160 + &cli.StringFlag{ 161 + Name: "user", 162 + Aliases: []string{"u"}, 163 + Usage: "User handle or DID (defaults to authenticated user)", 164 + }, 165 + &cli.IntFlag{ 166 + Name: "inactive", 167 + Usage: "Export only followers with no posts in N days", 168 + Value: 0, 169 + }, 170 + &cli.BoolFlag{ 171 + Name: "quiet", 172 + Usage: "Export only quiet posters (low posting frequency)", 173 + }, 174 + &cli.FloatFlag{ 175 + Name: "threshold", 176 + Usage: "Posts per day threshold for quiet posters (used with --quiet)", 177 + Value: 1.0, 178 + }, 179 + &cli.StringFlag{ 180 + Name: "output", 181 + Aliases: []string{"o"}, 182 + Usage: "Output format: json, csv", 183 + Value: "csv", 184 + Required: true, 185 + }, 186 + &cli.BoolFlag{ 187 + Name: "refresh", 188 + Usage: "Force refresh cached data (bypasses 24-hour cache)", 189 + }, 190 + }, 191 + Action: FollowersExportAction, 192 + }, 193 + }, 79 194 } 80 - 81 - return filtered 82 - } 83 - 84 - // filterQuiet filters follower infos to only include quiet posters 85 - func filterQuiet(ctx context.Context, service *store.BlueskyService, cacheRepo *store.CacheRepository, followerInfos []followerInfo, actors []string, threshold float64, refresh bool, logger *log.Logger) []followerInfo { 86 - logger.Infof("Computing post rates (threshold: %.2f posts/day)...", threshold) 87 - if refresh { 88 - logger.Infof("Refreshing cache (this may take a while)...") 89 - } 90 - 91 - postRates := service.BatchGetPostRatesCached(ctx, cacheRepo, actors, 30, 30, 10, refresh, func(current, total int) { 92 - if current%10 == 0 || current == total { 93 - logger.Infof("Progress: %d/%d accounts analyzed", current, total) 94 - } 95 - }) 96 - 97 - var filtered []followerInfo 98 - for i, info := range followerInfos { 99 - if rate, ok := postRates[actors[i]]; ok { 100 - info.PostsPerDay = rate.PostsPerDay 101 - info.LastPostDate = rate.LastPostDate 102 - info.IsQuiet = rate.PostsPerDay <= threshold 103 - } 104 - 105 - if info.IsQuiet { 106 - filtered = append(filtered, info) 107 - } 108 - followerInfos[i] = info 109 - } 110 - 111 - logger.Infof("Found %d quiet posters (posting <= %.2f times/day)", len(filtered), threshold) 112 - return filtered 113 195 } 114 196 115 197 // ListFollowersAction fetches and displays followers for a user with optional filtering ··· 204 286 } 205 287 206 288 followerInfos, actors := enrichFollowerProfiles(ctx, service, allFollowers, logger) 207 - 208 - if inactiveDays > 0 { 209 - followerInfos = filterInactive(ctx, service, cacheRepo, followerInfos, actors, inactiveDays, refresh, logger) 210 - } 211 - 212 - if quietPosters { 213 - followerInfos = filterQuiet(ctx, service, cacheRepo, followerInfos, actors, quietThreshold, refresh, logger) 214 - } 215 - 216 - switch outputFormat { 217 - case "json": 218 - return outputFollowersJSON(followerInfos) 219 - case "csv": 220 - return outputFollowersCSV(followerInfos, inactiveDays > 0 || quietPosters) 221 - default: 222 - displayFollowersTable(followerInfos, inactiveDays > 0 || quietPosters) 223 - } 224 - 225 - return nil 226 - } 227 - 228 - // ListFollowingAction fetches and displays accounts the user follows 229 - func ListFollowingAction(ctx context.Context, cmd *cli.Command) error { 230 - if err := setup.EnsurePersistenceReady(ctx); err != nil { 231 - return fmt.Errorf("persistence layer not ready: %w", err) 232 - } 233 - 234 - reg := registry.Get() 235 - 236 - service, err := reg.GetService() 237 - if err != nil { 238 - return fmt.Errorf("failed to get service: %w", err) 239 - } 240 - 241 - if !service.Authenticated() { 242 - return fmt.Errorf("not authenticated: run 'skycli login' first") 243 - } 244 - 245 - cacheRepo, err := reg.GetCacheRepo() 246 - if err != nil { 247 - return fmt.Errorf("failed to get cache repository: %w", err) 248 - } 249 - 250 - actor := cmd.String("user") 251 - if actor == "" { 252 - actor = service.GetDid() 253 - } 254 - inactiveDays := cmd.Int("inactive") 255 - mutual := cmd.Bool("mutual") 256 - quietPosters := cmd.Bool("quiet") 257 - quietThreshold := cmd.Float("threshold") 258 - outputFormat := cmd.String("output") 259 - refresh := cmd.Bool("refresh") 260 - 261 - logger.Debugf("Fetching following for actor %v", actor) 262 - 263 - var allFollowing []store.ActorProfile 264 - cursor := "" 265 - page := 0 266 - for { 267 - page++ 268 - response, err := service.GetFollows(ctx, actor, 100, cursor) 269 - if err != nil { 270 - return fmt.Errorf("failed to fetch following: %w", err) 271 - } 272 - 273 - allFollowing = append(allFollowing, response.Follows...) 274 - 275 - if response.Cursor != "" { 276 - logger.Infof("Fetched page %d (%d following so far)...", page, len(allFollowing)) 277 - } 278 - 279 - if response.Cursor == "" { 280 - break 281 - } 282 - cursor = response.Cursor 283 - } 284 - 285 - logger.Infof("Fetched %d total following", len(allFollowing)) 286 - 287 - if mutual { 288 - var mutualFollows []store.ActorProfile 289 - for _, follow := range allFollowing { 290 - if follow.Viewer != nil && follow.Viewer.FollowedBy != "" { 291 - mutualFollows = append(mutualFollows, follow) 292 - } 293 - } 294 - allFollowing = mutualFollows 295 - } 296 - 297 - followerInfos, actors := enrichFollowerProfiles(ctx, service, allFollowing, logger) 298 289 299 290 if inactiveDays > 0 { 300 291 followerInfos = filterInactive(ctx, service, cacheRepo, followerInfos, actors, inactiveDays, refresh, logger) ··· 618 609 return nil 619 610 } 620 611 621 - func displayDiffTable(baselineLabel, comparisonLabel string, baselineCount, comparisonCount int, newFollowers, unfollows []string) { 622 - ui.Titleln("Follower Diff: %s → %s", baselineLabel, comparisonLabel) 623 - fmt.Println() 624 - 625 - fmt.Printf("Baseline: %d followers\n", baselineCount) 626 - fmt.Printf("Comparison: %d followers\n", comparisonCount) 627 - fmt.Printf("Net change: %+d\n", comparisonCount-baselineCount) 628 - fmt.Println() 629 - 630 - if len(newFollowers) > 0 { 631 - ui.Titleln("New Followers (%d)", len(newFollowers)) 632 - for _, did := range newFollowers { 633 - fmt.Printf(" + %s\n", did) 634 - } 635 - fmt.Println() 636 - } 637 - 638 - if len(unfollows) > 0 { 639 - ui.Titleln("Unfollows (%d)", len(unfollows)) 640 - for _, did := range unfollows { 641 - fmt.Printf(" - %s\n", did) 642 - } 643 - fmt.Println() 644 - } 645 - 646 - if len(newFollowers) == 0 && len(unfollows) == 0 { 647 - ui.Infoln("No changes detected") 648 - } 649 - } 650 - 651 - type diffOutput struct { 652 - NewFollowers []string `json:"newFollowers"` 653 - Unfollows []string `json:"unfollows"` 654 - Summary struct { 655 - BaselineCount int `json:"baselineCount"` 656 - ComparisonCount int `json:"comparisonCount"` 657 - NetChange int `json:"netChange"` 658 - NewCount int `json:"newCount"` 659 - UnfollowCount int `json:"unfollowCount"` 660 - } `json:"summary"` 661 - } 662 - 663 - func outputDiffJSON(newFollowers, unfollows []string) error { 664 - output := diffOutput{ 665 - NewFollowers: newFollowers, 666 - Unfollows: unfollows, 667 - } 668 - if output.NewFollowers == nil { 669 - output.NewFollowers = []string{} 670 - } 671 - if output.Unfollows == nil { 672 - output.Unfollows = []string{} 673 - } 674 - output.Summary.NewCount = len(newFollowers) 675 - output.Summary.UnfollowCount = len(unfollows) 676 - 677 - encoder := json.NewEncoder(os.Stdout) 678 - encoder.SetIndent("", " ") 679 - return encoder.Encode(output) 680 - } 681 - 682 - func outputDiffCSV(newFollowers, unfollows []string) error { 683 - writer := csv.NewWriter(os.Stdout) 684 - defer writer.Flush() 685 - 686 - if err := writer.Write([]string{"type", "did"}); err != nil { 687 - return err 688 - } 689 - 690 - for _, did := range newFollowers { 691 - if err := writer.Write([]string{"new_follower", did}); err != nil { 692 - return err 693 - } 694 - } 695 - 696 - for _, did := range unfollows { 697 - if err := writer.Write([]string{"unfollow", did}); err != nil { 698 - return err 699 - } 700 - } 701 - 702 - return nil 703 - } 704 - 705 612 // FollowersExportAction exports followers to CSV or JSON 706 613 func FollowersExportAction(ctx context.Context, cmd *cli.Command) error { 707 614 if err := setup.EnsurePersistenceReady(ctx); err != nil { ··· 780 687 } 781 688 } 782 689 690 + // enrichFollowerProfiles fetches full profiles and merges them with lightweight profiles 691 + func enrichFollowerProfiles(ctx context.Context, service *store.BlueskyService, profiles []store.ActorProfile, logger *log.Logger) ([]followerInfo, []string) { 692 + logger.Infof("Fetching detailed profiles for %d accounts...", len(profiles)) 693 + actors := make([]string, len(profiles)) 694 + for i, profile := range profiles { 695 + actors[i] = profile.Did 696 + } 697 + 698 + fullProfiles := service.BatchGetProfiles(ctx, actors, 10) 699 + logger.Infof("Fetched %d detailed profiles", len(fullProfiles)) 700 + 701 + followerInfos := make([]followerInfo, len(profiles)) 702 + for i, profile := range profiles { 703 + if fullProfile, ok := fullProfiles[profile.Did]; ok { 704 + followerInfos[i] = followerInfo{Profile: fullProfile} 705 + } else { 706 + followerInfos[i] = followerInfo{Profile: &profile} 707 + } 708 + } 709 + 710 + return followerInfos, actors 711 + } 712 + 713 + // filterInactive filters follower infos to only include accounts inactive for N days 714 + func filterInactive(ctx context.Context, service *store.BlueskyService, cacheRepo *store.CacheRepository, followerInfos []followerInfo, actors []string, inactiveDays int, refresh bool, logger *log.Logger) []followerInfo { 715 + logger.Infof("Checking activity status (threshold: %d days)...", inactiveDays) 716 + 717 + lastPostDates := service.BatchGetLastPostDatesCached(ctx, cacheRepo, actors, 10, refresh) 718 + 719 + var filtered []followerInfo 720 + for i, info := range followerInfos { 721 + lastPost, ok := lastPostDates[actors[i]] 722 + info.LastPostDate = lastPost 723 + 724 + if !ok || lastPost.IsZero() { 725 + info.IsInactive = true 726 + info.DaysSincePost = -1 727 + } else { 728 + daysSince := int(time.Since(lastPost).Hours() / 24) 729 + info.DaysSincePost = daysSince 730 + info.IsInactive = daysSince > inactiveDays 731 + } 732 + 733 + if info.IsInactive { 734 + filtered = append(filtered, info) 735 + } 736 + followerInfos[i] = info 737 + } 738 + 739 + return filtered 740 + } 741 + 742 + // filterQuiet filters follower infos to only include quiet posters 743 + func filterQuiet(ctx context.Context, service *store.BlueskyService, cacheRepo *store.CacheRepository, followerInfos []followerInfo, actors []string, threshold float64, refresh bool, logger *log.Logger) []followerInfo { 744 + logger.Infof("Computing post rates (threshold: %.2f posts/day)...", threshold) 745 + if refresh { 746 + logger.Infof("Refreshing cache (this may take a while)...") 747 + } 748 + 749 + postRates := service.BatchGetPostRatesCached(ctx, cacheRepo, actors, 30, 30, 10, refresh, func(current, total int) { 750 + if current%10 == 0 || current == total { 751 + logger.Infof("Progress: %d/%d accounts analyzed", current, total) 752 + } 753 + }) 754 + 755 + var filtered []followerInfo 756 + for i, info := range followerInfos { 757 + if rate, ok := postRates[actors[i]]; ok { 758 + info.PostsPerDay = rate.PostsPerDay 759 + info.LastPostDate = rate.LastPostDate 760 + info.IsQuiet = rate.PostsPerDay <= threshold 761 + } 762 + 763 + if info.IsQuiet { 764 + filtered = append(filtered, info) 765 + } 766 + followerInfos[i] = info 767 + } 768 + 769 + logger.Infof("Found %d quiet posters (posting <= %.2f times/day)", len(filtered), threshold) 770 + return filtered 771 + } 772 + 773 + func displayDiffTable(baselineLabel, comparisonLabel string, baselineCount, comparisonCount int, newFollowers, unfollows []string) { 774 + ui.Titleln("Follower Diff: %s → %s", baselineLabel, comparisonLabel) 775 + fmt.Println() 776 + 777 + fmt.Printf("Baseline: %d followers\n", baselineCount) 778 + fmt.Printf("Comparison: %d followers\n", comparisonCount) 779 + fmt.Printf("Net change: %+d\n", comparisonCount-baselineCount) 780 + fmt.Println() 781 + 782 + if len(newFollowers) > 0 { 783 + ui.Titleln("New Followers (%d)", len(newFollowers)) 784 + for _, did := range newFollowers { 785 + fmt.Printf(" + %s\n", did) 786 + } 787 + fmt.Println() 788 + } 789 + 790 + if len(unfollows) > 0 { 791 + ui.Titleln("Unfollows (%d)", len(unfollows)) 792 + for _, did := range unfollows { 793 + fmt.Printf(" - %s\n", did) 794 + } 795 + fmt.Println() 796 + } 797 + 798 + if len(newFollowers) == 0 && len(unfollows) == 0 { 799 + ui.Infoln("No changes detected") 800 + } 801 + } 802 + 803 + func outputDiffJSON(newFollowers, unfollows []string) error { 804 + output := diffOutput{ 805 + NewFollowers: newFollowers, 806 + Unfollows: unfollows, 807 + } 808 + if output.NewFollowers == nil { 809 + output.NewFollowers = []string{} 810 + } 811 + if output.Unfollows == nil { 812 + output.Unfollows = []string{} 813 + } 814 + output.Summary.NewCount = len(newFollowers) 815 + output.Summary.UnfollowCount = len(unfollows) 816 + 817 + encoder := json.NewEncoder(os.Stdout) 818 + encoder.SetIndent("", " ") 819 + return encoder.Encode(output) 820 + } 821 + 822 + func outputDiffCSV(newFollowers, unfollows []string) error { 823 + writer := csv.NewWriter(os.Stdout) 824 + defer writer.Flush() 825 + 826 + if err := writer.Write([]string{"type", "did"}); err != nil { 827 + return err 828 + } 829 + 830 + for _, did := range newFollowers { 831 + if err := writer.Write([]string{"new_follower", did}); err != nil { 832 + return err 833 + } 834 + } 835 + 836 + for _, did := range unfollows { 837 + if err := writer.Write([]string{"unfollow", did}); err != nil { 838 + return err 839 + } 840 + } 841 + 842 + return nil 843 + } 844 + 783 845 // formatTimeSince formats a time duration into a human-readable string. 784 846 // 785 847 // Returns ··· 975 1037 fmt.Printf("%s%s\n", activeBar, inactiveBar) 976 1038 fmt.Printf("█ Active ▒ Inactive\n") 977 1039 } 978 - 979 - // FollowersCommand returns the followers command with all subcommands 980 - func FollowersCommand() *cli.Command { 981 - return &cli.Command{ 982 - Name: "followers", 983 - Usage: "Manage and analyze followers", 984 - Commands: []*cli.Command{ 985 - { 986 - Name: "list", 987 - Usage: "List followers for a user", 988 - UsageText: "Fetch all followers with optional filters for inactivity, date range, and output format.", 989 - ArgsUsage: " ", 990 - Flags: []cli.Flag{ 991 - &cli.StringFlag{ 992 - Name: "user", 993 - Aliases: []string{"u"}, 994 - Usage: "User handle or DID (defaults to authenticated user)", 995 - }, 996 - &cli.IntFlag{ 997 - Name: "limit", 998 - Aliases: []string{"l"}, 999 - Usage: "Maximum number of followers to fetch (0 = all)", 1000 - Value: 0, 1001 - }, 1002 - &cli.StringFlag{ 1003 - Name: "since", 1004 - Usage: "Filter followers created after date (YYYY-MM-DD)", 1005 - }, 1006 - &cli.IntFlag{ 1007 - Name: "inactive", 1008 - Usage: "Show only followers with no posts in N days", 1009 - Value: 0, 1010 - }, 1011 - &cli.BoolFlag{ 1012 - Name: "quiet", 1013 - Usage: "Show only quiet posters (low posting frequency)", 1014 - }, 1015 - &cli.FloatFlag{ 1016 - Name: "threshold", 1017 - Usage: "Posts per day threshold for quiet posters (used with --quiet)", 1018 - Value: 1.0, 1019 - }, 1020 - &cli.StringFlag{ 1021 - Name: "output", 1022 - Aliases: []string{"o"}, 1023 - Usage: "Output format: table, json, csv", 1024 - Value: "table", 1025 - }, 1026 - &cli.BoolFlag{ 1027 - Name: "refresh", 1028 - Usage: "Force refresh cached data (bypasses 24-hour cache)", 1029 - }, 1030 - }, 1031 - Action: ListFollowersAction, 1032 - }, 1033 - { 1034 - Name: "stats", 1035 - Usage: "Show aggregate follower statistics", 1036 - UsageText: "Calculate aggregate statistics including active/inactive counts, growth metrics, and optional ASCII chart.", 1037 - ArgsUsage: " ", 1038 - Flags: []cli.Flag{ 1039 - &cli.StringFlag{ 1040 - Name: "user", 1041 - Aliases: []string{"u"}, 1042 - Usage: "User handle or DID (defaults to authenticated user)", 1043 - }, 1044 - &cli.StringFlag{ 1045 - Name: "since", 1046 - Usage: "Calculate growth since date (YYYY-MM-DD)", 1047 - }, 1048 - &cli.IntFlag{ 1049 - Name: "inactive", 1050 - Usage: "Threshold for inactive status (days)", 1051 - Value: 60, 1052 - }, 1053 - &cli.BoolFlag{ 1054 - Name: "chart", 1055 - Usage: "Display ASCII bar chart", 1056 - }, 1057 - }, 1058 - Action: FollowersStatsAction, 1059 - }, 1060 - { 1061 - Name: "diff", 1062 - Usage: "Compare follower lists between two dates", 1063 - UsageText: "Compare follower lists to identify new followers and unfollows. Without --until, compares snapshot to current live data.", 1064 - ArgsUsage: " ", 1065 - Flags: []cli.Flag{ 1066 - &cli.StringFlag{ 1067 - Name: "user", 1068 - Aliases: []string{"u"}, 1069 - Usage: "User handle or DID (defaults to authenticated user)", 1070 - }, 1071 - &cli.StringFlag{ 1072 - Name: "since", 1073 - Usage: "Start date (YYYY-MM-DD) or snapshot ID", 1074 - Required: true, 1075 - }, 1076 - &cli.StringFlag{ 1077 - Name: "until", 1078 - Usage: "End date (YYYY-MM-DD) or snapshot ID (omit to compare with live data)", 1079 - }, 1080 - &cli.StringFlag{ 1081 - Name: "output", 1082 - Aliases: []string{"o"}, 1083 - Usage: "Output format: table, json, csv", 1084 - Value: "table", 1085 - }, 1086 - }, 1087 - Action: FollowersDiffAction, 1088 - }, 1089 - { 1090 - Name: "export", 1091 - Usage: "Export followers to CSV or JSON", 1092 - UsageText: "Export follower list to CSV or JSON for external analysis, archival, or backup purposes.", 1093 - ArgsUsage: " ", 1094 - Flags: []cli.Flag{ 1095 - &cli.StringFlag{ 1096 - Name: "user", 1097 - Aliases: []string{"u"}, 1098 - Usage: "User handle or DID (defaults to authenticated user)", 1099 - }, 1100 - &cli.IntFlag{ 1101 - Name: "inactive", 1102 - Usage: "Export only followers with no posts in N days", 1103 - Value: 0, 1104 - }, 1105 - &cli.BoolFlag{ 1106 - Name: "quiet", 1107 - Usage: "Export only quiet posters (low posting frequency)", 1108 - }, 1109 - &cli.FloatFlag{ 1110 - Name: "threshold", 1111 - Usage: "Posts per day threshold for quiet posters (used with --quiet)", 1112 - Value: 1.0, 1113 - }, 1114 - &cli.StringFlag{ 1115 - Name: "output", 1116 - Aliases: []string{"o"}, 1117 - Usage: "Output format: json, csv", 1118 - Value: "csv", 1119 - Required: true, 1120 - }, 1121 - &cli.BoolFlag{ 1122 - Name: "refresh", 1123 - Usage: "Force refresh cached data (bypasses 24-hour cache)", 1124 - }, 1125 - }, 1126 - Action: FollowersExportAction, 1127 - }, 1128 - }, 1129 - } 1130 - } 1131 - 1132 - // FollowingCommand returns the following command 1133 - func FollowingCommand() *cli.Command { 1134 - return &cli.Command{ 1135 - Name: "following", 1136 - Usage: "Manage and analyze accounts you follow", 1137 - Commands: []*cli.Command{ 1138 - { 1139 - Name: "list", 1140 - Usage: "List accounts you follow", 1141 - UsageText: "Fetch all accounts you follow with optional filters for inactive accounts and mutual follows.", 1142 - ArgsUsage: " ", 1143 - Flags: []cli.Flag{ 1144 - &cli.StringFlag{ 1145 - Name: "user", 1146 - Aliases: []string{"u"}, 1147 - Usage: "User handle or DID (defaults to authenticated user)", 1148 - }, 1149 - &cli.IntFlag{ 1150 - Name: "inactive", 1151 - Usage: "Show only accounts with no posts in N days", 1152 - Value: 0, 1153 - }, 1154 - &cli.BoolFlag{ 1155 - Name: "mutual", 1156 - Usage: "Show only mutual follows", 1157 - }, 1158 - &cli.BoolFlag{ 1159 - Name: "quiet", 1160 - Usage: "Show only quiet posters (low posting frequency)", 1161 - }, 1162 - &cli.FloatFlag{ 1163 - Name: "threshold", 1164 - Usage: "Posts per day threshold for quiet posters (used with --quiet)", 1165 - Value: 1.0, 1166 - }, 1167 - &cli.StringFlag{ 1168 - Name: "output", 1169 - Aliases: []string{"o"}, 1170 - Usage: "Output format: table, json, csv", 1171 - Value: "table", 1172 - }, 1173 - &cli.BoolFlag{ 1174 - Name: "refresh", 1175 - Usage: "Force refresh cached data (bypasses 24-hour cache)", 1176 - }, 1177 - }, 1178 - Action: ListFollowingAction, 1179 - }, 1180 - }, 1181 - } 1182 - }
+154
cli/cmd/following.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/stormlightlabs/skypanel/cli/internal/registry" 8 + "github.com/stormlightlabs/skypanel/cli/internal/setup" 9 + "github.com/stormlightlabs/skypanel/cli/internal/store" 10 + "github.com/urfave/cli/v3" 11 + ) 12 + 13 + // ListFollowingAction fetches and displays accounts the user follows 14 + func ListFollowingAction(ctx context.Context, cmd *cli.Command) error { 15 + if err := setup.EnsurePersistenceReady(ctx); err != nil { 16 + return fmt.Errorf("persistence layer not ready: %w", err) 17 + } 18 + 19 + reg := registry.Get() 20 + 21 + service, err := reg.GetService() 22 + if err != nil { 23 + return fmt.Errorf("failed to get service: %w", err) 24 + } 25 + 26 + if !service.Authenticated() { 27 + return fmt.Errorf("not authenticated: run 'skycli login' first") 28 + } 29 + 30 + cacheRepo, err := reg.GetCacheRepo() 31 + if err != nil { 32 + return fmt.Errorf("failed to get cache repository: %w", err) 33 + } 34 + 35 + actor := cmd.String("user") 36 + if actor == "" { 37 + actor = service.GetDid() 38 + } 39 + inactiveDays := cmd.Int("inactive") 40 + mutual := cmd.Bool("mutual") 41 + quietPosters := cmd.Bool("quiet") 42 + quietThreshold := cmd.Float("threshold") 43 + outputFormat := cmd.String("output") 44 + refresh := cmd.Bool("refresh") 45 + 46 + logger.Debugf("Fetching following for actor %v", actor) 47 + 48 + var allFollowing []store.ActorProfile 49 + cursor := "" 50 + page := 0 51 + for { 52 + page++ 53 + response, err := service.GetFollows(ctx, actor, 100, cursor) 54 + if err != nil { 55 + return fmt.Errorf("failed to fetch following: %w", err) 56 + } 57 + 58 + allFollowing = append(allFollowing, response.Follows...) 59 + 60 + if response.Cursor != "" { 61 + logger.Infof("Fetched page %d (%d following so far)...", page, len(allFollowing)) 62 + } 63 + 64 + if response.Cursor == "" { 65 + break 66 + } 67 + cursor = response.Cursor 68 + } 69 + 70 + logger.Infof("Fetched %d total following", len(allFollowing)) 71 + 72 + if mutual { 73 + var mutualFollows []store.ActorProfile 74 + for _, follow := range allFollowing { 75 + if follow.Viewer != nil && follow.Viewer.FollowedBy != "" { 76 + mutualFollows = append(mutualFollows, follow) 77 + } 78 + } 79 + allFollowing = mutualFollows 80 + } 81 + 82 + followerInfos, actors := enrichFollowerProfiles(ctx, service, allFollowing, logger) 83 + 84 + if inactiveDays > 0 { 85 + followerInfos = filterInactive(ctx, service, cacheRepo, followerInfos, actors, inactiveDays, refresh, logger) 86 + } 87 + 88 + if quietPosters { 89 + followerInfos = filterQuiet(ctx, service, cacheRepo, followerInfos, actors, quietThreshold, refresh, logger) 90 + } 91 + 92 + switch outputFormat { 93 + case "json": 94 + return outputFollowersJSON(followerInfos) 95 + case "csv": 96 + return outputFollowersCSV(followerInfos, inactiveDays > 0 || quietPosters) 97 + default: 98 + displayFollowersTable(followerInfos, inactiveDays > 0 || quietPosters) 99 + } 100 + 101 + return nil 102 + } 103 + 104 + // FollowingCommand returns the following command 105 + func FollowingCommand() *cli.Command { 106 + return &cli.Command{ 107 + Name: "following", 108 + Usage: "Manage and analyze accounts you follow", 109 + Commands: []*cli.Command{ 110 + { 111 + Name: "list", 112 + Usage: "List accounts you follow", 113 + UsageText: "Fetch all accounts you follow with optional filters for inactive accounts and mutual follows.", 114 + ArgsUsage: " ", 115 + Flags: []cli.Flag{ 116 + &cli.StringFlag{ 117 + Name: "user", 118 + Aliases: []string{"u"}, 119 + Usage: "User handle or DID (defaults to authenticated user)", 120 + }, 121 + &cli.IntFlag{ 122 + Name: "inactive", 123 + Usage: "Show only accounts with no posts in N days", 124 + Value: 0, 125 + }, 126 + &cli.BoolFlag{ 127 + Name: "mutual", 128 + Usage: "Show only mutual follows", 129 + }, 130 + &cli.BoolFlag{ 131 + Name: "quiet", 132 + Usage: "Show only quiet posters (low posting frequency)", 133 + }, 134 + &cli.FloatFlag{ 135 + Name: "threshold", 136 + Usage: "Posts per day threshold for quiet posters (used with --quiet)", 137 + Value: 1.0, 138 + }, 139 + &cli.StringFlag{ 140 + Name: "output", 141 + Aliases: []string{"o"}, 142 + Usage: "Output format: table, json, csv", 143 + Value: "table", 144 + }, 145 + &cli.BoolFlag{ 146 + Name: "refresh", 147 + Usage: "Force refresh cached data (bypasses 24-hour cache)", 148 + }, 149 + }, 150 + Action: ListFollowingAction, 151 + }, 152 + }, 153 + } 154 + }