diff --git a/SKILL.md b/SKILL.md index f398692..20ac606 100644 --- a/SKILL.md +++ b/SKILL.md @@ -140,6 +140,14 @@ xurl read https://x.com/user/status/1234567890 xurl search "golang" xurl search "from:elonmusk" -n 20 xurl search "#buildinpublic lang:en" -n 15 + +# Search with pagination / incremental polling +xurl search "from:elonmusk" -n 100 --since-id 1234567890 +xurl search "crypto" --start-time 2025-01-01T00:00:00Z -n 50 +xurl search "AI" --next-token TOKEN_FROM_PREVIOUS_RESPONSE + +# Override default fields / expansions +xurl search "golang" --tweet-fields created_at,public_metrics,author_id --user-fields username,name --expansions author_id ``` ### User Info @@ -160,9 +168,20 @@ xurl user @XDevelopers xurl timeline xurl timeline -n 25 +# Timeline with pagination / filtering +xurl timeline --since-id 1234567890 -n 50 +xurl timeline --exclude replies,retweets +xurl timeline --start-time 2025-01-01T00:00:00Z -n 100 +xurl timeline --next-token TOKEN_FROM_PREVIOUS_RESPONSE + # Your mentions xurl mentions xurl mentions -n 20 + +# Mentions with incremental polling +xurl mentions --since-id 1234567890 -n 50 +xurl mentions --start-time 2025-03-01T00:00:00Z +xurl mentions --next-token TOKEN_FROM_PREVIOUS_RESPONSE ``` ### Engagement @@ -241,6 +260,24 @@ xurl post "lol" --media-id MEDIA_ID --- +## Pagination & Polling Flags + +The `search`, `timeline`, and `mentions` commands accept these optional flags for incremental polling and field customisation: + +| Flag | Applies to | Description | +|---|---|---| +| `--since-id ID` | search, timeline, mentions | Only return results with a tweet ID greater than this (exclusive). Ideal for incremental polling. | +| `--until-id ID` | search, timeline, mentions | Only return results with a tweet ID less than this (exclusive). | +| `--start-time TIME` | search, timeline, mentions | Oldest UTC datetime, ISO 8601 format (`YYYY-MM-DDTHH:mm:ssZ`). | +| `--end-time TIME` | search, timeline, mentions | Newest UTC datetime, ISO 8601 format. | +| `--next-token TOKEN` | search, timeline, mentions | Pagination token from a previous response's `meta.next_token`. | +| `--tweet-fields FIELDS` | search, timeline, mentions | Comma-separated tweet fields (overrides defaults: `created_at,public_metrics,conversation_id,entities`). | +| `--user-fields FIELDS` | search, timeline, mentions | Comma-separated user fields (overrides defaults: `username,name,verified`). | +| `--expansions FIELDS` | search, timeline, mentions | Comma-separated expansions (overrides default: `author_id`). | +| `--exclude TYPES` | timeline only | Comma-separated exclusions: `replies`, `retweets`, or both. | + +--- + ## Global Flags These flags work on every command: diff --git a/api/shortcuts.go b/api/shortcuts.go index 5d091a9..b1f2096 100644 --- a/api/shortcuts.go +++ b/api/shortcuts.go @@ -158,8 +158,22 @@ func ReadPost(client Client, postID string, opts RequestOptions) (json.RawMessag return client.SendRequest(opts) } +// PaginationOptions holds optional pagination and field-override flags +// shared by search, timeline, and mentions commands. +type PaginationOptions struct { + SinceID string + UntilID string + StartTime string + EndTime string + NextToken string + TweetFields string + UserFields string + Expansions string + Exclude string // timeline/mentions only: "replies,retweets" +} + // SearchPosts searches recent posts. -func SearchPosts(client Client, query string, maxResults int, opts RequestOptions) (json.RawMessage, error) { +func SearchPosts(client Client, query string, maxResults int, pag PaginationOptions, opts RequestOptions) (json.RawMessage, error) { q := url.QueryEscape(query) // X API enforces min 10 / max 100 for search @@ -169,8 +183,41 @@ func SearchPosts(client Client, query string, maxResults int, opts RequestOption maxResults = 100 } + // Defaults + tweetFields := "created_at,public_metrics,conversation_id,entities" + userFields := "username,name,verified" + expansions := "author_id" + if pag.TweetFields != "" { + tweetFields = pag.TweetFields + } + if pag.UserFields != "" { + userFields = pag.UserFields + } + if pag.Expansions != "" { + expansions = pag.Expansions + } + + endpoint := fmt.Sprintf("/2/tweets/search/recent?query=%s&max_results=%d&tweet.fields=%s&expansions=%s&user.fields=%s", + q, maxResults, tweetFields, expansions, userFields) + + if pag.SinceID != "" { + endpoint += "&since_id=" + pag.SinceID + } + if pag.UntilID != "" { + endpoint += "&until_id=" + pag.UntilID + } + if pag.StartTime != "" { + endpoint += "&start_time=" + url.QueryEscape(pag.StartTime) + } + if pag.EndTime != "" { + endpoint += "&end_time=" + url.QueryEscape(pag.EndTime) + } + if pag.NextToken != "" { + endpoint += "&next_token=" + pag.NextToken + } + opts.Method = "GET" - opts.Endpoint = fmt.Sprintf("/2/tweets/search/recent?query=%s&max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name,verified", q, maxResults) + opts.Endpoint = endpoint opts.Data = "" return client.SendRequest(opts) @@ -207,18 +254,88 @@ func GetUserPosts(client Client, userID string, maxResults int, opts RequestOpti // GetTimeline fetches the authenticated user's reverse‑chronological timeline. // Route: GET /2/users/{id}/timelines/reverse_chronological -func GetTimeline(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { +func GetTimeline(client Client, userID string, maxResults int, pag PaginationOptions, opts RequestOptions) (json.RawMessage, error) { + tweetFields := "created_at,public_metrics,conversation_id,entities" + userFields := "username,name" + expansions := "author_id" + if pag.TweetFields != "" { + tweetFields = pag.TweetFields + } + if pag.UserFields != "" { + userFields = pag.UserFields + } + if pag.Expansions != "" { + expansions = pag.Expansions + } + + endpoint := fmt.Sprintf("/2/users/%s/timelines/reverse_chronological?max_results=%d&tweet.fields=%s&expansions=%s&user.fields=%s", + userID, maxResults, tweetFields, expansions, userFields) + + if pag.SinceID != "" { + endpoint += "&since_id=" + pag.SinceID + } + if pag.UntilID != "" { + endpoint += "&until_id=" + pag.UntilID + } + if pag.StartTime != "" { + endpoint += "&start_time=" + url.QueryEscape(pag.StartTime) + } + if pag.EndTime != "" { + endpoint += "&end_time=" + url.QueryEscape(pag.EndTime) + } + if pag.NextToken != "" { + endpoint += "&pagination_token=" + pag.NextToken + } + if pag.Exclude != "" { + endpoint += "&exclude=" + pag.Exclude + } + opts.Method = "GET" - opts.Endpoint = fmt.Sprintf("/2/users/%s/timelines/reverse_chronological?max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name", userID, maxResults) + opts.Endpoint = endpoint opts.Data = "" return client.SendRequest(opts) } // GetMentions fetches recent mentions for a user. -func GetMentions(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) { +func GetMentions(client Client, userID string, maxResults int, pag PaginationOptions, opts RequestOptions) (json.RawMessage, error) { + tweetFields := "created_at,public_metrics,conversation_id,entities" + userFields := "username,name" + expansions := "author_id" + if pag.TweetFields != "" { + tweetFields = pag.TweetFields + } + if pag.UserFields != "" { + userFields = pag.UserFields + } + if pag.Expansions != "" { + expansions = pag.Expansions + } + + endpoint := fmt.Sprintf("/2/users/%s/mentions?max_results=%d&tweet.fields=%s&expansions=%s&user.fields=%s", + userID, maxResults, tweetFields, expansions, userFields) + + if pag.SinceID != "" { + endpoint += "&since_id=" + pag.SinceID + } + if pag.UntilID != "" { + endpoint += "&until_id=" + pag.UntilID + } + if pag.StartTime != "" { + endpoint += "&start_time=" + url.QueryEscape(pag.StartTime) + } + if pag.EndTime != "" { + endpoint += "&end_time=" + url.QueryEscape(pag.EndTime) + } + if pag.NextToken != "" { + endpoint += "&pagination_token=" + pag.NextToken + } + if pag.Exclude != "" { + endpoint += "&exclude=" + pag.Exclude + } + opts.Method = "GET" - opts.Endpoint = fmt.Sprintf("/2/users/%s/mentions?max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name", userID, maxResults) + opts.Endpoint = endpoint opts.Data = "" return client.SendRequest(opts) diff --git a/api/shortcuts_test.go b/api/shortcuts_test.go index 4d1048c..c3ab1fe 100644 --- a/api/shortcuts_test.go +++ b/api/shortcuts_test.go @@ -228,7 +228,7 @@ func TestSearchPosts(t *testing.T) { defer server.Close() client := shortcutClient(t, server) - resp, err := SearchPosts(client, "golang", 10, baseTestOpts()) + resp, err := SearchPosts(client, "golang", 10, PaginationOptions{}, baseTestOpts()) require.NoError(t, err) var result struct { diff --git a/cli/shortcuts.go b/cli/shortcuts.go index 7741ee9..cc865b4 100644 --- a/cli/shortcuts.go +++ b/cli/shortcuts.go @@ -254,6 +254,8 @@ Examples: func searchCmd(a *auth.Auth) *cobra.Command { var maxResults int + var sinceID, untilID, startTime, endTime, nextToken string + var tweetFields, userFields, expansions string cmd := &cobra.Command{ Use: `search "QUERY"`, Short: "Search recent posts", @@ -262,15 +264,35 @@ func searchCmd(a *auth.Auth) *cobra.Command { Examples: xurl search "golang" xurl search "from:elonmusk" -n 20 - xurl search "#buildinpublic" -n 15`, + xurl search "#buildinpublic" -n 15 + xurl search "from:user1 OR from:user2" -n 100 --since-id 1234567890 + xurl search "crypto" --start-time 2025-01-01T00:00:00Z -n 50`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { client := newClient(a) opts := baseOpts(cmd) - printResult(api.SearchPosts(client, args[0], maxResults, opts)) + pag := api.PaginationOptions{ + SinceID: sinceID, + UntilID: untilID, + StartTime: startTime, + EndTime: endTime, + NextToken: nextToken, + TweetFields: tweetFields, + UserFields: userFields, + Expansions: expansions, + } + printResult(api.SearchPosts(client, args[0], maxResults, pag, opts)) }, } cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (min 10, max 100)") + cmd.Flags().StringVar(&sinceID, "since-id", "", "Only return results with an ID greater than this") + cmd.Flags().StringVar(&untilID, "until-id", "", "Only return results with an ID less than this") + cmd.Flags().StringVar(&startTime, "start-time", "", "Oldest UTC datetime (YYYY-MM-DDTHH:mm:ssZ)") + cmd.Flags().StringVar(&endTime, "end-time", "", "Newest UTC datetime (YYYY-MM-DDTHH:mm:ssZ)") + cmd.Flags().StringVar(&nextToken, "next-token", "", "Pagination token for next page of results") + cmd.Flags().StringVar(&tweetFields, "tweet-fields", "", "Comma-separated tweet fields (overrides defaults)") + cmd.Flags().StringVar(&userFields, "user-fields", "", "Comma-separated user fields (overrides defaults)") + cmd.Flags().StringVar(&expansions, "expansions", "", "Comma-separated expansions (overrides defaults)") addCommonFlags(cmd) return cmd } @@ -324,6 +346,8 @@ Examples: func timelineCmd(a *auth.Auth) *cobra.Command { var maxResults int + var sinceID, untilID, startTime, endTime, nextToken string + var tweetFields, userFields, expansions, exclude string cmd := &cobra.Command{ Use: "timeline", Short: "Show your home timeline", @@ -331,7 +355,9 @@ func timelineCmd(a *auth.Auth) *cobra.Command { Examples: xurl timeline - xurl timeline -n 25`, + xurl timeline -n 25 + xurl timeline --since-id 1234567890 -n 50 + xurl timeline --exclude replies,retweets`, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { client := newClient(a) @@ -341,16 +367,38 @@ Examples: fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } - printResult(api.GetTimeline(client, userID, maxResults, opts)) + pag := api.PaginationOptions{ + SinceID: sinceID, + UntilID: untilID, + StartTime: startTime, + EndTime: endTime, + NextToken: nextToken, + TweetFields: tweetFields, + UserFields: userFields, + Expansions: expansions, + Exclude: exclude, + } + printResult(api.GetTimeline(client, userID, maxResults, pag, opts)) }, } cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (1–100)") + cmd.Flags().StringVar(&sinceID, "since-id", "", "Only return results with an ID greater than this") + cmd.Flags().StringVar(&untilID, "until-id", "", "Only return results with an ID less than this") + cmd.Flags().StringVar(&startTime, "start-time", "", "Oldest UTC datetime (YYYY-MM-DDTHH:mm:ssZ)") + cmd.Flags().StringVar(&endTime, "end-time", "", "Newest UTC datetime (YYYY-MM-DDTHH:mm:ssZ)") + cmd.Flags().StringVar(&nextToken, "next-token", "", "Pagination token for next page of results") + cmd.Flags().StringVar(&tweetFields, "tweet-fields", "", "Comma-separated tweet fields (overrides defaults)") + cmd.Flags().StringVar(&userFields, "user-fields", "", "Comma-separated user fields (overrides defaults)") + cmd.Flags().StringVar(&expansions, "expansions", "", "Comma-separated expansions (overrides defaults)") + cmd.Flags().StringVar(&exclude, "exclude", "", "Comma-separated exclusions: replies,retweets") addCommonFlags(cmd) return cmd } func mentionsCmd(a *auth.Auth) *cobra.Command { var maxResults int + var sinceID, untilID, startTime, endTime, nextToken string + var tweetFields, userFields, expansions string cmd := &cobra.Command{ Use: "mentions", Short: "Show your recent mentions", @@ -358,7 +406,8 @@ func mentionsCmd(a *auth.Auth) *cobra.Command { Examples: xurl mentions - xurl mentions -n 25`, + xurl mentions -n 25 + xurl mentions --since-id 1234567890`, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { client := newClient(a) @@ -368,10 +417,28 @@ Examples: fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) os.Exit(1) } - printResult(api.GetMentions(client, userID, maxResults, opts)) + pag := api.PaginationOptions{ + SinceID: sinceID, + UntilID: untilID, + StartTime: startTime, + EndTime: endTime, + NextToken: nextToken, + TweetFields: tweetFields, + UserFields: userFields, + Expansions: expansions, + } + printResult(api.GetMentions(client, userID, maxResults, pag, opts)) }, } cmd.Flags().IntVarP(&maxResults, "max-results", "n", 10, "Number of results (5–100)") + cmd.Flags().StringVar(&sinceID, "since-id", "", "Only return results with an ID greater than this") + cmd.Flags().StringVar(&untilID, "until-id", "", "Only return results with an ID less than this") + cmd.Flags().StringVar(&startTime, "start-time", "", "Oldest UTC datetime (YYYY-MM-DDTHH:mm:ssZ)") + cmd.Flags().StringVar(&endTime, "end-time", "", "Newest UTC datetime (YYYY-MM-DDTHH:mm:ssZ)") + cmd.Flags().StringVar(&nextToken, "next-token", "", "Pagination token for next page of results") + cmd.Flags().StringVar(&tweetFields, "tweet-fields", "", "Comma-separated tweet fields (overrides defaults)") + cmd.Flags().StringVar(&userFields, "user-fields", "", "Comma-separated user fields (overrides defaults)") + cmd.Flags().StringVar(&expansions, "expansions", "", "Comma-separated expansions (overrides defaults)") addCommonFlags(cmd) return cmd }