bluesky viewer in the terminal
0
fork

Configure Feed

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

at main 1039 lines 29 kB view raw
1package main 2 3import ( 4 "context" 5 "encoding/csv" 6 "encoding/json" 7 "fmt" 8 "os" 9 "strings" 10 "time" 11 12 "github.com/charmbracelet/lipgloss" 13 lgtable "github.com/charmbracelet/lipgloss/table" 14 "github.com/charmbracelet/log" 15 "github.com/stormlightlabs/skypanel/cli/internal/registry" 16 "github.com/stormlightlabs/skypanel/cli/internal/setup" 17 "github.com/stormlightlabs/skypanel/cli/internal/store" 18 "github.com/stormlightlabs/skypanel/cli/internal/ui" 19 "github.com/urfave/cli/v3" 20) 21 22// followerInfo holds enriched follower data for display and export 23type followerInfo struct { 24 Profile *store.ActorProfile 25 LastPostDate time.Time 26 DaysSincePost int 27 IsInactive bool 28 PostsPerDay float64 29 IsQuiet bool 30} 31 32type 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"` 42} 43 44// FollowersCommand returns the followers command with all subcommands 45func 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 }, 194 } 195} 196 197// ListFollowersAction fetches and displays followers for a user with optional filtering 198func ListFollowersAction(ctx context.Context, cmd *cli.Command) error { 199 if err := setup.EnsurePersistenceReady(ctx); err != nil { 200 return fmt.Errorf("persistence layer not ready: %w", err) 201 } 202 203 reg := registry.Get() 204 205 service, err := reg.GetService() 206 if err != nil { 207 return fmt.Errorf("failed to get service: %w", err) 208 } 209 210 if !service.Authenticated() { 211 return fmt.Errorf("not authenticated: run 'skycli login' first") 212 } 213 214 cacheRepo, err := reg.GetCacheRepo() 215 if err != nil { 216 return fmt.Errorf("failed to get cache repository: %w", err) 217 } 218 219 actor := cmd.String("user") 220 if actor == "" { 221 actor = service.GetDid() 222 } 223 limit := cmd.Int("limit") 224 sinceStr := cmd.String("since") 225 inactiveDays := cmd.Int("inactive") 226 quietPosters := cmd.Bool("quiet") 227 quietThreshold := cmd.Float("threshold") 228 outputFormat := cmd.String("output") 229 refresh := cmd.Bool("refresh") 230 231 if limit == 0 { 232 logger.Debugf("Fetching all followers for %v", actor) 233 } else { 234 logger.Debugf("Fetching %v followers for %v", actor, limit) 235 } 236 237 var allFollowers []store.ActorProfile 238 cursor := "" 239 page := 0 240 for { 241 page++ 242 response, err := service.GetFollowers(ctx, actor, 100, cursor) 243 if err != nil { 244 return fmt.Errorf("failed to fetch followers: %w", err) 245 } 246 247 allFollowers = append(allFollowers, response.Followers...) 248 249 if response.Cursor != "" { 250 logger.Infof("Fetched page %d (%d followers so far)...", page, len(allFollowers)) 251 } 252 253 if response.Cursor == "" || (limit > 0 && len(allFollowers) >= limit) { 254 break 255 } 256 cursor = response.Cursor 257 } 258 259 logger.Infof("Fetched %d total followers", len(allFollowers)) 260 261 if limit > 0 && len(allFollowers) > limit { 262 allFollowers = allFollowers[:limit] 263 } 264 265 if sinceStr != "" { 266 since, err := time.Parse("2006-01-02", sinceStr) 267 if err != nil { 268 return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err) 269 } 270 271 var filtered []store.ActorProfile 272 for _, follower := range allFollowers { 273 if follower.IndexedAt == "" { 274 continue 275 } 276 indexedAt, err := time.Parse(time.RFC3339, follower.IndexedAt) 277 if err != nil { 278 logger.Warn("Failed to parse indexedAt", "error", err) 279 continue 280 } 281 if indexedAt.After(since) { 282 filtered = append(filtered, follower) 283 } 284 } 285 allFollowers = filtered 286 } 287 288 followerInfos, actors := enrichFollowerProfiles(ctx, service, allFollowers, logger) 289 290 if inactiveDays > 0 { 291 followerInfos = filterInactive(ctx, service, cacheRepo, followerInfos, actors, inactiveDays, refresh, logger) 292 } 293 294 if quietPosters { 295 followerInfos = filterQuiet(ctx, service, cacheRepo, followerInfos, actors, quietThreshold, refresh, logger) 296 } 297 298 switch outputFormat { 299 case "json": 300 return outputFollowersJSON(followerInfos) 301 case "csv": 302 return outputFollowersCSV(followerInfos, inactiveDays > 0 || quietPosters) 303 default: 304 displayFollowersTable(followerInfos, inactiveDays > 0 || quietPosters) 305 } 306 307 return nil 308} 309 310// FollowersStatsAction displays aggregate statistics about followers 311func FollowersStatsAction(ctx context.Context, cmd *cli.Command) error { 312 if err := setup.EnsurePersistenceReady(ctx); err != nil { 313 return fmt.Errorf("persistence layer not ready: %w", err) 314 } 315 316 reg := registry.Get() 317 318 service, err := reg.GetService() 319 if err != nil { 320 return fmt.Errorf("failed to get service: %w", err) 321 } 322 323 if !service.Authenticated() { 324 return fmt.Errorf("not authenticated: run 'skycli login' first") 325 } 326 327 actor := cmd.String("user") 328 if actor == "" { 329 actor = service.GetDid() 330 } 331 sinceStr := cmd.String("since") 332 inactiveDays := cmd.Int("inactive") 333 showChart := cmd.Bool("chart") 334 335 logger.Debugf("Fetching followers stats for actor %v", actor) 336 337 var allFollowers []store.ActorProfile 338 cursor := "" 339 page := 0 340 for { 341 page++ 342 response, err := service.GetFollowers(ctx, actor, 100, cursor) 343 if err != nil { 344 return fmt.Errorf("failed to fetch followers: %w", err) 345 } 346 347 allFollowers = append(allFollowers, response.Followers...) 348 349 if response.Cursor != "" { 350 logger.Infof("Fetched page %d (%d followers so far)...", page, len(allFollowers)) 351 } 352 353 if response.Cursor == "" { 354 break 355 } 356 cursor = response.Cursor 357 } 358 359 logger.Infof("Fetched %d total followers", len(allFollowers)) 360 361 totalFollowers := len(allFollowers) 362 363 // Fetch full profiles for stats (required for accurate counts) 364 logger.Infof("Fetching detailed profiles for %d followers...", len(allFollowers)) 365 actors := make([]string, len(allFollowers)) 366 for i, follower := range allFollowers { 367 actors[i] = follower.Did 368 } 369 370 fullProfiles := service.BatchGetProfiles(ctx, actors, 10) 371 logger.Infof("Fetched %d detailed profiles", len(fullProfiles)) 372 373 var growth int 374 var sinceDate time.Time 375 if sinceStr != "" { 376 since, err := time.Parse("2006-01-02", sinceStr) 377 if err != nil { 378 return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err) 379 } 380 sinceDate = since 381 382 for _, follower := range allFollowers { 383 if follower.IndexedAt == "" { 384 continue 385 } 386 indexedAt, err := time.Parse(time.RFC3339, follower.IndexedAt) 387 if err != nil { 388 continue 389 } 390 if indexedAt.After(since) { 391 growth++ 392 } 393 } 394 } 395 396 var activeCount, inactiveCount int 397 if inactiveDays > 0 { 398 logger.Infof("Checking activity status (threshold: %d days)...", inactiveDays) 399 400 actors := make([]string, len(allFollowers)) 401 for i, follower := range allFollowers { 402 actors[i] = follower.Did 403 } 404 405 lastPostDates := service.BatchGetLastPostDates(ctx, actors, 10) 406 407 for _, actor := range actors { 408 lastPost, ok := lastPostDates[actor] 409 if !ok || lastPost.IsZero() { 410 inactiveCount++ 411 } else { 412 daysSince := int(time.Since(lastPost).Hours() / 24) 413 if daysSince > inactiveDays { 414 inactiveCount++ 415 } else { 416 activeCount++ 417 } 418 } 419 } 420 } 421 422 ui.Titleln("Follower Statistics") 423 fmt.Printf("Total followers: %d\n", totalFollowers) 424 425 if inactiveDays > 0 { 426 fmt.Printf("Active: %d\n", activeCount) 427 fmt.Printf("Inactive: %d (no post > %d days)\n", inactiveCount, inactiveDays) 428 } 429 430 if sinceStr != "" { 431 fmt.Printf("\nGrowth since %s: +%d\n", sinceDate.Format("2006-01-02"), growth) 432 } 433 434 if showChart && inactiveDays > 0 { 435 displayActivityChart(activeCount, inactiveCount) 436 } 437 438 return nil 439} 440 441// FollowersDiffAction compares follower lists between two dates 442func FollowersDiffAction(ctx context.Context, cmd *cli.Command) error { 443 if err := setup.EnsurePersistenceReady(ctx); err != nil { 444 return fmt.Errorf("persistence layer not ready: %w", err) 445 } 446 447 reg := registry.Get() 448 449 service, err := reg.GetService() 450 if err != nil { 451 return fmt.Errorf("failed to get service: %w", err) 452 } 453 454 if !service.Authenticated() { 455 return fmt.Errorf("not authenticated: run 'skycli login' first") 456 } 457 458 snapshotRepo, err := reg.GetSnapshotRepo() 459 if err != nil { 460 return fmt.Errorf("failed to get snapshot repository: %w", err) 461 } 462 463 actor := cmd.String("user") 464 if actor == "" { 465 actor = service.GetDid() 466 } 467 sinceStr := cmd.String("since") 468 untilStr := cmd.String("until") 469 outputFormat := cmd.String("output") 470 471 // Parse since parameter (date or snapshot ID) 472 sinceDate, err := time.Parse("2006-01-02", sinceStr) 473 var baselineSnapshot *store.SnapshotModel 474 if err != nil { 475 // Not a date, try as snapshot ID 476 model, err := snapshotRepo.Get(ctx, sinceStr) 477 if err != nil { 478 return fmt.Errorf("invalid --since parameter (not a date or snapshot ID): %w", err) 479 } 480 if model == nil { 481 return fmt.Errorf("snapshot not found: %s", sinceStr) 482 } 483 baselineSnapshot = model.(*store.SnapshotModel) 484 } else { 485 // Find snapshot by date 486 baselineSnapshot, err = snapshotRepo.FindByUserTypeAndDate(ctx, actor, "followers", sinceDate) 487 if err != nil { 488 return fmt.Errorf("failed to find snapshot: %w", err) 489 } 490 if baselineSnapshot == nil { 491 return fmt.Errorf("no snapshot found for %s on or before %s", actor, sinceStr) 492 } 493 } 494 495 logger.Infof("Using baseline snapshot from %s (%d followers)", baselineSnapshot.CreatedAt().Format("2006-01-02 15:04"), baselineSnapshot.TotalCount) 496 497 // Get baseline follower DIDs 498 baselineDids, err := snapshotRepo.GetActorDids(ctx, baselineSnapshot.ID()) 499 if err != nil { 500 return fmt.Errorf("failed to get baseline followers: %w", err) 501 } 502 503 var comparisonDids []string 504 var comparisonLabel string 505 506 if untilStr != "" { 507 // Snapshot-to-snapshot comparison 508 untilDate, err := time.Parse("2006-01-02", untilStr) 509 var comparisonSnapshot *store.SnapshotModel 510 if err != nil { 511 // Not a date, try as snapshot ID 512 model, err := snapshotRepo.Get(ctx, untilStr) 513 if err != nil { 514 return fmt.Errorf("invalid --until parameter (not a date or snapshot ID): %w", err) 515 } 516 if model == nil { 517 return fmt.Errorf("snapshot not found: %s", untilStr) 518 } 519 comparisonSnapshot = model.(*store.SnapshotModel) 520 } else { 521 // Find snapshot by date 522 comparisonSnapshot, err = snapshotRepo.FindByUserTypeAndDate(ctx, actor, "followers", untilDate) 523 if err != nil { 524 return fmt.Errorf("failed to find snapshot: %w", err) 525 } 526 if comparisonSnapshot == nil { 527 return fmt.Errorf("no snapshot found for %s on or before %s", actor, untilStr) 528 } 529 } 530 531 logger.Infof("Comparing with snapshot from %s (%d followers)", comparisonSnapshot.CreatedAt().Format("2006-01-02 15:04"), comparisonSnapshot.TotalCount) 532 comparisonLabel = comparisonSnapshot.CreatedAt().Format("2006-01-02 15:04") 533 534 comparisonDids, err = snapshotRepo.GetActorDids(ctx, comparisonSnapshot.ID()) 535 if err != nil { 536 return fmt.Errorf("failed to get comparison followers: %w", err) 537 } 538 } else { 539 // Snapshot-to-live comparison 540 logger.Infof("Fetching current followers for comparison...") 541 comparisonLabel = "now" 542 543 var allFollowers []store.ActorProfile 544 cursor := "" 545 page := 0 546 for { 547 page++ 548 response, err := service.GetFollowers(ctx, actor, 100, cursor) 549 if err != nil { 550 return fmt.Errorf("failed to fetch followers: %w", err) 551 } 552 553 allFollowers = append(allFollowers, response.Followers...) 554 555 if response.Cursor != "" { 556 logger.Infof("Fetched page %d (%d followers so far)...", page, len(allFollowers)) 557 } 558 559 if response.Cursor == "" { 560 break 561 } 562 cursor = response.Cursor 563 } 564 565 logger.Infof("Fetched %d current followers", len(allFollowers)) 566 567 for _, follower := range allFollowers { 568 comparisonDids = append(comparisonDids, follower.Did) 569 } 570 } 571 572 // Calculate diff 573 baselineSet := make(map[string]bool) 574 for _, did := range baselineDids { 575 baselineSet[did] = true 576 } 577 578 comparisonSet := make(map[string]bool) 579 for _, did := range comparisonDids { 580 comparisonSet[did] = true 581 } 582 583 // New followers: in comparison but not in baseline 584 var newFollowers []string 585 for _, did := range comparisonDids { 586 if !baselineSet[did] { 587 newFollowers = append(newFollowers, did) 588 } 589 } 590 591 // Unfollows: in baseline but not in comparison 592 var unfollows []string 593 for _, did := range baselineDids { 594 if !comparisonSet[did] { 595 unfollows = append(unfollows, did) 596 } 597 } 598 599 // Output results 600 switch outputFormat { 601 case "json": 602 return outputDiffJSON(newFollowers, unfollows) 603 case "csv": 604 return outputDiffCSV(newFollowers, unfollows) 605 default: 606 displayDiffTable(baselineSnapshot.CreatedAt().Format("2006-01-02 15:04"), comparisonLabel, len(baselineDids), len(comparisonDids), newFollowers, unfollows) 607 } 608 609 return nil 610} 611 612// FollowersExportAction exports followers to CSV or JSON 613func FollowersExportAction(ctx context.Context, cmd *cli.Command) error { 614 if err := setup.EnsurePersistenceReady(ctx); err != nil { 615 return fmt.Errorf("persistence layer not ready: %w", err) 616 } 617 618 reg := registry.Get() 619 620 service, err := reg.GetService() 621 if err != nil { 622 return fmt.Errorf("failed to get service: %w", err) 623 } 624 625 if !service.Authenticated() { 626 return fmt.Errorf("not authenticated: run 'skycli login' first") 627 } 628 629 cacheRepo, err := reg.GetCacheRepo() 630 if err != nil { 631 return fmt.Errorf("failed to get cache repository: %w", err) 632 } 633 634 actor := cmd.String("user") 635 if actor == "" { 636 actor = service.GetDid() 637 } 638 inactiveDays := cmd.Int("inactive") 639 quietPosters := cmd.Bool("quiet") 640 quietThreshold := cmd.Float("threshold") 641 outputFormat := cmd.String("output") 642 refresh := cmd.Bool("refresh") 643 644 logger.Debugf("Exporting followers for actor %v with fmt %v", actor, outputFormat) 645 646 var allFollowers []store.ActorProfile 647 cursor := "" 648 page := 0 649 for { 650 page++ 651 response, err := service.GetFollowers(ctx, actor, 100, cursor) 652 if err != nil { 653 return fmt.Errorf("failed to fetch followers: %w", err) 654 } 655 656 allFollowers = append(allFollowers, response.Followers...) 657 658 if response.Cursor != "" { 659 logger.Infof("Fetched page %d (%d followers so far)...", page, len(allFollowers)) 660 } 661 662 if response.Cursor == "" { 663 break 664 } 665 cursor = response.Cursor 666 } 667 668 logger.Infof("Fetched %d total followers", len(allFollowers)) 669 670 followerInfos, actors := enrichFollowerProfiles(ctx, service, allFollowers, logger) 671 672 if inactiveDays > 0 { 673 followerInfos = filterInactive(ctx, service, cacheRepo, followerInfos, actors, inactiveDays, refresh, logger) 674 } 675 676 if quietPosters { 677 followerInfos = filterQuiet(ctx, service, cacheRepo, followerInfos, actors, quietThreshold, refresh, logger) 678 } 679 680 switch outputFormat { 681 case "json": 682 return outputFollowersJSON(followerInfos) 683 case "csv": 684 return outputFollowersCSV(followerInfos, inactiveDays > 0 || quietPosters) 685 default: 686 return fmt.Errorf("output format must be 'json' or 'csv'") 687 } 688} 689 690// enrichFollowerProfiles fetches full profiles and merges them with lightweight profiles 691func 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 714func 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 743func 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 773func 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 803func 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 822func 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 845// formatTimeSince formats a time duration into a human-readable string. 846// 847// Returns 848// - "< 1 hour ago" for durations under 1 hour 849// - "X hours ago" for under 24 hours 850// - "X days ago" for longer durations. 851func formatTimeSince(since time.Time) string { 852 if since.IsZero() { 853 return "never" 854 } 855 856 duration := time.Since(since) 857 hours := duration.Hours() 858 859 if hours < 1 { 860 return "< 1 hour ago" 861 } else if hours < 24 { 862 return fmt.Sprintf("%d hours ago", int(hours)) 863 } else { 864 days := int(hours / 24) 865 if days == 1 { 866 return "1 day ago" 867 } 868 return fmt.Sprintf("%d days ago", days) 869 } 870} 871 872func displayFollowersTable(followers []followerInfo, showInactive bool) { 873 if len(followers) == 0 { 874 ui.Infoln("No followers found") 875 return 876 } 877 878 ui.Titleln("Followers (%d)", len(followers)) 879 fmt.Println() 880 881 headers := []string{"Handle", "Display Name", "Followers", "Posts"} 882 883 if showInactive && len(followers) > 0 && followers[0].IsQuiet { 884 headers = append(headers, "Posts/Day", "Last Post") 885 } else if len(followers) > 0 && followers[0].IsQuiet { 886 headers = append(headers, "Posts/Day") 887 } else if showInactive { 888 headers = append(headers, "Last Post") 889 } 890 headers = append(headers, "Profile URL") 891 892 data := make([][]string, len(followers)) 893 for i, info := range followers { 894 displayName := info.Profile.DisplayName 895 if displayName == "" { 896 displayName = info.Profile.Handle 897 } 898 899 profileURL := fmt.Sprintf("https://bsky.app/profile/%s", info.Profile.Handle) 900 901 row := []string{ 902 "@" + info.Profile.Handle, 903 displayName, 904 fmt.Sprintf("%d", info.Profile.FollowersCount), 905 fmt.Sprintf("%d", info.Profile.PostsCount), 906 } 907 908 if showInactive && info.IsQuiet { 909 row = append(row, fmt.Sprintf("%.2f", info.PostsPerDay)) 910 row = append(row, formatTimeSince(info.LastPostDate)) 911 } else if info.IsQuiet { 912 row = append(row, fmt.Sprintf("%.2f", info.PostsPerDay)) 913 } else if showInactive { 914 row = append(row, formatTimeSince(info.LastPostDate)) 915 } 916 917 row = append(row, profileURL) 918 data[i] = row 919 } 920 921 lastColIdx := len(headers) - 1 922 923 re := lipgloss.NewRenderer(os.Stdout) 924 t := lgtable.New().Border(lipgloss.NormalBorder()).BorderStyle(ui.TableBorderStyle).Headers(headers...).Rows(data...) 925 t = t.StyleFunc(func(row, col int) lipgloss.Style { 926 if row == lgtable.HeaderRow { 927 return ui.TableHeaderStyle 928 } 929 930 if col == 0 { 931 even := row%2 == 0 932 if even { 933 return ui.TableRowEvenStyle.Foreground(lipgloss.Color("#f6c177")) 934 } 935 return ui.TableRowOddStyle.Foreground(lipgloss.Color("#f6c177")) 936 } 937 938 if col == lastColIdx { 939 even := row%2 == 0 940 baseStyle := ui.TableRowEvenStyle 941 if !even { 942 baseStyle = ui.TableRowOddStyle 943 } 944 return baseStyle.Foreground(lipgloss.Color("#e0def4")) 945 } 946 947 if row%2 == 0 { 948 return ui.TableRowEvenStyle 949 } 950 return ui.TableRowOddStyle 951 }) 952 953 fmt.Println(re.NewStyle().Render(t.String())) 954 fmt.Println() 955} 956 957func outputFollowersJSON(followers []followerInfo) error { 958 encoder := json.NewEncoder(os.Stdout) 959 encoder.SetIndent("", " ") 960 return encoder.Encode(followers) 961} 962 963func outputFollowersCSV(followers []followerInfo, includeInactive bool) error { 964 writer := csv.NewWriter(os.Stdout) 965 defer writer.Flush() 966 967 hasQuiet := len(followers) > 0 && followers[0].IsQuiet 968 969 header := []string{"handle", "displayName", "did", "followersCount", "postsCount", "profileURL"} 970 if hasQuiet { 971 header = append(header, "postsPerDay") 972 } 973 if includeInactive { 974 header = append(header, "daysSincePost", "lastPostDate") 975 } 976 if err := writer.Write(header); err != nil { 977 return err 978 } 979 980 for _, info := range followers { 981 // profileURL := fmt.Sprintf("https://bsky.app/profile/%s", info.Profile.Handle) 982 row := []string{ 983 info.Profile.Handle, 984 info.Profile.DisplayName, 985 info.Profile.Did, 986 fmt.Sprintf("%d", info.Profile.FollowersCount), 987 fmt.Sprintf("%d", info.Profile.PostsCount), 988 // profileURL, 989 } 990 991 if hasQuiet { 992 row = append(row, fmt.Sprintf("%.2f", info.PostsPerDay)) 993 } 994 995 if includeInactive { 996 daysSince := "N/A" 997 if info.DaysSincePost >= 0 { 998 daysSince = fmt.Sprintf("%d", info.DaysSincePost) 999 } 1000 lastPost := "" 1001 if !info.LastPostDate.IsZero() { 1002 lastPost = info.LastPostDate.Format(time.RFC3339) 1003 } 1004 row = append(row, daysSince, lastPost) 1005 } 1006 1007 if err := writer.Write(row); err != nil { 1008 return err 1009 } 1010 } 1011 1012 return nil 1013} 1014 1015func displayActivityChart(active, inactive int) { 1016 total := active + inactive 1017 if total == 0 { 1018 return 1019 } 1020 1021 fmt.Println() 1022 1023 activePercent := float64(active) / float64(total) 1024 inactivePercent := float64(inactive) / float64(total) 1025 1026 chartWidth := 30 1027 activeBars := int(activePercent * float64(chartWidth)) 1028 inactiveBars := int(inactivePercent * float64(chartWidth)) 1029 1030 if activeBars+inactiveBars < chartWidth { 1031 activeBars = chartWidth - inactiveBars 1032 } 1033 1034 activeBar := strings.Repeat("█", activeBars) 1035 inactiveBar := strings.Repeat("▒", inactiveBars) 1036 1037 fmt.Printf("%s%s\n", activeBar, inactiveBar) 1038 fmt.Printf("█ Active ▒ Inactive\n") 1039}