xapi-cli/cmd/search.go
Soldier 94635e7ace feat: MVP release - OAuth 1.0a CLI for X API
Complete CLI tool with 4 core commands:
- xapi login: Configure OAuth credentials via editor
- xapi status: Test authentication
- xapi search: Search tweets with preview/execute modes
- xapi create: Post tweets with preview/execute modes

Features:
- OAuth 1.0a authentication with HMAC-SHA1 signing
- OAuth 2.0 Client ID/Secret support (for future features)
- TOML-based configuration
- Editor integration for config management
- Helpful error messages for permission issues
- Quota-aware design (no caching to avoid complexity)

Built for developers on Free/Basic X API tiers.
2025-11-13 21:46:18 +00:00

562 lines
14 KiB
Go

/*
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
}