UNPKG

x-developer

Version:

Twitter scraper Skills & Twitter MCP server setup for Claude Code, Codex & Cursor. Search tweets, export followers & monitor accounts with Xquik. Not affiliated with X Corp.

417 lines (353 loc) • 21.4 kB
# Read routes All routes use `https://xquik.com/api/v1` and the `x-api-key` header. `{id}` accepts a username without `@` or a numeric user ID unless noted. Tweet IDs are numeric strings. ## Request example Send reads through the Python `get_json` helper from [Retries](#retries). ```python page = get_json( "/x/tweets/search", {"q": "climate policy", "sinceDate": "2026-09-01", "limit": 100}, ) ``` ## Tweets | Route | Key parameters | | --- | --- | | `GET /x/tweets/search` | `q` (required), `queryType` (`Latest` default, or `Top`), `limit` (1 to 10,000, default 20), `cursor` | | `GET /x/tweets/{id}` | Tweet ID | | `GET /x/tweets?ids=` | Up to 100 comma-separated tweet IDs | | `GET /x/tweets/{id}/replies` | `pageSize` (1 to 300), `sort` (`relevance`, `latest`, `oldest`, `likes`), `excludeOriginalAuthor`, `includeOriginalPost`, `hasMediaOnly`, `scope`, `cursor` | | `GET /x/tweets/{id}/quotes` | `pageSize` (1 to 300), `sinceTime`, `untilTime`, `includeReplies`, `cursor` | | `GET /x/tweets/{id}/thread` | Visible posts in the conversation thread, from any author. Takes `pageSize` (1 to 100), `cursor` | | `GET /x/tweets/{id}/retweeters` | `pageSize` (1 to 200), profile filters, `cursor` | | `GET /x/tweets/{id}/favoriters` | `pageSize` (1 to 200), profile filters, `cursor` | | `GET /x/articles/{tweetId}` | Full X Article content | | `GET /x/tweets/top` | X's top tweets. Takes `metric` (`likes` default, `replies`, `quotes`, `bookmarks`, `shares`, `views`), `window` (`day` default, `week`, `month`), `country` or `language`, `pageSize` (1 to 100), `cursor`. The list ends at 100 | | `GET /x/tweets/{id}/hidden-replies` | Replies the author hid, oldest first. Takes `pageSize` (1 to 100), tweet filters, `cursor` | | `GET /x/tweets/{id}/retweeter-ids` | Reposter user IDs only. Takes `pageSize` (1 to 200), `cursor` | | `GET /x/tweets/{id}/retweeters/check` | `user` (required) takes an ID, username, or profile URL. Returns `isRetweeted`, `retweetedAt`, and `complete` | | `GET /x/tweets/{id}/insights` | Engagements per hour, per view, and per follower of the author | | `GET /x/tweets/{id}/translation` | `language` (required), such as `en` or `pt-br`. Returns X's own translation | | `GET /x/tweets/{id}/subtitles` | Caption tracks of the tweet's videos, with language, timed cues, and full text | | `GET /x/tweets/{id}/embed` | Embed HTML. Takes `theme` (`light` or `dark`), `hideMedia`, `hideThread`, `omitScript` | Search, replies, quotes, thread, and user timelines accept the same tweet filters as named query parameters. Send only the ones the user asked for. - Authors and targets use `fromUser`, `toUser`, `mentioning`, `verifiedOnly`, `blueVerifiedOnly` - Text uses `exactPhrase`, `anyWords`, `excludeWords`, `hashtags`, `cashtags`, `url` - Time uses `sinceDate` and `untilDate` (`YYYY-MM-DD`), `sinceTime` (ISO, inclusive) and `untilTime` (ISO, exclusive, so end 2023 with `2024-01-01T00:00:00Z`), `withinTime` (such as `7d`) - Language uses `language` (such as `en`) - Media uses `mediaType` (`images`, `videos`, `gifs`, `media`, `links`, `none`) - Engagement uses `minLikes`, `minRetweets`, `minReplies`, `minQuotes`, `minViews`, `minBookmarks`, `maxFaves`, `maxRetweets`, `maxReplies` - Post types use `replies`, `retweets`, `quotes`, each `include`, `exclude`, or `only` - Conversation uses `conversationId`, `inReplyToTweetId`, `quotesOfTweetId` - Raw operators use `advancedQuery` for a known X search operator string, on search only Tweet responses hold `tweets`, `has_next_page`, and `next_cursor`. Each tweet has `id`, `text`, `createdAt`, `lang`, `likeCount`, `retweetCount`, `replyCount`, `quoteCount`, `viewCount`, `bookmarkCount`, `media`, and `author` with `id`, `username`, and `name`. Optional fields are omitted when X does not return them. A filtered page can be empty and still have a next page. ## Users Use the user timeline for an account's latest posts. Its path accepts a username or numeric ID. | Route | Key parameters | | --- | --- | | `GET /x/users/{id}` | Profile with `followers`, `following`, `description` (bio), `verified`, `isVerified`, `isBlueVerified`, `verifiedType`, `statusesCount`, `location`, `createdAt` | | `GET /x/users/search` | `q` (required), `pageSize` (1 to 100), profile filters, `cursor` | | `GET /x/users/batch` | `ids` (comma-separated user IDs), profile filters | | `GET /x/users/{id}/tweets` | `pageSize` (1 to 300, default 20), `sinceTime`, `untilTime`, `includeReplies` (default `false`), tweet filters, `cursor` | | `GET /x/users/{id}/replies` | With Replies timeline. Takes `pageSize` (1 to 300), `sinceTime`, `untilTime`, tweet filters, `cursor` | | `GET /x/users/{id}/media` | `pageSize` (1 to 100), tweet filters, `cursor` | | `GET /x/users/{id}/mentions` | `pageSize` (1 to 100), `sinceTime`, `untilTime`, tweet filters, `cursor` | | `GET /x/users/{id}/likes` | `pageSize` (1 to 100), tweet filters, `cursor`. Needs a connected X account for the owner's likes | | `GET /x/users/batch/tweets` | Newest posts of up to 20 users. Takes `ids` or `usernames` (comma-separated, send 1 of the 2), `pageSize` (1 to 20) | | `GET /x/users/{id}/articles` | Posts on the Articles tab, newest first. Takes `pageSize` (1 to 100), tweet filters, `cursor`. Read the full text with `GET /x/articles/{tweetId}` | | `GET /x/users/{id}/highlights` | Posts on the Highlights tab. Takes `pageSize` (1 to 100), tweet filters, `cursor` | | `GET /x/users/{id}/insights` | Engagement rate, averages per post, and posting rate, from the last 20 posts | | `GET /x/users/{id}/translation` | `language` (required). Returns X's own translation of the bio | | `GET /x/users/{id}/live` | Whether the user broadcasts live video now, with the latest broadcast | | `GET /x/users/{id}/lists` | Public Lists the user created. Takes `pageSize` (1 to 100), `cursor` | | `GET /x/users/{id}/list-memberships` | Public Lists that include the user. Takes `pageSize` (1 to 100), `cursor` | For verification, `isBlueVerified` means X shows the blue badge. `isVerified` and `verified` mean X marks the profile as verified. `verifiedType` names the kind, such as `Business`. Report only the values the response returns. ## Relationships | Route | Key parameters | | --- | --- | | `GET /x/users/{id}/followers` | `pageSize` (1 to 300), `cursor`, profile filters | | `GET /x/users/{id}/following` | Same as followers | | `GET /x/users/{id}/verified-followers` | Same as followers | | `GET /x/followers/check` | `source` and `target` usernames. Returns `isFollowing` and `isFollowedBy` | | `GET /x/users/{id}/follower-ids` | Follower user IDs only, newest first. Takes `pageSize` (1 to 5,000), `cursor` | | `GET /x/users/{id}/following-ids` | User IDs of the accounts the user follows. Takes `pageSize` (1 to 5,000), `cursor` | | `GET /x/users/{id}/similar` | Accounts X lists as similar. Takes `pageSize` (1 to 200), profile filters, `cursor` | | `GET /x/users/{id}/affiliates` | Accounts an organization lists as affiliates. Takes `pageSize` (1 to 200), profile filters, `cursor` | | `GET /x/users/{id}/subscriptions` | Creators the user subscribes to. Takes `pageSize` (1 to 200), profile filters, `cursor` | Profile filters, applied before billing, are `minFollowers`, `maxFollowers`, `minFollowing`, `maxFollowing`, `minStatuses`, `maxStatuses`, `minAccountAgeDays`, `verifiedOnly`, `hasWebsite`, `hasLocation`, `bioContains` (any comma-separated term, ignoring case), `locationContains`, `usernameContains`. Profile pages return `users`, `has_next_page`, and `next_cursor`. Use a `follower_explorer` extraction for a complete follower list. ## Lists and communities | Route | Notes | | --- | --- | | `GET /x/lists/search` | Find public Lists with `q` (required), `pageSize` (1 to 100), `cursor` | | `GET /x/lists/{id}` | Name, description, counts, and owner. Takes a List ID or a URL-encoded List URL | | `GET /x/lists/{id}/members`, `/followers`, `/tweets` | List ID | | `GET /x/communities/find` | Find communities by keyword with `q` (required), `pageSize` (1 to 100), `cursor` | | `GET /x/communities/topics` | Topics and subtopics X groups communities under. Takes `limit` | | `GET /x/communities/popular` | Top communities of a topic. Takes `topicId` or `topic`, `pageSize` (1 to 100), `cursor` | | `GET /x/communities/suggested` | Communities X suggests to join. Takes `limit` | | `GET /x/communities/{id}/info`, `/members`, `/moderators`, `/tweets` | Community ID | | `GET /x/communities/{id}/media` | Posts with photos or videos on the Media tab, newest first. Takes `pageSize` (1 to 100), tweet filters, `cursor` | | `GET /x/communities/{id}/members/search` | Find members by name or username with `q` (required) | | `GET /x/communities/search` | Search posts inside a community | | `GET /x/communities/tweets` | Search posts in 1 community with `communityId`, or in every community without it. Takes `q` (required), `queryType`, `pageSize` (1 to 100), tweet filters, `cursor` | ## Spaces, broadcasts, and jobs | Route | Notes | | --- | --- | | `GET /x/spaces/search` | `q` (required) takes words, hashtags, or operators such as `from:solana`. `queryType`, `pageSize` (1 to 100), `cursor` | | `GET /x/spaces/{id}` | Title, state, times, audience, host, and speakers. Takes a Space ID or a URL-encoded Space URL | | `GET /x/spaces/{id}/replay` | HLS playback URL of an ended Space. A live or scheduled Space answers `409` | | `GET /x/broadcasts/{id}` | Title, state, times, watch counts, and host of a live video broadcast | | `GET /x/jobs/search` | `q` (required), `location` or `locationId`, `locationType`, `seniority`, `employmentType`, `company`, `pageSize` (1 to 100), `cursor` | | `GET /x/jobs/locations` | `q` (required). Returns the location IDs that job search takes | | `GET /x/jobs/{id}` | 1 job listing with its description, salary, company, and apply link | Use the `space_explorer` extraction for a Space's participants. ## Trends and search helpers | Route | Notes | | --- | --- | | `GET /x/trends` | Pick the place by `woeid` (default 1, worldwide), `country`, or `location`. `count` (1 to 50, default 30) | | `GET /x/trends/locations` | Places X offers trends for. Takes `country`, `placeType`, `q`, `limit`. Pass a location's `woeid` to `/x/trends` | | `GET /radar` | Trending topics from curated sources. Takes `category`, `region`, `hours`, `limit` | | `GET /x/search/autocomplete` | Suggestions for a partial query. Takes `q` (required), `types` (comma-separated `users`, `topics`, `hashtags`, `cashtags`), `limit` (1 to 100, default 20) | | `GET /x/places/search` | `q` (required), `limit`. Pass a place `id` as `place` to tweet search | | `GET /x/hashflags` | Custom emoji X shows after hashtags. Takes `q`, `activeOnly` (default `true`), `limit` | | `GET /x/links/resolve` | `urls` (required) takes 1 to 100 `https://t.co/` links, comma-separated. Returns each `resolvedUrl` | A trends request bills 1 credit, however many trends it returns. `woeid` wins when a request also names a `country` or `location`. The table lists common WOEIDs. | Region | WOEID | Region | WOEID | | --- | --- | --- | --- | | Worldwide | 1 | France | 23424819 | | United States | 23424977 | Japan | 23424856 | | United Kingdom | 23424975 | India | 23424848 | | Turkey | 23424969 | Brazil | 23424768 | | Spain | 23424950 | Canada | 23424775 | | Germany | 23424829 | Mexico | 23424900 | `GET /x/trends/locations` lists every other place. Lookups, checks, insights, translations, and embeds bill 1 credit per call. Lists of IDs, Lists, communities, topics, Spaces, jobs, places, locations, hashflags, suggestions, and resolved links bill 1 credit per returned item. Subtitles bill 1 credit only when a caption track returns. [Pricing](compare-faq.md#pricing-facts) lists every rate. ## Media `POST /x/media/download` with JSON `{"tweetInput": "<tweet URL or ID>"}` for one tweet, or `{"tweetIds": ["<id>", "..."]}` for up to 50 tweets. - One tweet returns `tweetId`, `galleryUrl`, and `cacheHit`. - Several tweets return `galleryUrl`, `totalTweets`, and `totalMedia`. - `galleryUrl` is the page where the user saves the files. - A tweet without media returns `400 no_media`. A download grants no reuse rights. Tell the user to confirm permission before using the media in their own work. ## AI analysis These routes read posts and answer questions about each one with AI. Send a JSON body with `POST`. Each analyzed post bills 2 credits, read included. | Route | Answers | | --- | --- | | `POST /x/analysis/sentiment` | `sentiment`, `intensity`, `sarcasm` | | `POST /x/analysis/brand` | `relevance`, `sentiment`, `experience` for the brand in `analysis.targets` | | `POST /x/analysis/news` | `format`, `attribution`, `relevance` for the topic in `analysis.targets` | | `POST /x/analysis/market-signals` | `stance`, `content`, `conviction`, `relevance`, plus bullish & bearish counts per cashtag | | `POST /x/analysis/viral-score` | 8 wording traits, `viralScore` from 0 to 100 & `viralVerdict` | | `POST /x/analysis/classify` | 1 to 8 questions you write in `analysis.questions` | Send exactly 1 source. - `tweetIds` takes up to 100 post IDs or URLs. - `texts` takes up to 100 texts of the user's, such as drafts. Nothing is read from X. - A search takes `query`, `username`, `listId`, `quotesOf`, or `repliesTo`, alone or together, with `sinceTime`, `untilTime`, `queryType`, `limit` (1 to 100, default 20), and `cursor`. `analysis` is optional. It takes `targets` (names with `aliases`), `context`, `questions`, or a `preset`. Question IDs and category names are lower case with underscores. Send each analysis once with the helpers from [Retries](#retries). A refused call bills nothing. Ask the user before sending it again. ```python response = requests.post( f"{BASE}/x/analysis/brand", headers=HEADERS, json={"username": "Sony", "limit": 50, "analysis": {"targets": [{"name": "Sony"}]}}, timeout=60, allow_redirects=False, ) page = read_body(response) if not 200 <= response.status_code < 300 or isinstance(page, str): raise XquikError(f"{response.status_code}: {page}", response.status_code, page) ``` The response holds `results` (each post with its `answers`), `unanalyzed` (free, with a `reason`), `analysisSummary` (totals per question, target and cashtag), `has_next_page`, and `next_cursor`. Answers describe what posts say. They do not verify claims or give investment advice. ## Private reads These need a connected X account. - In `GET /x/dm/{userId}/history?account=<connected handle>`, `userId` is the other person's numeric ID. Resolve it with `GET /x/users/{username}`. It bills 1 credit per returned message and has no page size, so the last page can go past a message target. Give the cost per message, not a hard cap. - `GET /x/bookmarks`, `GET /x/bookmarks/folders`, `GET /x/notifications`, `GET /x/timeline` - `GET /x/accounts` lists connected accounts. ## Pagination and errors - Follow `next_cursor` while `has_next_page` is true. Stop at the user's bound. Never build or decode a cursor. - On `409 coverage_cursor_unavailable`, wait the exact `Retry-After` seconds, then retry the same cursor. The helper below does this. - On `410 coverage_cursor_gone` or `400 invalid_coverage_cursor`, restart once without a cursor and deduplicate by ID. The paging loop below catches `XquikError` from the helper and restarts with the remaining budget. - Generated paging code keeps one result budget across the entire run. Count every returned row before deduplication, including refetched rows after a restart. Lower later page sizes to the remaining budget. Persist IDs already appended across cron runs. An empty page can still have a cursor. - On `429`, `5xx`, and failed connections, retry as [Retries](#retries) shows. ## Retries - Retry `GET` on `429`, `409 coverage_cursor_unavailable` with `Retry-After`, `5xx`, and connection failures, such as a refused or reset connection or a timeout. - Wait `Retry-After` when the response has it. Otherwise back off exponentially from about 1 second, cap each wait at 30 seconds, and add jitter. Stop after about 5 minutes and report the last error. - Retry the same URL and cursor, so pages are not skipped or repeated. - Set a request timeout, such as 30 seconds, so a stalled connection cannot hang a run. - Do not retry other `4xx` errors. Fix the request, or handle the cursor errors above. - Parse bodies safely. An outage can return an HTML page, even with a `2xx` status, so parsing it as JSON throws. Retry that case. - Never retry `POST`, `PATCH`, or `DELETE` automatically. See [writes](writes.md#responses-and-retries). In JavaScript, `fetch` rejects on a refused or reset connection, so catch that as a retryable failure. Read `await response.text()` and parse it inside `try`. ```python import os import random import time from email.utils import parsedate_to_datetime import requests BASE = "https://xquik.com/api/v1" API_KEY = os.environ.get("XQUIK_API_KEY", "").strip() if not API_KEY or not API_KEY.isascii() or not API_KEY.isprintable(): raise ValueError("Set XQUIK_API_KEY to a valid API key.") HEADERS = {"x-api-key": API_KEY} NETWORK_ERRORS = ( requests.ConnectionError, requests.Timeout, requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError, ) class XquikError(Exception): def __init__(self, message, status=None, body=None): super().__init__(message) self.status, self.body = status, body def redact(value): """Replace the API key in text, lists, and dictionary keys and values.""" if isinstance(value, str): return value.replace(API_KEY, "[redacted]") if isinstance(value, dict): return {redact(key): redact(item) for key, item in value.items()} if isinstance(value, list): return [redact(item) for item in value] return value def retry_after(response): value = response.headers.get("Retry-After", "") if value.isdigit(): return float(value) try: return max(0.0, parsedate_to_datetime(value).timestamp() - time.time()) except (TypeError, ValueError): return None def read_body(response): """Return redacted JSON, or up to 200 characters of a redacted non-JSON body.""" try: return redact(response.json()) except ValueError: return redact(response.text)[:200] def get_json(path, params=None, budget_s=300): """GET with retries for reads only. Never use it for POST, PATCH, or DELETE.""" deadline = time.monotonic() + budget_s attempt = 0 while True: wait, status, body = None, None, None try: response = requests.get( f"{BASE}{path}", headers=HEADERS, params=params, timeout=30, allow_redirects=False, ) except NETWORK_ERRORS as exc: problem = redact(f"connection failed: {exc}") else: status, body = response.status_code, read_body(response) if 200 <= status < 300 and not isinstance(body, str): return body wait = retry_after(response) problem = f"{status}: {body}" busy_cursor = ( status == 409 and isinstance(body, dict) and body.get("error") == "coverage_cursor_unavailable" and wait is not None ) if not (200 <= status < 300 or status >= 500 or status == 429 or busy_cursor): raise XquikError(problem, status, body) if wait is None: wait = min(30.0, 2.0**attempt) * random.uniform(0.5, 1.0) attempt += 1 if time.monotonic() + wait > deadline: raise XquikError( f"Gave up after {attempt} attempts. Last: {problem}", status, body ) time.sleep(wait) ``` A paging loop on that helper. It restarts once on a gone or invalid cursor. It counts every returned tweet toward the user's number, refetched ones included, so the bill never passes that cap. Load `seen_ids` from earlier output, and make `append_row` save each row before it returns. ```python def append_search_tweets(q, cap, seen_ids, append_row): billed, cursor, restarted, cursors = 0, None, False, set() while billed < cap: params = {"q": q, "limit": cap - billed} if cursor: params["cursor"] = cursor try: page = get_json("/x/tweets/search", params) except XquikError as err: code = err.body.get("error") if isinstance(err.body, dict) else None restartable = (err.status, code) in ( (410, "coverage_cursor_gone"), (400, "invalid_coverage_cursor"), ) if restarted or not restartable: raise cursor, restarted = None, True cursors.clear() continue billed += len(page["tweets"]) for tweet in page["tweets"]: if tweet["id"] not in seen_ids: append_row(tweet) seen_ids.add(tweet["id"]) if not page["has_next_page"] or billed >= cap: break cursor = page["next_cursor"] if not isinstance(cursor, str) or not cursor or cursor in cursors: raise XquikError("Invalid or repeated search cursor") cursors.add(cursor) return billed ```