diff --git a/cmd/search.go b/cmd/search.go index 3a8d666..b152545 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/paolo/x-cli/internal/api" + "github.com/paolo/x-cli/internal/models" "github.com/paolo/x-cli/internal/output" "github.com/spf13/cobra" ) @@ -13,6 +14,7 @@ import ( var searchCount int var searchCursor string var searchType string +var searchBackend string var searchCmd = &cobra.Command{ Use: "search ", @@ -26,27 +28,56 @@ var searchCmd = &cobra.Command{ return fmt.Errorf("invalid search type: %s (use: top, latest, people, media)", searchType) } - client, err := api.NewClient() - if err != nil { - return err - } - - page, rawJSON, err := client.Search(context.Background(), query, product, searchCount, searchCursor) + page, rawJSON, err := runSearch(query, product) if err != nil { return fmt.Errorf("search '%s': %w", query, err) } output.PrintTweets(page.Tweets, jsonOutput, rawJSON) if !jsonOutput { - output.PrintCursor(fmt.Sprintf("x-cli search \"%s\"", query), page.NextCursor) + output.PrintCursor(searchPageCommand(query), page.NextCursor) } return nil }, } +func searchPageCommand(query string) string { + command := fmt.Sprintf("x-cli search \"%s\"", query) + if searchType != "top" { + command += fmt.Sprintf(" --type %s", searchType) + } + if searchBackend != "x" { + command += fmt.Sprintf(" --backend %s", searchBackend) + } + return command +} + func init() { searchCmd.Flags().IntVar(&searchCount, "count", 20, "Number of results") searchCmd.Flags().StringVar(&searchCursor, "cursor", "", "Pagination cursor") searchCmd.Flags().StringVar(&searchType, "type", "top", "Search type: top, latest, people, media") + searchCmd.Flags().StringVar(&searchBackend, "backend", "x", "Search backend: x or xquik") rootCmd.AddCommand(searchCmd) } + +func runSearch(query string, product string) (models.TimelinePage, []byte, error) { + switch searchBackend { + case "x": + client, err := api.NewClient() + if err != nil { + return models.TimelinePage{}, nil, err + } + return client.Search(context.Background(), query, product, searchCount, searchCursor) + case "xquik": + if product != "Top" && product != "Latest" { + return models.TimelinePage{}, nil, fmt.Errorf("xquik backend supports tweet search only") + } + client, err := api.NewXquikClientFromEnv() + if err != nil { + return models.TimelinePage{}, nil, err + } + return client.Search(context.Background(), query, product, searchCount, searchCursor) + default: + return models.TimelinePage{}, nil, fmt.Errorf("invalid backend: %s (use: x, xquik)", searchBackend) + } +} diff --git a/cmd/search_test.go b/cmd/search_test.go new file mode 100644 index 0000000..fd0bbc1 --- /dev/null +++ b/cmd/search_test.go @@ -0,0 +1,37 @@ +package cmd + +import "testing" + +func TestSearchPageCommandOmitsDefaultFlags(t *testing.T) { + originalType := searchType + originalBackend := searchBackend + t.Cleanup(func() { + searchType = originalType + searchBackend = originalBackend + }) + + searchType = "top" + searchBackend = "x" + + command := searchPageCommand("golang") + if command != `x-cli search "golang"` { + t.Fatalf("unexpected command: %s", command) + } +} + +func TestSearchPageCommandPreservesNonDefaultFlags(t *testing.T) { + originalType := searchType + originalBackend := searchBackend + t.Cleanup(func() { + searchType = originalType + searchBackend = originalBackend + }) + + searchType = "latest" + searchBackend = "xquik" + + command := searchPageCommand("golang") + if command != `x-cli search "golang" --type latest --backend xquik` { + t.Fatalf("unexpected command: %s", command) + } +} diff --git a/internal/api/xquik.go b/internal/api/xquik.go new file mode 100644 index 0000000..f4d916f --- /dev/null +++ b/internal/api/xquik.go @@ -0,0 +1,208 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/paolo/x-cli/internal/models" +) + +const ( + defaultXquikBaseURL = "https://xquik.com/api/v1" + maxXquikResponseBytes = 4 * 1024 * 1024 +) + +type XquikClient struct { + apiKey string + baseURL string + httpClient *http.Client +} + +type xquikSearchResponse struct { + Tweets []xquikTweet `json:"tweets"` + Data []xquikTweet `json:"data"` + NextCursor string `json:"next_cursor"` + NextCursorCamel string `json:"nextCursor"` + NextToken string `json:"nextToken"` +} + +type xquikTweet struct { + ID string `json:"id"` + Text string `json:"text"` + CreatedAt string `json:"createdAt"` + IsQuoteStatus bool `json:"isQuoteStatus"` + InReplyToID string `json:"inReplyToId"` + Language string `json:"language"` + Lang string `json:"lang"` + RetweetCount int `json:"retweetCount"` + LikeCount int `json:"likeCount"` + ReplyCount int `json:"replyCount"` + QuoteCount int `json:"quoteCount"` + BookmarkCount int `json:"bookmarkCount"` + ViewCount int `json:"viewCount"` + Media []xquikMedia `json:"media"` + Author xquikAuthor `json:"author"` +} + +type xquikAuthor struct { + ID string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` +} + +type xquikMedia struct { + Type string `json:"type"` + MediaURL string `json:"mediaUrl"` + URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` + VideoURL string `json:"videoUrl"` +} + +func NewXquikClientFromEnv() (*XquikClient, error) { + return NewXquikClient(os.Getenv("XQUIK_API_KEY"), os.Getenv("XQUIK_API_BASE_URL")) +} + +func NewXquikClient(apiKey string, baseURL string) (*XquikClient, error) { + trimmedKey := strings.TrimSpace(apiKey) + if trimmedKey == "" { + return nil, fmt.Errorf("xquik: XQUIK_API_KEY is required") + } + + trimmedBaseURL := strings.TrimSpace(baseURL) + if trimmedBaseURL == "" { + trimmedBaseURL = defaultXquikBaseURL + } + + return &XquikClient{ + apiKey: trimmedKey, + baseURL: strings.TrimRight(trimmedBaseURL, "/"), + httpClient: &http.Client{Timeout: 60 * time.Second}, + }, nil +} + +func (c *XquikClient) Search(ctx context.Context, query string, queryType string, count int, cursor string) (models.TimelinePage, []byte, error) { + if c == nil { + return models.TimelinePage{}, nil, fmt.Errorf("xquik: nil client") + } + values := url.Values{} + values.Set("q", query) + if queryType != "" { + values.Set("queryType", queryType) + } + if count > 0 { + values.Set("limit", fmt.Sprintf("%d", count)) + } + if cursor != "" { + values.Set("cursor", cursor) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/x/tweets/search?"+values.Encode(), nil) + if err != nil { + return models.TimelinePage{}, nil, fmt.Errorf("xquik: create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("x-api-key", c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return models.TimelinePage{}, nil, fmt.Errorf("xquik: request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxXquikResponseBytes+1)) + if err != nil { + return models.TimelinePage{}, nil, fmt.Errorf("xquik: read response: %w", err) + } + if len(body) > maxXquikResponseBytes { + return models.TimelinePage{}, body[:maxXquikResponseBytes], fmt.Errorf("xquik: response exceeds %d bytes", maxXquikResponseBytes) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return models.TimelinePage{}, body, fmt.Errorf("xquik: API error (HTTP %d): %s", resp.StatusCode, truncate(string(body), 200)) + } + + var payload xquikSearchResponse + if err := json.Unmarshal(body, &payload); err != nil { + return models.TimelinePage{}, body, fmt.Errorf("xquik: decode search response: %w", err) + } + + tweets := payload.Tweets + if len(tweets) == 0 { + tweets = payload.Data + } + + page := models.TimelinePage{ + Tweets: make([]*models.Tweet, 0, len(tweets)), + NextCursor: firstNonEmpty(payload.NextCursor, payload.NextCursorCamel, payload.NextToken), + } + for _, tweet := range tweets { + page.Tweets = append(page.Tweets, tweet.toModel()) + } + + return page, body, nil +} + +func (tweet xquikTweet) toModel() *models.Tweet { + return &models.Tweet{ + ID: tweet.ID, + FullText: tweet.Text, + RetweetCount: tweet.RetweetCount, + LikeCount: tweet.LikeCount, + ReplyCount: tweet.ReplyCount, + QuoteCount: tweet.QuoteCount, + BookmarkCount: tweet.BookmarkCount, + ViewCount: tweet.ViewCount, + IsQuote: tweet.IsQuoteStatus, + Language: firstNonEmpty(tweet.Language, tweet.Lang), + InReplyTo: tweet.InReplyToID, + Media: mapXquikMedia(tweet.Media), + Author: models.UserSummary{ + ID: tweet.Author.ID, + ScreenName: tweet.Author.Username, + Name: tweet.Author.Name, + }, + CreatedAt: parseXquikTime(tweet.CreatedAt), + } +} + +func mapXquikMedia(items []xquikMedia) []models.Media { + if len(items) == 0 { + return nil + } + + media := make([]models.Media, 0, len(items)) + for _, item := range items { + media = append(media, models.Media{ + Type: item.Type, + URL: firstNonEmpty(item.MediaURL, item.URL), + Width: item.Width, + Height: item.Height, + VideoURL: firstNonEmpty(item.VideoURL, item.URL), + }) + } + return media +} + +func parseXquikTime(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{} + } + return parsed +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/api/xquik_test.go b/internal/api/xquik_test.go new file mode 100644 index 0000000..3de70ba --- /dev/null +++ b/internal/api/xquik_test.go @@ -0,0 +1,116 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestXquikSearchMapsTweets(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/x/tweets/search" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("q"); got != "golang" { + t.Fatalf("unexpected query: %s", got) + } + if got := r.URL.Query().Get("queryType"); got != "Latest" { + t.Fatalf("unexpected query type: %s", got) + } + if got := r.URL.Query().Get("limit"); got != "2" { + t.Fatalf("unexpected limit: %s", got) + } + if got := r.URL.Query().Get("cursor"); got != "abc" { + t.Fatalf("unexpected cursor: %s", got) + } + if got := r.Header.Get("x-api-key"); got != "test-key" { + t.Fatalf("unexpected api key header: %s", got) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"tweets":[{"id":"1","text":"hello","createdAt":"2026-01-02T03:04:05Z","lang":"en","likeCount":7,"retweetCount":3,"replyCount":2,"quoteCount":1,"viewCount":100,"bookmarkCount":4,"media":[{"type":"photo","mediaUrl":"https://pbs.twimg.com/media/example.jpg","width":640,"height":480}],"author":{"id":"42","username":"ada","name":"Ada"}}],"next_cursor":"next"}`)) + })) + defer server.Close() + + client, err := NewXquikClient("test-key", server.URL) + if err != nil { + t.Fatalf("new client: %v", err) + } + + page, raw, err := client.Search(context.Background(), "golang", "Latest", 2, "abc") + if err != nil { + t.Fatalf("search: %v", err) + } + + if len(raw) == 0 { + t.Fatal("expected raw response") + } + if page.NextCursor != "next" { + t.Fatalf("unexpected cursor: %s", page.NextCursor) + } + if len(page.Tweets) != 1 { + t.Fatalf("unexpected tweet count: %d", len(page.Tweets)) + } + if page.Tweets[0].ID != "1" { + t.Fatalf("unexpected tweet id: %s", page.Tweets[0].ID) + } + if page.Tweets[0].FullText != "hello" { + t.Fatalf("unexpected tweet text: %s", page.Tweets[0].FullText) + } + if page.Tweets[0].Author.ScreenName != "ada" { + t.Fatalf("unexpected author username: %s", page.Tweets[0].Author.ScreenName) + } + if page.Tweets[0].LikeCount != 7 { + t.Fatalf("unexpected like count: %d", page.Tweets[0].LikeCount) + } + if page.Tweets[0].RetweetCount != 3 { + t.Fatalf("unexpected retweet count: %d", page.Tweets[0].RetweetCount) + } + if page.Tweets[0].ReplyCount != 2 { + t.Fatalf("unexpected reply count: %d", page.Tweets[0].ReplyCount) + } + if page.Tweets[0].QuoteCount != 1 { + t.Fatalf("unexpected quote count: %d", page.Tweets[0].QuoteCount) + } + if page.Tweets[0].ViewCount != 100 { + t.Fatalf("unexpected view count: %d", page.Tweets[0].ViewCount) + } + if page.Tweets[0].BookmarkCount != 4 { + t.Fatalf("unexpected bookmark count: %d", page.Tweets[0].BookmarkCount) + } + if page.Tweets[0].Language != "en" { + t.Fatalf("unexpected language: %s", page.Tweets[0].Language) + } + if len(page.Tweets[0].Media) != 1 { + t.Fatalf("unexpected media count: %d", len(page.Tweets[0].Media)) + } + if page.Tweets[0].Media[0].URL != "https://pbs.twimg.com/media/example.jpg" { + t.Fatalf("unexpected media URL: %s", page.Tweets[0].Media[0].URL) + } +} + +func TestXquikSearchRejectsOversizedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(strings.Repeat("x", maxXquikResponseBytes+1))) + })) + defer server.Close() + + client, err := NewXquikClient("test-key", server.URL) + if err != nil { + t.Fatalf("new client: %v", err) + } + + _, raw, err := client.Search(context.Background(), "golang", "Latest", 2, "") + if err == nil { + t.Fatal("expected oversized response error") + } + if !strings.Contains(err.Error(), "response exceeds") { + t.Fatalf("unexpected error: %v", err) + } + if len(raw) != maxXquikResponseBytes { + t.Fatalf("unexpected raw response size: %d", len(raw)) + } +}