-
Notifications
You must be signed in to change notification settings - Fork 9
Add optional Xquik search backend #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
| } |
| 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{ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Xquik search schema returns engagement and media fields on 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 "" | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 thexbackend. 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 xquikin the printed command when the non-default backend is selected.Useful? React with 👍 / 👎.