/* Copyright © 2025 maxtheweb */ package cmd import ( "encoding/json" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "strings" "time" "github.com/BurntSushi/toml" "github.com/spf13/cobra" ) // SearchConfig structure for search parameters type SearchConfig struct { Query struct { Keywords string `toml:"keywords"` Language string `toml:"language"` DaysBack int `toml:"days_back"` } `toml:"query"` Filters struct { IncludeRetweets bool `toml:"include_retweets"` MinLikes int `toml:"min_likes"` MinRetweets int `toml:"min_retweets"` HasLinks bool `toml:"has_links"` HasMedia bool `toml:"has_media"` HasVideo bool `toml:"has_video"` } `toml:"filters"` Source struct { FromUser string `toml:"from_user"` ToUser string `toml:"to_user"` } `toml:"source"` Output struct { MaxResults int `toml:"max_results"` TweetFields string `toml:"tweet_fields"` UserFields string `toml:"user_fields"` } `toml:"output"` } // SearchResponse from X API type SearchResponse struct { Data []struct { ID string `json:"id"` Text string `json:"text"` CreatedAt time.Time `json:"created_at"` AuthorID string `json:"author_id"` PublicMetrics struct { RetweetCount int `json:"retweet_count"` ReplyCount int `json:"reply_count"` LikeCount int `json:"like_count"` QuoteCount int `json:"quote_count"` } `json:"public_metrics"` } `json:"data"` Includes struct { Users []struct { ID string `json:"id"` Name string `json:"name"` Username string `json:"username"` Verified bool `json:"verified"` } `json:"users"` } `json:"includes"` Meta struct { ResultCount int `json:"result_count"` NextToken string `json:"next_token"` } `json:"meta"` } var ( searchExecute bool searchDryRun bool searchLast bool ) // searchCmd represents the search command var searchCmd = &cobra.Command{ Use: "search [command]", Short: "Search tweets on X", Long: `Opens your editor to configure search parameters. The search configuration will be saved to ~/.config/xapi/search.toml Usage: xapi search # Edit search configuration xapi search now # Execute the configured search xapi search again # Rerun the last search xapi search preview # Preview the query without executing`, Run: func(cmd *cobra.Command, args []string) { // Check for command arguments if len(args) > 0 { switch args[0] { case "now": searchExecute = true case "again": searchLast = true searchExecute = true case "preview": searchDryRun = true } } runSearch() }, } func init() { rootCmd.AddCommand(searchCmd) // Legacy flags (hidden, but still work for backwards compatibility) searchCmd.Flags().BoolVarP(&searchExecute, "execute", "e", false, "Execute the search") searchCmd.Flags().BoolVarP(&searchDryRun, "dry-run", "d", false, "Preview the search query") searchCmd.Flags().BoolVarP(&searchLast, "last", "l", false, "Use last search configuration") // Hide flags from help - prefer English commands searchCmd.Flags().MarkHidden("execute") searchCmd.Flags().MarkHidden("dry-run") searchCmd.Flags().MarkHidden("last") } func runSearch() { // Get config directory homeDir, err := os.UserHomeDir() if err != nil { fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) os.Exit(1) } searchConfigFile := filepath.Join(homeDir, ".config", "xapi", "search.toml") // Handle different modes if searchExecute { executeSearch(searchConfigFile) return } if searchDryRun { previewSearch(searchConfigFile) return } if searchLast { // Just execute with existing config if _, err := os.Stat(searchConfigFile); os.IsNotExist(err) { fmt.Fprintf(os.Stderr, "No previous search found. Run 'xapi search' first.\n") os.Exit(1) } executeSearch(searchConfigFile) return } // Default: Open editor to configure search editSearchConfig(searchConfigFile) } func editSearchConfig(configFile string) { // Ensure config directory exists configDir := filepath.Dir(configFile) if err := os.MkdirAll(configDir, 0755); err != nil { fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err) os.Exit(1) } // Create template if file doesn't exist if _, err := os.Stat(configFile); os.IsNotExist(err) { template := `# X API Search Configuration # Edit this file to define your search, then run 'xapi search now' # # TIP: Start simple! Just set keywords and leave other fields empty/false [query] # What are you searching for? (required) keywords = "" # Language filter (optional: en, es, fr, de, ja, etc.) # Leave empty for all languages language = "" # Time range in days (optional: 1-7 for free tier) # Leave empty for last 7 days days_back = 0 [filters] # Include retweets? (default: false) include_retweets = false # Only posts with minimum likes (optional, 0 = no filter) min_likes = 0 # Only posts with minimum retweets (optional, 0 = no filter) min_retweets = 0 # Must contain (set to true to require) has_links = false has_media = false has_video = false [source] # From specific user (optional, without @) from_user = "" # To specific user (optional, without @) to_user = "" [output] # Maximum results per page (1-100, default: 10) # Tip: Start with 1-5 results while you learn, then increase as needed max_results = 10 # Fields to retrieve (advanced - don't change unless needed) tweet_fields = "created_at,public_metrics,author_id" user_fields = "username,verified" ` if err := os.WriteFile(configFile, []byte(template), 0600); err != nil { fmt.Fprintf(os.Stderr, "Error creating search config: %v\n", err) os.Exit(1) } } // Get editor using same function from login editor := findEditor() if editor == "" { fmt.Fprintf(os.Stderr, "Error: No text editor found!\n\n") fmt.Println("Please install one of: nvim, vim, vi, nano, emacs") fmt.Println("Or set your preferred editor: export EDITOR=nvim") os.Exit(1) } // Open editor fmt.Printf("Opening %s with %s...\n", configFile, editor) cmd := exec.Command(editor, configFile) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error opening editor: %v\n", err) os.Exit(1) } fmt.Println("\nSearch configuration saved!") fmt.Println("\nNext steps:") fmt.Println(" xapi search preview # Preview the query") fmt.Println(" xapi search now # Run the search") } func previewSearch(configFile string) { // Load search config config, err := loadSearchConfig(configFile) if err != nil { fmt.Fprintf(os.Stderr, "Error loading search config: %v\n", err) fmt.Println("Run 'xapi search' to configure your search") os.Exit(1) } // Validate and build query query, valid, errors := config.BuildQuery() fmt.Println("\nSEARCH PREVIEW") fmt.Println("==============") if !valid { fmt.Println("INVALID CONFIGURATION\n") for _, err := range errors { fmt.Printf(" - %s\n", err) } fmt.Println("\nRun 'xapi search' to fix the configuration") os.Exit(1) } fmt.Println("VALID QUERY\n") fmt.Printf("Query: %s\n", query) fmt.Printf("Max results: %d per request\n", config.Output.MaxResults) // Explain what the query does fmt.Println("\nTHIS WILL SEARCH FOR:") if config.Query.Keywords != "" { fmt.Printf(" - Posts containing: %s\n", config.Query.Keywords) } if config.Query.Language != "" { fmt.Printf(" - Language: %s\n", config.Query.Language) } if !config.Filters.IncludeRetweets { fmt.Println(" - Original posts only (no retweets)") } if config.Filters.HasLinks { fmt.Println(" - Must contain links") } if config.Filters.HasMedia { fmt.Println(" - Must contain images/media") } if config.Source.FromUser != "" { fmt.Printf(" - From user: @%s\n", config.Source.FromUser) } fmt.Println("\nReady to execute!") fmt.Println("Run: xapi search now") } func executeSearch(configFile string) { // Load search config searchConfig, err := loadSearchConfig(configFile) if err != nil { fmt.Fprintf(os.Stderr, "Error loading search config: %v\n", err) fmt.Println("Run 'xapi search' to configure your search") os.Exit(1) } // Validate and build query query, valid, errors := searchConfig.BuildQuery() if !valid { fmt.Println("INVALID CONFIGURATION\n") for _, err := range errors { fmt.Printf(" - %s\n", err) } fmt.Println("\nRun 'xapi search' to fix the configuration") os.Exit(1) } // Load API config apiConfig, err := loadConfig() if err != nil { fmt.Fprintf(os.Stderr, "Error loading API config: %v\n", err) fmt.Println("Run 'xapi login' to configure your credentials") os.Exit(1) } // Check OAuth credentials if apiConfig.OAuth.ConsumerKey == "" { fmt.Fprintf(os.Stderr, "OAuth credentials not configured\n") fmt.Println("Run 'xapi login' to configure your credentials") os.Exit(1) } fmt.Printf("\nExecuting search: %s\n", query) // Build API URL and params baseURL := "https://api.x.com/2/tweets/search/recent" params := make(map[string]string) params["query"] = query params["max_results"] = fmt.Sprintf("%d", searchConfig.Output.MaxResults) if searchConfig.Output.TweetFields != "" { params["tweet.fields"] = searchConfig.Output.TweetFields } if searchConfig.Output.UserFields != "" { params["user.fields"] = searchConfig.Output.UserFields params["expansions"] = "author_id" } // Build full URL with query params urlParams := url.Values{} for k, v := range params { urlParams.Set(k, v) } apiURL := baseURL + "?" + urlParams.Encode() // Create HTTP client client := &http.Client{ Timeout: 30 * time.Second, } // Make request req, err := http.NewRequest("GET", apiURL, nil) if err != nil { fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err) os.Exit(1) } // Generate OAuth 1.0a Authorization header oauthClient := NewOAuthClient(apiConfig) authHeader := oauthClient.GetAuthorizationHeader("GET", baseURL, params) req.Header.Set("Authorization", authHeader) resp, err := client.Do(req) if err != nil { fmt.Fprintf(os.Stderr, "Error making API request: %v\n", err) os.Exit(1) } defer resp.Body.Close() // Check status if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) fmt.Fprintf(os.Stderr, "API error (status %d): %s\n", resp.StatusCode, string(body)) os.Exit(1) } // Parse response var searchResp SearchResponse if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) os.Exit(1) } // Display results displaySearchResults(searchResp) } func displaySearchResults(resp SearchResponse) { fmt.Printf("\nRetrieved %d tweets\n", resp.Meta.ResultCount) if resp.Meta.ResultCount == 0 { fmt.Println("\nNo results found. Try adjusting your search parameters.") return } fmt.Println("\nRESULTS SUMMARY") fmt.Println("===============") // Calculate stats totalLikes := 0 totalRetweets := 0 for _, tweet := range resp.Data { totalLikes += tweet.PublicMetrics.LikeCount totalRetweets += tweet.PublicMetrics.RetweetCount } fmt.Printf(" - Total tweets: %d\n", len(resp.Data)) fmt.Printf(" - Total engagement: %d likes, %d retweets\n", totalLikes, totalRetweets) if len(resp.Data) > 0 { fmt.Printf(" - Latest: %s\n", resp.Data[0].CreatedAt.Format("Jan 2, 15:04")) fmt.Printf(" - Oldest: %s\n", resp.Data[len(resp.Data)-1].CreatedAt.Format("Jan 2, 15:04")) } // Show sample tweets fmt.Println("\nSAMPLE TWEETS (first 3)") fmt.Println("=======================") limit := 3 if len(resp.Data) < limit { limit = len(resp.Data) } for i := 0; i < limit; i++ { tweet := resp.Data[i] // Find author author := "unknown" for _, user := range resp.Includes.Users { if user.ID == tweet.AuthorID { author = "@" + user.Username if user.Verified { author += " [verified]" } break } } fmt.Printf("\n[%d] %s\n", i+1, author) // Truncate tweet if too long text := tweet.Text if len(text) > 200 { text = text[:197] + "..." } fmt.Printf(" %s\n", strings.ReplaceAll(text, "\n", "\n ")) fmt.Printf(" Replies: %d Retweets: %d Likes: %d\n", tweet.PublicMetrics.ReplyCount, tweet.PublicMetrics.RetweetCount, tweet.PublicMetrics.LikeCount) } if resp.Meta.NextToken != "" { fmt.Println("\nMORE RESULTS AVAILABLE") fmt.Println("More results available - you can implement pagination if needed") } fmt.Println() } func loadSearchConfig(configFile string) (*SearchConfig, error) { var config SearchConfig // Set defaults // NOTE: max_results is per-tweet cost! (not per-request) // With 100 reads/month, default to 1 to maximize search operations config.Output.MaxResults = 1 config.Output.TweetFields = "created_at,public_metrics,author_id" config.Output.UserFields = "username,verified" if _, err := toml.DecodeFile(configFile, &config); err != nil { return nil, err } // Validate max_results (cap at 100 to avoid burning quota) if config.Output.MaxResults < 1 { config.Output.MaxResults = 10 } if config.Output.MaxResults > 100 { config.Output.MaxResults = 100 } return &config, nil } func (s *SearchConfig) BuildQuery() (string, bool, []string) { var errors []string // Keywords are required if s.Query.Keywords == "" { errors = append(errors, "Keywords are required") return "", false, errors } // Start with keywords query := s.Query.Keywords // Add language if s.Query.Language != "" { query += fmt.Sprintf(" lang:%s", s.Query.Language) } // Add filters if !s.Filters.IncludeRetweets { query += " -is:retweet" } if s.Filters.MinLikes > 0 { query += fmt.Sprintf(" min_faves:%d", s.Filters.MinLikes) } if s.Filters.MinRetweets > 0 { query += fmt.Sprintf(" min_retweets:%d", s.Filters.MinRetweets) } if s.Filters.HasLinks { query += " has:links" } if s.Filters.HasMedia { query += " has:media" } if s.Filters.HasVideo { query += " has:video" } // Add source if s.Source.FromUser != "" { query += fmt.Sprintf(" from:%s", s.Source.FromUser) } if s.Source.ToUser != "" { query += fmt.Sprintf(" to:%s", s.Source.ToUser) } // Check query length (512 chars for free/basic tier) if len(query) > 512 { errors = append(errors, fmt.Sprintf("Query too long (%d chars). Maximum is 512.", len(query))) return query, false, errors } return query, true, nil }