Back to use cases

    Use case / X DATA / Python 3 / Published

    Pick an X giveaway winner from reposts and replies in Python

    Build a giveaway picker for X in Python. Collect public reposts and replies, apply entry rules, draw reproducible winners and export an entrants CSV.

    Repost and reply lists flowing through entry rules to one selected winner

    This giveaway picker helps developers collect public X giveaway entries, apply rules and pick a winner with Python. It runs as a local web page or CLI using the standard library. This page shows a stored sample; there is no hosted demo.

    The problem

    A giveaway that asks people to repost and reply leaves two lists to reconcile. You need one entry per account, a clear account of who qualified and a draw you can repeat. You also need to know when the collected entries cover only part of the post's activity.

    What the app does

    • Collects reposters, repliers or both from a public post.
    • Applies minimum distinct tags in one reply, account age and follower rules.
    • Keeps one entry per user id and excludes the post author.
    • Shows returned counts beside the counts reported on the post.
    • Draws up to 10 winners with a printed seed and exports collected entrants as CSV with eligibility and exclusion reasons.

    How it works

    1. Resolve the post. GET /twitter/urls checks the post URL first. An empty result stops collection before any reposter or reply calls.
    2. Collect reposters. GET /twitter/post/retweeters takes id and follows next_cursor. Collection stops on a null or repeated cursor, an empty page, no new user ids or 25 pages. A failed page is retried once. Partial collections carry a notice.
    3. Collect replies when needed. GET /twitter/replies/post takes post_id and count from 1 to 100. It is one call with no cursor. Reply items and unique authors are counted separately.
    4. Apply rules. Repost, reply or both determines eligibility. Tags must be distinct accounts in one reply; tags from separate replies are not combined. Missing age or follower data fails an enabled filter.
    5. Confirm coverage and draw. When reply coverage is incomplete and replies are required, the web page asks for confirmation. The CLI requires confirmation on a terminal or --accept-partial-replies. Eligible accounts are sorted by user id and drawn with random.Random(seed), where the seed combines the post id and UTC draw timestamp.
    6. Keep the evidence. Export the collected accounts and preserve the rules and seed. Repeating a draw requires the same eligible list, seed and rules. A seed alone does not prove who entered, and accounts absent from all collected lists cannot appear in the CSV.

    The code

    These are verbatim excerpts of api_key and Client.fetch from app.py. The client reads the key from os.environ using DESEARCH_API_KEY, or an explicitly configured key file. BASE and imports are defined in the full file.

    PYTHON
    def api_key():
        key = os.environ.get('DESEARCH_API_KEY')
        if not key and os.environ.get('DESEARCH_API_KEY_FILE'):
            path = os.environ['DESEARCH_API_KEY_FILE']
            try:
                key = pathlib.Path(path).read_text().strip()
            except FileNotFoundError:
                raise ValueError(f'Key file not found: {path} (set by DESEARCH_API_KEY_FILE).') from None
            except (OSError, UnicodeError):
                raise ValueError(f'Cannot read key file: {path}') from None
        if not key:
            raise ValueError('Set DESEARCH_API_KEY or DESEARCH_API_KEY_FILE')
        return key
    
    
    class Client:
        def __init__(self, key):
            self.key, self.total = key, 0.0
            self.lock = threading.Lock()
    
        def fetch(self, path, params):
            request = urllib.request.Request(BASE + path + '?' + urllib.parse.urlencode(params),
                                             headers={'Authorization': self.key,
                                                      'User-Agent': 'desearch-x-giveaway-picker/1.0'})
            try:
                response = urllib.request.urlopen(request, timeout=60)
            except urllib.error.HTTPError as error:
                response = error
            except (urllib.error.URLError, TimeoutError) as error:
                reason = getattr(error, 'reason', error)
                raise RuntimeError(f'Could not reach Desearch ({reason}). Check your connection and try again.') from None
            with response:
                headers = dict(response.headers)
                for key, value in headers.items():
                    if key.lower() == 'x-desearch-cost-usd':
                        try:
                            with self.lock:
                                self.total += float(value)
                        except ValueError:
                            pass
                try:
                    data = response.read()
                except (urllib.error.URLError, TimeoutError) as error:
                    reason = getattr(error, 'reason', error)
                    raise RuntimeError(f'Could not reach Desearch ({reason}). Check your connection and try again.') from None
                try:
                    body = json.loads(data.decode())
                except (json.JSONDecodeError, UnicodeDecodeError):
                    if response.code == 200:
                        raise RuntimeError('Desearch returned a non-JSON response (HTTP 200)') from None
                    body = None
                return response.code, body, headers

    The folder separates collection, eligibility and draw logic in core.py from the client, local server and CLI in app.py.

    View the code

    Endpoints

    • GET /twitter/urls
    • GET /twitter/post/retweeters
    • GET /twitter/replies/post

    Example output

    The README's offline fixture command uses made-up accounts and a fixed timestamp. Its stored output reports:

    FieldSample value
    Collected entrants1
    Eligible accounts1
    Winners drawn1
    Returned / reported reposts1 / 2
    Returned / reported replies1 / 1
    Seed900001234567890:2026-10-23T00:00:00Z
    API cost$0.00, offline fixtures

    The sample explicitly reports that more reposters may remain. Deleted, protected or uncollected accounts do not appear. Replies are a single returned sample, so a reply requirement on a large post does not check every reply.

    Run it yourself

    Get the Python examples and run from the repository root. The following command is copied from the README and needs no key:

    SH
    python3 x-giveaway-picker/app.py --cli 900001234567890 --demo-fixtures --mode both --draw-timestamp 2026-10-23T00:00:00Z

    For live collection, set DESEARCH_API_KEY in your environment, omit --demo-fixtures and pass your public post URL or numeric id. Use --csv to save entrants, --winners to choose 1 to 10 winners and --min-tags for 0 to 5 distinct tags. Review incomplete reply coverage before accepting it.

    To open the local web page, use the README's server command:

    SH
    python3 x-giveaway-picker/app.py --port 8000

    The local server caches collection parts for one hour. A draw with all required parts cached makes no new API calls. Keep the original export because an expired collection can return a different list.

    What it costs

    Each billable response exposes its cost in the X-Desearch-Cost-Usd header, so you can sum the headers to see what a collect and draw costs. Actual collection cost depends on the calls made. See pricing.

    Ideas to extend it

    • Save the seed, rules and entrant export together for later review.
    • Add a report comparing returned entries with the post's reported counts.
    • Package the local workflow for repeat giveaways while keeping the key on the server.

    Use with AI agents

    Point an MCP client at the hosted Desearch server. Replace the placeholder with a key from the console. Do not commit the key.

    JSON
    {
      "mcpServers": {
        "desearch": {
          "url": "https://mcp.desearch.ai/mcp",
          "headers": {
            "Authorization": "Bearer <key>"
          }
        }
      }
    }

    Prompt you can paste for this example:

    Build an X giveaway picker with Desearch. Resolve the public post using GET /twitter/urls, collect reposters using GET /twitter/post/retweeters with next_cursor and a 25-page cap, and optionally fetch replies once with GET /twitter/replies/post. Deduplicate by user id, exclude the post author and apply the entry rules. State returned versus reported coverage and require confirmation for incomplete reply coverage before drawing. Sort eligible accounts by user id and draw with random.Random using the post id and UTC draw timestamp as the seed. Export collected entrants, eligibility and exclusion reasons as CSV. Never invent entrants or claim complete coverage.

    Build your own example.

    Give your agents access to web and social search. Get an API key to start building, or talk to us about your integration.

    Read docs