Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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"
)

var searchCount int
var searchCursor string
var searchType string
var searchBackend string

var searchCmd = &cobra.Command{
Use: "search <query>",
Expand All @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the backend in pagination hints

When users run x-cli search ... --backend xquik, the next-page hint still omits this new flag and prints a command that defaults back to the x backend. Following that hint sends the opaque Xquik cursor to the existing GraphQL search path, so pagination either fails or fetches the wrong backend; include --backend xquik in the printed command when the non-default backend is selected.

Useful? React with 👍 / 👎.

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)
}
}
37 changes: 37 additions & 0 deletions cmd/search_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
208 changes: 208 additions & 0 deletions internal/api/xquik.go
Original file line number Diff line number Diff line change
@@ -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{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Xquik tweet metrics in model mapping

The Xquik search schema returns engagement and media fields on SearchTweet (https://docs.xquik.com/openapi.yaml), but this conversion only populates id, text, author, and time. In pretty output, output.printTweetPretty suppresses zero-value stats, so tweets returned by --backend xquik display as if they have no likes, reposts, replies, views, or media even when the API returned them.

Useful? React with 👍 / 👎.

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 ""
}
Loading