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
- Resolve the post.
GET /twitter/urlschecks the post URL first. An empty result stops collection before any reposter or reply calls. - Collect reposters.
GET /twitter/post/retweeterstakesidand followsnext_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. - Collect replies when needed.
GET /twitter/replies/posttakespost_idandcountfrom 1 to 100. It is one call with no cursor. Reply items and unique authors are counted separately. - 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.
- 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 withrandom.Random(seed), where the seed combines the post id and UTC draw timestamp. - 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.
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, headersThe folder separates collection, eligibility and draw logic in core.py from the client, local server and CLI in app.py.
Endpoints
GET /twitter/urlsGET /twitter/post/retweetersGET /twitter/replies/post
Example output
The README's offline fixture command uses made-up accounts and a fixed timestamp. Its stored output reports:
| Field | Sample value |
|---|---|
| Collected entrants | 1 |
| Eligible accounts | 1 |
| Winners drawn | 1 |
| Returned / reported reposts | 1 / 2 |
| Returned / reported replies | 1 / 1 |
| Seed | 900001234567890: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:
python3 x-giveaway-picker/app.py --cli 900001234567890 --demo-fixtures --mode both --draw-timestamp 2026-10-23T00:00:00ZFor 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:
python3 x-giveaway-picker/app.py --port 8000The 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.
