dhruv575 commited on
Commit
ea037a1
·
1 Parent(s): 818d48b

The script

Browse files
app/config.py CHANGED
@@ -8,12 +8,12 @@ from pydantic_settings import BaseSettings
8
  class Settings(BaseSettings):
9
  """Central configuration for the HuggingFace backend."""
10
 
11
- default_num_items: int = Field(10, env="POLYGRAPH_DEFAULT_NUM_ITEMS")
12
- skip_dub: bool = Field(False, env="POLYGRAPH_SKIP_DUB")
13
- template_root: str = Field("polygraph(email)/polygraph(email)", env="POLYGRAPH_TEMPLATE_DIR")
14
- template_archive_dir: str = Field("template_archive", env="POLYGRAPH_TEMPLATE_ARCHIVE")
15
- header_path: str = Field("header.png", env="POLYGRAPH_HEADER_PATH")
16
- header_link: str = Field("https://polymarket.com", env="POLYGRAPH_HEADER_LINK")
17
  supabase_url: Optional[str] = Field(None, env="SUPABASE_URL")
18
  supabase_service_key: Optional[str] = Field(None, env="SUPABASE_SERVICE_KEY")
19
  storage_prefix: str = Field("", env="STORAGE_PREFIX")
 
8
  class Settings(BaseSettings):
9
  """Central configuration for the HuggingFace backend."""
10
 
11
+ default_num_items: int = Field(10, env="KALSHI_DEFAULT_NUM_ITEMS")
12
+ skip_dub: bool = Field(False, env="KALSHI_SKIP_DUB")
13
+ template_root: str = Field("kalshi(email)/kalshi(email)", env="KALSHI_TEMPLATE_DIR")
14
+ template_archive_dir: str = Field("template_archive", env="KALSHI_TEMPLATE_ARCHIVE")
15
+ header_path: str = Field("header.png", env="KALSHI_HEADER_PATH")
16
+ header_link: str = Field("https://kalshi.com", env="KALSHI_HEADER_LINK")
17
  supabase_url: Optional[str] = Field(None, env="SUPABASE_URL")
18
  supabase_service_key: Optional[str] = Field(None, env="SUPABASE_SERVICE_KEY")
19
  storage_prefix: str = Field("", env="STORAGE_PREFIX")
app/email3.py DELETED
@@ -1,1869 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Polymarket Data Collection Script
4
- Fetches and organizes market data, comments, and whale moves from Polymarket
5
- Similar to breaking.py but with multiple data sections
6
- """
7
-
8
- import argparse
9
- import json
10
- import os
11
- import sys
12
- import re
13
- import base64
14
- import cloudinary
15
- import cloudinary.uploader
16
- from datetime import datetime, timedelta
17
- from typing import Dict, List, Any, Optional, Tuple
18
- from curl_cffi import requests
19
- from collections import defaultdict
20
- from copy import deepcopy
21
- from dotenv import load_dotenv
22
-
23
- # Load environment variables from .env file if it exists
24
- load_dotenv()
25
-
26
- # Placeholder data used when upstream data sources are unavailable.
27
- PLACEHOLDER_WHALE_MOVES = [
28
- {
29
- "title": "$441,975 position on No",
30
- "market": "Xi Jinping out in 2025?",
31
- "event": "Xi Jinping out in 2025?",
32
- "slug": "xi-jinping-out-in-2025",
33
- "amount": 441974.7783061075,
34
- "user_name": "7d74",
35
- "user_profile_url": "7d74",
36
- "distinct_positions": 2,
37
- "timestamp": datetime.utcnow().isoformat(),
38
- "market_image": "", # Will be fetched
39
- "user_image": "https://i.ibb.co/23VYpRcK/polywhale.png"
40
- },
41
- {
42
- "title": "$160,910 position on Yes",
43
- "market": "Luiz Inácio Lula da Silva",
44
- "event": "Brazil Presidential Election",
45
- "slug": "brazil-presidential-election",
46
- "amount": 160909.54283122497,
47
- "user_name": "UnknwnFnd",
48
- "user_profile_url": "UnknwnFnd",
49
- "distinct_positions": 4,
50
- "timestamp": datetime.utcnow().isoformat(),
51
- "market_image": "", # Will be fetched
52
- "user_image": "https://i.ibb.co/23VYpRcK/polywhale.png"
53
- },
54
- {
55
- "title": "$122,836 position on No",
56
- "market": "Russia x Ukraine ceasefire by end of 2026?",
57
- "event": "Russia x Ukraine ceasefire by end of 2026?",
58
- "slug": "russia-x-ukraine-ceasefire-by-end-of-2026",
59
- "amount": 122835.693405475,
60
- "user_name": "Businio",
61
- "user_profile_url": "Businio",
62
- "distinct_positions": 1,
63
- "timestamp": datetime.utcnow().isoformat(),
64
- "market_image": "", # Will be fetched
65
- "user_image": "https://i.ibb.co/23VYpRcK/polywhale.png"
66
- },
67
- ]
68
-
69
- PLACEHOLDER_COMMENTS = [
70
- {
71
- "comment": "Seasonality alone doesn\u2019t explain the flow here. This market is really a referendum on how quickly energy prices ease and that path still looks rough.",
72
- "author": "MacroRhino",
73
- "market_title": "Will US inflation fall below 3% in 2025?",
74
- "slug": "us-inflation-below-3-2025",
75
- "likes": 42,
76
- "createdAt": datetime.utcnow().isoformat(),
77
- "weighted_score": 8.4,
78
- "age_days": 1.1,
79
- "hours_ago": 6,
80
- "hotness": 9.5
81
- },
82
- {
83
- "comment": "Desk chatter says the campaign is actually accelerating spend in WI + PA. I still like \u2018no\u2019 at 38 but you have to size for a very noisy news cycle.",
84
- "author": "ElectionNerd",
85
- "market_title": "Will Democrats win Wisconsin in 2024?",
86
- "slug": "democrats-win-wisconsin-2024",
87
- "likes": 57,
88
- "createdAt": datetime.utcnow().isoformat(),
89
- "weighted_score": 10.2,
90
- "age_days": 0.6,
91
- "hours_ago": 3,
92
- "hotness": 14.1
93
- },
94
- {
95
- "comment": "Markets are underpricing how quickly AI-capex translates into revenue. Smells like peak pessimism on the megacap multiples.",
96
- "author": "GammaFlow",
97
- "market_title": "Will NVDA finish 2025 above $1600?",
98
- "slug": "nvidia-1600-2025",
99
- "likes": 33,
100
- "createdAt": datetime.utcnow().isoformat(),
101
- "weighted_score": 7.1,
102
- "age_days": 1.6,
103
- "hours_ago": 12,
104
- "hotness": 6.8
105
- },
106
- ]
107
-
108
-
109
- def _fresh_placeholder_whales() -> List[Dict[str, Any]]:
110
- """Return whale placeholder data with refreshed timestamps."""
111
- whales = deepcopy(PLACEHOLDER_WHALE_MOVES)
112
- timestamp = datetime.utcnow().isoformat()
113
- for entry in whales:
114
- entry["timestamp"] = timestamp
115
- return whales
116
-
117
-
118
- def _fresh_placeholder_comments() -> List[Dict[str, Any]]:
119
- """Return comment placeholder data with refreshed timing metadata."""
120
- now = datetime.utcnow()
121
- comments = deepcopy(PLACEHOLDER_COMMENTS)
122
- for idx, entry in enumerate(comments):
123
- entry["createdAt"] = now.isoformat()
124
- entry["age_days"] = round(0.5 + idx * 0.4, 2)
125
- entry["hours_ago"] = round(idx * 3 + 1, 1)
126
- entry["weighted_score"] = entry.get("weighted_score", 1.0) + idx
127
- entry["hotness"] = entry.get("hotness", 5.0) + idx
128
- return comments
129
-
130
- def upload_to_cloudinary(png_data: bytes, filename: str, date_folder: str, apply_transform: bool = True) -> str:
131
- """Upload image to Cloudinary with folder organization"""
132
- cloudinary_url = os.getenv('CLOUDINARY_URL')
133
-
134
- if not cloudinary_url:
135
- print(f" No CLOUDINARY_URL found - using placeholder")
136
- return "https://via.placeholder.com/48"
137
-
138
- try:
139
- # Convert png_data to base64
140
- if isinstance(png_data, bytes):
141
- # Detect image format from data
142
- if png_data[:4] == b'\x89PNG':
143
- mime_type = "image/png"
144
- elif png_data[:2] == b'\xff\xd8':
145
- mime_type = "image/jpeg"
146
- else:
147
- mime_type = "image/png" # Default to PNG
148
-
149
- image_base64 = base64.b64encode(png_data).decode('utf-8')
150
- upload_data = f"data:{mime_type};base64,{image_base64}"
151
- else:
152
- upload_data = png_data
153
-
154
- # Upload with folder organization
155
- folder_path = f"polymarket/{date_folder}"
156
-
157
- result = cloudinary.uploader.upload(
158
- upload_data,
159
- folder=folder_path
160
- )
161
-
162
- # Get the URL and optionally add transformations
163
- if result and result.get('secure_url'):
164
- image_url = result['secure_url']
165
- print(f" Uploaded to Cloudinary: {filename}")
166
-
167
- # Only add transformation for market images (48x48)
168
- if apply_transform and '/upload/' in image_url:
169
- transformed_url = image_url.replace('/upload/', '/upload/w_48,h_48,c_fill,q_auto,f_auto/')
170
- return transformed_url
171
- return image_url
172
- else:
173
- return "https://via.placeholder.com/48"
174
-
175
- except Exception as e:
176
- print(f" Cloudinary upload failed: {e}")
177
- return "https://via.placeholder.com/48"
178
-
179
-
180
- def create_dub_tracking_link(url: str, title: str, tags: list = None, skip_dub: bool = False) -> str:
181
- """Create Dub tracking link with multiple tags."""
182
- # Skip if nodub flag is set
183
- if skip_dub:
184
- return url
185
-
186
- api_key = os.getenv('DUB_API_KEY')
187
-
188
- if not api_key:
189
- print(f" ⚠️ DUB_API_KEY not set, using original URL")
190
- return url
191
-
192
- try:
193
- # Default tags if none provided
194
- if tags is None:
195
- tags = []
196
-
197
- payload = {
198
- "url": url,
199
- "workspaceId": "ws_cm7dm89q90000qmskmss62vla",
200
- "title": title[:50] + "..." if len(title) > 50 else title,
201
- "comments": "Polymarket daily email",
202
- "trackConversion": True
203
- }
204
-
205
- # Only add tags if they are provided and not empty
206
- if tags:
207
- payload["tagNames"] = tags
208
-
209
- response = requests.post(
210
- "https://api.dub.co/links",
211
- headers={
212
- "Authorization": f"Bearer {api_key}",
213
- "Content-Type": "application/json"
214
- },
215
- json=payload,
216
- timeout=10,
217
- impersonate="chrome110"
218
- )
219
-
220
- if response.status_code in [200, 201]:
221
- data = response.json()
222
- # Debug: log key parts of the response (commented out - uncomment if needed)
223
- # print(f" DEBUG: Dub response contains: shortLink={data.get('shortLink')}, link={data.get('link')}, shortUrl={data.get('shortUrl')}")
224
-
225
- # Try different possible field names for the short URL
226
- short_link = data.get('shortLink') or data.get('shortUrl') or data.get('link')
227
-
228
- if short_link:
229
- # Validate the short link format
230
- if not short_link.startswith('http'):
231
- print(f" ⚠️ Invalid dub link format: {short_link}, using original URL")
232
- return url
233
-
234
- # Check for expected short link domains
235
- if 'polymarket.com' in short_link:
236
- # This shouldn't happen - it's returning the original URL
237
- print(f" ⚠️ Dub returned original URL: {short_link}, API may have failed")
238
- return url
239
-
240
- # poly.market is the CORRECT domain for shortened links
241
- if 'poly.market' not in short_link and 'dub.co' not in short_link and 'dub.sh' not in short_link:
242
- print(f" ⚠️ Unexpected domain in short link: {short_link}")
243
- return url
244
-
245
- print(f" ✓ Created tracking link: {short_link}")
246
- return short_link
247
- else:
248
- print(f" ⚠️ No short link in response: {data}")
249
- return url
250
- else:
251
- print(f" ⚠️ Dub API error {response.status_code}: {response.text[:100]}")
252
-
253
- return url
254
-
255
- except Exception as e:
256
- print(f" ⚠️ Dub exception: {str(e)}")
257
- return url
258
-
259
- def calculate_time_remaining(end_date_str: str) -> str:
260
- """Calculate time remaining until market closes"""
261
- try:
262
- end_date = datetime.fromisoformat(end_date_str.replace('Z', '+00:00'))
263
- now = datetime.now(end_date.tzinfo)
264
- delta = end_date - now
265
-
266
- if delta.days > 0:
267
- return f"{delta.days}d"
268
- elif delta.seconds > 3600:
269
- hours = delta.seconds // 3600
270
- return f"{hours}h"
271
- else:
272
- minutes = delta.seconds // 60
273
- return f"{minutes}m"
274
- except:
275
- return "Soon"
276
-
277
- def calculate_time_since(created_date_str: str) -> str:
278
- """Calculate time since market was created"""
279
- try:
280
- if not created_date_str:
281
- return "Recently"
282
-
283
- # Handle different date formats
284
- from dateutil import parser
285
- created_date = parser.parse(created_date_str)
286
-
287
- # Make sure both dates are timezone aware
288
- if created_date.tzinfo is None:
289
- from datetime import timezone
290
- created_date = created_date.replace(tzinfo=timezone.utc)
291
-
292
- now = datetime.now(created_date.tzinfo)
293
- delta = now - created_date
294
-
295
- # More granular time display
296
- total_seconds = delta.total_seconds()
297
-
298
- if delta.days > 30:
299
- months = delta.days // 30
300
- return f"{months}mo ago"
301
- elif delta.days > 0:
302
- if delta.days == 1:
303
- return "1d ago"
304
- return f"{delta.days}d ago"
305
- elif total_seconds > 3600:
306
- hours = int(total_seconds // 3600)
307
- if hours == 1:
308
- return "1h ago"
309
- return f"{hours}h ago"
310
- elif total_seconds > 60:
311
- minutes = int(total_seconds // 60)
312
- if minutes == 1:
313
- return "1m ago"
314
- return f"{minutes}m ago"
315
- else:
316
- return "Just now"
317
- except Exception as e:
318
- print(f" Warning: Could not parse date '{created_date_str}': {e}")
319
- return "Recently"
320
-
321
- class PolymarketEmailGenerator:
322
- """Main class for generating Polymarket data emails"""
323
-
324
- def __init__(self, num_items: int = 10, skip_dub: bool = False):
325
- """Initialize with number of items per section"""
326
- self.num_items = num_items
327
- self.skip_dub = skip_dub
328
- self.selected_markets = []
329
- self.menu_items = {}
330
- self.market_data_cache = {}
331
-
332
- def fetch_market_details(self, slug: str) -> Dict[str, Any]:
333
- """Fetch detailed market information including image"""
334
- try:
335
- # Try to get from cache first
336
- if slug in self.market_data_cache:
337
- return self.market_data_cache[slug]
338
-
339
- # Fetch market details using slug parameter instead of path
340
- response = requests.get(
341
- "https://gamma-api.polymarket.com/events",
342
- params={"slug": slug},
343
- timeout=10,
344
- impersonate="chrome110"
345
- )
346
- response.raise_for_status()
347
-
348
- # The API returns an array, get the first item
349
- data = response.json()
350
- if data and isinstance(data, list) and len(data) > 0:
351
- market_data = data[0]
352
- self.market_data_cache[slug] = market_data
353
- return market_data
354
- else:
355
- return {}
356
-
357
- except Exception as e:
358
- print(f" Warning: Could not fetch details for {slug}: {e}")
359
- return {}
360
-
361
- def fetch_ending_soon(self) -> List[Dict[str, Any]]:
362
- """Fetch markets ending soon - ONLY non-sports, non-crypto markets"""
363
- print("Fetching markets ending soon (no sports/crypto)...")
364
-
365
- try:
366
- from datetime import timezone
367
- from dateutil import parser
368
- now = datetime.now(timezone.utc)
369
-
370
- all_markets = []
371
-
372
- # Strategy 1: Get high volume markets (likely to be current) - paginated
373
- # Fetch 400 total (4 pages of 100)
374
- for offset in range(0, 900, 100):
375
- response = requests.get(
376
- "https://gamma-api.polymarket.com/events",
377
- params={
378
- "order": "volume24hr",
379
- "ascending": "false",
380
- "limit": 100,
381
- "offset": offset
382
- },
383
- timeout=10,
384
- impersonate="chrome110"
385
- )
386
- if response.status_code == 200:
387
- page_data = response.json()
388
- all_markets.extend(page_data)
389
- # If we got fewer than 100, we've reached the end
390
- if len(page_data) < 100:
391
- break
392
- else:
393
- break
394
-
395
- # Deduplicate by slug
396
- seen_slugs = set()
397
- unique_markets = []
398
- for market in all_markets:
399
- slug = market.get('slug', '')
400
- if slug and slug not in seen_slugs:
401
- seen_slugs.add(slug)
402
- unique_markets.append(market)
403
-
404
- # Filter for non-sports, non-crypto markets only
405
- valid_markets = []
406
-
407
- # Sports keywords to EXCLUDE
408
- sports_keywords = ['vs.', ' vs ', 'NFL', 'NBA', 'MLB', 'NHL', 'UFC', 'MMA',
409
- 'Soccer', 'Football', 'Basketball', 'Baseball', 'Hockey',
410
- 'Tennis', 'Golf', 'Boxing', 'Premier League', 'Champions League',
411
- 'ucl-', 'mlb-', 'nba-', 'nfl-', 'nhl-', 'wnba-', 'Serie A',
412
- 'La Liga', 'Bundesliga', 'Ligue 1', 'UEFA', 'FIFA', 'World Cup',
413
- 'Cavaliers', 'Lakers', 'Warriors', 'Celtics', 'Knicks', 'Nets',
414
- 'Yankees', 'Dodgers', 'Astros', 'Heisman', 'Davey O\'Brien',
415
- 'Doak Walker', 'Biletnikoff', 'Award Winner', 'cfb-', 'ncaa',
416
- 'Bowl Game', 'Championship Game', 'playoffs', 'tournament']
417
-
418
- # Esports keywords to EXCLUDE
419
- esports_keywords = ['LoL:', 'Dota', 'CS:GO', 'Valorant', 'Overwatch', 'Rocket League',
420
- 'Fortnite', 'PUBG', 'Apex Legends', 'Rainbow Six', 'Call of Duty',
421
- 'esports', 'e-sports', '(BO3)', '(BO5)', 'Gen.G', 'T1', 'TSM',
422
- 'Team Liquid', 'Cloud9', 'FaZe', 'NaVi', 'Fnatic', 'G2',
423
- 'Mobile Legends', 'MLBB', 'Honor of Kings', 'Arena of Valor',
424
- 'League of Legends:', 'this week', 'StarCraft', 'Hearthstone', 'Overwatch League']
425
-
426
- # Crypto/trading keywords to EXCLUDE - expanded for short-term
427
- crypto_keywords = ['bitcoin', 'ethereum', 'btc', 'eth', 'solana', 'sol', 'xrp',
428
- 'crypto', 'coin', 'token', 'above', 'below', 'hit',
429
- 'multistrike', '4pm et', '8pm et', '12pm et', 'trading',
430
- 'market cap', 'defi', 'nft', 'blockchain', '3:00pm', '3:15pm',
431
- '3:30pm', '3:45pm', 'price -', 'above ___', 'below ___',
432
- 'price on october', 'price on november', 'price on december',
433
- 'price on january', 'price on february', 'price on march',
434
- 'what price will', 'binance', 'coinbase', 'doge', 'shib',
435
- 'cardano', 'ada', 'bnb', 'polygon', 'matic', 'avalanche',
436
- 'avax', 'polkadot', 'dot', 'chainlink', 'link', 'tweets']
437
-
438
- # Weather keywords to EXCLUDE
439
- weather_keywords = ['temperature', 'degrees', 'rain', 'snow', 'weather', 'storm',
440
- 'hurricane', 'tornado', 'hotter', 'colder', 'warmest', 'coldest',
441
- 'precipitation', 'humidity', 'forecast', 'climate']
442
-
443
- for event in unique_markets:
444
- # Skip if marked as ended or resolved
445
- if event.get('ended', False) or event.get('resolved', False):
446
- continue
447
-
448
- # Get end date
449
- end_date_str = event.get("endDate") or event.get("closedTime") or event.get("closeTime", "")
450
- if not end_date_str:
451
- continue
452
-
453
- try:
454
- end_date = parser.parse(end_date_str)
455
- if end_date.tzinfo is None:
456
- end_date = end_date.replace(tzinfo=timezone.utc)
457
-
458
- delta = end_date - now
459
-
460
- # Skip if in the past or too far future (3 days max)
461
- if delta.total_seconds() <= 0 or delta.days > 3:
462
- continue
463
-
464
- title = event.get('title', '')
465
- slug = event.get('slug', '')
466
- title_lower = title.lower()
467
- slug_lower = slug.lower()
468
-
469
- # Skip if it's a sports market
470
- if any(keyword in title or keyword in title_lower or keyword in slug_lower
471
- for keyword in sports_keywords):
472
- continue
473
-
474
- # Skip if it's an esports market
475
- if any(keyword in title or keyword in title_lower or keyword in slug_lower
476
- for keyword in esports_keywords):
477
- continue
478
-
479
- # Skip if it's a crypto/trading market
480
- if any(keyword in title_lower or keyword in slug_lower
481
- for keyword in crypto_keywords):
482
- continue
483
-
484
- # Skip if it's a weather market
485
- if any(keyword in title_lower or keyword in slug_lower
486
- for keyword in weather_keywords):
487
- continue
488
-
489
- # Skip "Up or Down" markets
490
- if 'up or down' in title_lower:
491
- continue
492
-
493
- # Skip if market is closed (even if not marked as ended)
494
- if event.get('closed', False):
495
- continue
496
-
497
- # This is a valid non-sports, non-crypto market
498
- valid_markets.append({
499
- 'event': event,
500
- 'end_date': end_date,
501
- 'hours_until': delta.total_seconds() / 3600
502
- })
503
-
504
- except Exception as e:
505
- continue
506
-
507
- # Sort by end date (soonest first)
508
- valid_markets.sort(key=lambda x: x['end_date'])
509
-
510
- # Format results
511
- markets = []
512
- for item in valid_markets[:15]:
513
- event = item['event']
514
-
515
- # Fetch detailed data for image
516
- details = self.fetch_market_details(event.get("slug", ""))
517
-
518
- markets.append({
519
- "title": event.get("title", "Unknown"),
520
- "slug": event.get("slug", ""),
521
- "closedTime": item['end_date'].isoformat(),
522
- "volume": float(event.get("volume", 0)),
523
- "liquidity": float(event.get("liquidity", 0)),
524
- "image": details.get("image", event.get("image", "")),
525
- "time_remaining": calculate_time_remaining(item['end_date'].isoformat())
526
- })
527
-
528
- print(f" Found {len(valid_markets)} non-sports/crypto markets ending soon")
529
- return markets
530
-
531
- except Exception as e:
532
- print(f" Warning: API error: {e}")
533
- return []
534
-
535
- def fetch_just_listed(self) -> List[Dict[str, Any]]:
536
- """Fetch recently listed markets - ONLY non-sports, non-crypto"""
537
- print("Fetching newly listed markets (no sports/crypto)...")
538
-
539
- try:
540
- from datetime import timezone
541
- from dateutil import parser
542
- now = datetime.now(timezone.utc)
543
-
544
- # Use createdAt to get truly new markets (not just updated ones) - paginated
545
- # Fetch 800 total (8 pages of 100)
546
- events = []
547
- for offset in range(0, 6000, 500):
548
- response = requests.get(
549
- "https://gamma-api.polymarket.com/events",
550
- params={
551
- "order": "createdAt",
552
- "ascending": "false", # Most recently created first
553
- "limit": 500,
554
- "offset": offset
555
- },
556
- timeout=10,
557
- impersonate="chrome110"
558
- )
559
- if response.status_code == 200:
560
- page_data = response.json()
561
- events.extend(page_data)
562
- # If we got fewer than 100, we've reached the end
563
- if len(page_data) < 100:
564
- break
565
- else:
566
- response.raise_for_status()
567
- break
568
-
569
- # Count unique markets by slug
570
- unique_slugs = set()
571
- for event in events:
572
- slug = event.get('slug', '')
573
- if slug:
574
- unique_slugs.add(slug)
575
- print(f" Checked {len(unique_slugs)} unique markets")
576
-
577
- markets = []
578
-
579
- # Sports keywords to EXCLUDE
580
- sports_keywords = ['vs.', ' vs ', 'NFL', 'NBA', 'MLB', 'NHL', 'UFC', 'MMA',
581
- 'Soccer', 'Football', 'Basketball', 'Baseball', 'Hockey',
582
- 'Tennis', 'Golf', 'Boxing', 'Premier League', 'Champions League',
583
- 'ucl-', 'mlb-', 'nba-', 'nfl-', 'nhl-', 'wnba-', 'Serie A',
584
- 'La Liga', 'Bundesliga', 'Ligue 1', 'UEFA', 'FIFA', 'World Cup',
585
- 'Cavaliers', 'Lakers', 'Warriors', 'Celtics', 'Knicks', 'Nets',
586
- 'Yankees', 'Dodgers', 'Astros', 'Heisman', 'Davey O\'Brien',
587
- 'Doak Walker', 'Biletnikoff', 'Award Winner', 'cfb-', 'ncaa',
588
- 'Bowl Game', 'Championship Game', 'playoffs', 'tournament']
589
-
590
- # Esports keywords to EXCLUDE
591
- esports_keywords = ['LoL:', 'Dota', 'CS:GO', 'Valorant', 'Overwatch', 'Rocket League',
592
- 'Fortnite', 'PUBG', 'Apex Legends', 'Rainbow Six', 'Call of Duty',
593
- 'esports', 'e-sports', '(BO3)', '(BO5)', 'Gen.G', 'T1', 'TSM',
594
- 'Team Liquid', 'Cloud9', 'FaZe', 'NaVi', 'Fnatic', 'G2',
595
- 'Mobile Legends', 'MLBB', 'Honor of Kings', 'Arena of Valor',
596
- 'League of Legends:', 'StarCraft', 'Hearthstone', 'Overwatch League']
597
-
598
- # Crypto/trading keywords to EXCLUDE - expanded for short-term
599
- crypto_keywords = ['bitcoin', 'ethereum', 'btc', 'eth', 'solana', 'sol', 'xrp',
600
- 'crypto', 'coin', 'token', 'above', 'below', 'hit',
601
- 'multistrike', '4pm et', '8pm et', '12pm et', 'trading',
602
- 'market cap', 'defi', 'nft', 'blockchain', '3:00pm', '3:15pm',
603
- '3:30pm', '3:45pm', 'price -', 'above ___', 'below ___',
604
- 'price on october', 'price on november', 'price on december',
605
- 'price on january', 'price on february', 'price on march',
606
- 'what price will', 'binance', 'coinbase', 'doge', 'shib',
607
- 'cardano', 'ada', 'bnb', 'polygon', 'matic', 'avalanche',
608
- 'avax', 'polkadot', 'dot', 'chainlink', 'link']
609
-
610
- # Weather keywords to EXCLUDE
611
- weather_keywords = ['temperature', 'degrees', 'rain', 'snow', 'weather', 'storm',
612
- 'hurricane', 'tornado', 'hotter', 'colder', 'warmest', 'coldest',
613
- 'precipitation', 'humidity', 'forecast', 'climate']
614
-
615
- for event in events:
616
- # Skip if closed, ended, or resolved
617
- if event.get('closed', False) or event.get('ended', False) or event.get('resolved', False):
618
- continue
619
-
620
- title = event.get('title', '')
621
- slug = event.get('slug', '')
622
- title_lower = title.lower()
623
- slug_lower = slug.lower()
624
-
625
- # Skip "Up or Down" markets
626
- if 'up or down' in title_lower:
627
- continue
628
-
629
- # Skip if it's a sports market
630
- if any(keyword in title or keyword in title_lower or keyword in slug_lower
631
- for keyword in sports_keywords):
632
- continue
633
-
634
- # Skip if it's an esports market
635
- if any(keyword in title or keyword in title_lower or keyword in slug_lower
636
- for keyword in esports_keywords):
637
- continue
638
-
639
- # Skip if it's a crypto/trading market
640
- if any(keyword in title_lower or keyword in slug_lower
641
- for keyword in crypto_keywords):
642
- continue
643
-
644
- # Skip if it's a weather market
645
- if any(keyword in title_lower or keyword in slug_lower
646
- for keyword in weather_keywords):
647
- continue
648
-
649
- # Check how recently it was created
650
- created_at = event.get("createdAt", "")
651
- if created_at:
652
- try:
653
- created_date = parser.parse(created_at)
654
- if created_date.tzinfo is None:
655
- created_date = created_date.replace(tzinfo=timezone.utc)
656
-
657
- # Skip if created more than 14 days ago (expanded window due to heavy filtering)
658
- days_old = (now - created_date).days
659
- if days_old > 14:
660
- continue
661
- except:
662
- pass
663
-
664
- # Fetch detailed data for image
665
- details = self.fetch_market_details(event.get("slug", ""))
666
-
667
- markets.append({
668
- "title": event.get("title", "Unknown"),
669
- "slug": event.get("slug", ""),
670
- "createdAt": event.get("createdAt", ""),
671
- "volume": float(event.get("volume", 0)),
672
- "liquidity": float(event.get("liquidity", 0)),
673
- "image": details.get("image", event.get("image", "")),
674
- "time_since": calculate_time_since(event.get("createdAt", ""))
675
- })
676
-
677
- if len(markets) >= self.num_items:
678
- break
679
-
680
- print(f" {len(markets)} markets remained after filtering")
681
- print(f" Found {len(markets)} recently created non-sports/crypto markets")
682
- return markets
683
-
684
- except Exception as e:
685
- print(f" Warning: API error: {e}")
686
- return []
687
-
688
- def fetch_breaking_news(self) -> List[Dict[str, Any]]:
689
- """Fetch breaking news markets by scraping Polymarket breaking page"""
690
- print("📰 Fetching breaking news markets...")
691
-
692
- try:
693
- headers = {
694
- 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
695
- }
696
-
697
- response = requests.get("https://polymarket.com/breaking", headers=headers, timeout=15, impersonate="chrome110")
698
- response.raise_for_status()
699
-
700
- # Extract __NEXT_DATA__ JSON
701
- next_data_match = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', response.text, re.DOTALL)
702
- if not next_data_match:
703
- return []
704
-
705
- next_data = json.loads(next_data_match.group(1))
706
-
707
- # Navigate to markets data
708
- queries = next_data['props']['pageProps']['dehydratedState']['queries']
709
- markets = []
710
-
711
- for query in queries:
712
- query_key = query.get('queryKey', [])
713
- # Check if 'biggest-movers' is in the query key (could be ['biggest-movers'] or ['biggest-movers', 'all'])
714
- if isinstance(query_key, list) and 'biggest-movers' in query_key:
715
- raw_markets = query['state']['data']['markets']
716
-
717
- # Define filter keywords for breaking news
718
- sports_keywords = ['vs.', ' vs ', 'NFL', 'NBA', 'MLB', 'NHL', 'UFC', 'MMA']
719
- crypto_keywords = ['bitcoin', 'ethereum', 'btc', 'eth', 'solana', 'sol',
720
- 'above ___', 'below ___', 'price on', 'what price will']
721
- weather_keywords = ['temperature', 'degrees', 'weather']
722
- esports_keywords = ['LoL:', 'Dota', 'CS:GO', '(BO3)', '(BO5)']
723
-
724
- for market in raw_markets:
725
- title = market.get('question', 'Unknown')
726
- title_lower = title.lower()
727
-
728
- # Skip uninteresting markets
729
- if any(k in title or k in title_lower for k in sports_keywords):
730
- continue
731
- if any(k in title_lower for k in crypto_keywords):
732
- continue
733
- if any(k in title_lower for k in weather_keywords):
734
- continue
735
- if any(k in title or k in title_lower for k in esports_keywords):
736
- continue
737
- if 'up or down' in title_lower:
738
- continue
739
-
740
- # Get current price and calculate change
741
- current_price_raw = market.get('currentPrice', 0)
742
- if current_price_raw is None:
743
- current_price_raw = 0
744
- current_price = int(float(current_price_raw) * 100)
745
-
746
- price_change = market.get('livePriceChange', 0)
747
- if price_change is None:
748
- price_change = 0
749
-
750
- # Get the correct event slug (not market slug)
751
- event_slug = market.get('slug', '') # fallback to market slug
752
- events = market.get('events', [])
753
- if events and len(events) > 0:
754
- # Use the event slug which gives the correct URL
755
- event_slug = events[0].get('slug', event_slug)
756
-
757
- # Get image, with fallback to fetch details if missing
758
- image_url = market.get('image', '')
759
- if not image_url and event_slug:
760
- details = self.fetch_market_details(event_slug)
761
- image_url = details.get('image', '')
762
-
763
- markets.append({
764
- "title": title,
765
- "slug": event_slug,
766
- "image": image_url,
767
- "current_price": current_price,
768
- "price_change": price_change,
769
- "volume": float(market.get('volume', 0))
770
- })
771
-
772
- if len(markets) >= self.num_items:
773
- break
774
-
775
- break
776
-
777
- return markets
778
-
779
- except Exception as e:
780
- print(f" Warning: Scraping error: {e}")
781
- return []
782
-
783
- def fetch_hot_markets(self) -> List[Dict[str, Any]]:
784
- """Fetch hot markets by 24hr volume from Gamma API"""
785
- print("Fetching hot markets...")
786
-
787
- try:
788
- response = requests.get(
789
- "https://gamma-api.polymarket.com/events",
790
- params={
791
- "order": "volume24hr",
792
- "ascending": "false",
793
- "active": "true",
794
- "limit": 100
795
- },
796
- timeout=10,
797
- impersonate="chrome110"
798
- )
799
- response.raise_for_status()
800
-
801
- events = response.json()
802
- markets = []
803
-
804
- # Define filter keywords for hot markets
805
- sports_keywords = ['vs.', ' vs ', 'NFL', 'NBA', 'MLB', 'NHL', 'UFC', 'MMA']
806
- esports_keywords = ['LoL:', 'Dota', 'CS:GO', '(BO3)', '(BO5)', 'Mobile Legends', 'MLBB']
807
- crypto_short_keywords = ['above ___', 'below ___', 'price on', 'what price will',
808
- '4pm et', '8pm et', '12pm et', '3:00pm', '3:15pm']
809
- weather_keywords = ['temperature', 'degrees', 'weather', 'rain', 'snow']
810
-
811
- for event in events:
812
- # Skip if closed or ended
813
- if event.get('closed', False) or event.get('ended', False):
814
- continue
815
-
816
- title = event.get('title', '')
817
- title_lower = title.lower()
818
- slug = event.get('slug', '').lower()
819
-
820
- # Skip "Up or Down" markets
821
- if 'up or down' in title_lower:
822
- continue
823
-
824
- # Skip short-term crypto markets
825
- if any(keyword in title_lower or keyword in slug
826
- for keyword in crypto_short_keywords):
827
- continue
828
-
829
- # Skip weather markets
830
- if any(keyword in title_lower or keyword in slug
831
- for keyword in weather_keywords):
832
- continue
833
-
834
- # Skip esports
835
- if any(keyword in title or keyword in title_lower or keyword in slug
836
- for keyword in esports_keywords):
837
- continue
838
-
839
- # Note: We're NOT filtering out ALL sports here, just closed/ended ones
840
- # Active sports markets with high volume are legitimate hot markets
841
-
842
- # Fetch detailed data for image
843
- details = self.fetch_market_details(event.get("slug", ""))
844
-
845
- markets.append({
846
- "title": event.get("title", "Unknown"),
847
- "slug": event.get("slug", ""),
848
- "volume24hr": float(event.get("volume24hr", 0)),
849
- "liquidity": float(event.get("liquidity", 0)),
850
- "image": details.get("image", "")
851
- })
852
-
853
- if len(markets) >= self.num_items:
854
- break
855
-
856
- return markets
857
-
858
- except Exception as e:
859
- print(f" Warning: API error: {e}")
860
- return []
861
-
862
- def fetch_sports_events(self) -> List[Dict[str, Any]]:
863
- """Fetch upcoming sports events from Gamma API"""
864
- print("Fetching sports events...")
865
-
866
- try:
867
- # Get a larger set of events and filter for sports - paginated
868
- # Fetch 200 total (2 pages of 100)
869
- all_events = []
870
- for offset in range(0, 200, 100):
871
- response = requests.get(
872
- "https://gamma-api.polymarket.com/events",
873
- params={
874
- "active": "true",
875
- "closed": "false",
876
- "order": "volume24hr", # Get popular events
877
- "ascending": "false",
878
- "limit": 100,
879
- "offset": offset
880
- },
881
- timeout=10,
882
- impersonate="chrome110"
883
- )
884
- if response.status_code == 200:
885
- page_data = response.json()
886
- all_events.extend(page_data)
887
- # If we got fewer than 100, we've reached the end
888
- if len(page_data) < 100:
889
- break
890
- else:
891
- response.raise_for_status()
892
- break
893
-
894
- # Filter for sports-related titles - look for "vs" or "vs." which is common in sports
895
- sports_keywords = ['vs.', ' vs ', 'NFL', 'NBA', 'MLB', 'NHL', 'UFC', 'MMA',
896
- 'Soccer', 'Football', 'Basketball', 'Baseball', 'Hockey',
897
- 'Tennis', 'Golf', 'Boxing', 'Premier League', 'Champions League',
898
- 'World Cup', 'Super Bowl', 'fight', 'bout', 'F1', 'Racing',
899
- 'tournament', 'championship', 'Rookie of the Year', 'MVP']
900
-
901
- events = []
902
- for event in all_events:
903
- # Skip if the event is closed or ended
904
- if event.get('closed', False) or event.get('ended', False):
905
- continue
906
-
907
- title = event.get("title", "")
908
- # Check if title contains sports keywords (case insensitive for some)
909
- is_sport = False
910
- for keyword in sports_keywords:
911
- if keyword in title or keyword.lower() in title.lower():
912
- is_sport = True
913
- break
914
-
915
- # Additional filters to exclude non-sports that might have "vs"
916
- exclude_keywords = ['Trump', 'Biden', 'election', 'presidential', 'divorce',
917
- 'coronavirus', 'COVID', 'vaccine', 'trading', 'price',
918
- 'Bitcoin', 'Ethereum', 'stock', 'IPO', 'Supreme Court',
919
- 'LoL:', 'Dota', 'CS:GO', 'esports'] # Exclude esports
920
- if is_sport and not any(ex in title for ex in exclude_keywords):
921
- events.append(event)
922
-
923
- if len(events) >= self.num_items * 2:
924
- break
925
-
926
- markets = []
927
- for event in events[:self.num_items]:
928
- # Sports events already have image in the main response
929
- image_url = event.get("image", "")
930
-
931
- # If no image in main data, try fetching details
932
- if not image_url:
933
- details = self.fetch_market_details(event.get("slug", ""))
934
- image_url = details.get("image", "")
935
-
936
- # Calculate time to start or end
937
- end_time = event.get("endDate", event.get("closedTime", ""))
938
- time_to_start = calculate_time_remaining(end_time)
939
-
940
- # Determine sport type from title
941
- title = event.get("title", "")
942
- sport = "Sports"
943
- if any(x in title for x in ["NFL", "football", "Packers", "Bears", "Cowboys", "Patriots"]):
944
- sport = "NFL"
945
- elif any(x in title for x in ["NBA", "basketball", "Lakers", "Celtics", "Warriors"]):
946
- sport = "NBA"
947
- elif any(x in title for x in ["MLB", "baseball", "Yankees", "Dodgers", "Astros", "Royals", "Phillies"]):
948
- sport = "MLB"
949
- elif any(x in title for x in ["UFC", "MMA", "fight", "Boxing"]):
950
- sport = "UFC/MMA"
951
- elif any(x in title for x in ["Soccer", "Premier League", "Champions League", "World Cup"]):
952
- sport = "Soccer"
953
- elif "Golf" in title:
954
- sport = "Golf"
955
- elif any(x in title for x in ["Tennis", "Wimbledon", "Open"]):
956
- sport = "Tennis"
957
- elif "Super Bowl" in title:
958
- sport = "NFL"
959
-
960
- markets.append({
961
- "title": title,
962
- "slug": event.get("slug", ""),
963
- "startTime": end_time,
964
- "time_to_start": time_to_start,
965
- "volume": float(event.get("volume", 0)),
966
- "image": image_url,
967
- "sport": sport
968
- })
969
-
970
- # If still no sports events found, return empty
971
- if not markets:
972
- print(" Warning: No sports events found")
973
-
974
- return markets
975
-
976
- except Exception as e:
977
- print(f" Warning: API error: {e}")
978
- return []
979
-
980
- def fetch_whale_moves(self) -> List[Dict[str, Any]]:
981
- """Fetch whale moves from Polymarket trades API"""
982
- print("Fetching whale moves...")
983
-
984
- import time
985
-
986
- # Polymarket trades API endpoint
987
- url = 'https://data-api.polymarket.com/trades'
988
- params = {
989
- 'limit': 100,
990
- 'takerOnly': 'true',
991
- 'filterType': 'CASH',
992
- 'filterAmount': 50000
993
- }
994
-
995
- max_retries = 3
996
- timeout = 30
997
-
998
- for attempt in range(max_retries):
999
- try:
1000
- if attempt > 0:
1001
- wait_time = 2 ** attempt
1002
- print(f" Retry {attempt}/{max_retries} after {wait_time}s...")
1003
- time.sleep(wait_time)
1004
-
1005
- response = requests.get(
1006
- url,
1007
- params=params,
1008
- headers={'Content-Type': 'application/json'},
1009
- timeout=timeout,
1010
- impersonate="chrome110"
1011
- )
1012
- response.raise_for_status()
1013
- break
1014
-
1015
- except requests.exceptions.RequestException as e:
1016
- print(f" Warning: API request attempt {attempt + 1}/{max_retries} failed: {e}")
1017
- if attempt == max_retries - 1:
1018
- print(f" Error: Failed after {max_retries} attempts")
1019
- print(" Falling back to placeholder whale move data")
1020
- return self._placeholder_whale_moves()
1021
-
1022
- # Process the successful response
1023
- try:
1024
- trades_data = response.json()
1025
- except json.JSONDecodeError as e:
1026
- print(f" Error: Failed to parse API response: {e}")
1027
- print(" Falling back to placeholder whale move data")
1028
- return self._placeholder_whale_moves()
1029
-
1030
- if not isinstance(trades_data, list):
1031
- print(f" Error: Expected array response, got {type(trades_data)}")
1032
- print(" Falling back to placeholder whale move data")
1033
- return self._placeholder_whale_moves()
1034
-
1035
- # Get current Unix timestamp
1036
- current_timestamp = int(time.time())
1037
- # 24 hours in seconds
1038
- twenty_four_hours_ago = current_timestamp - (24 * 60 * 60)
1039
-
1040
- # Sports slugs to filter out
1041
- sports_keywords = ['nba', 'nhl', 'epl', 'ucl', 'nfl']
1042
-
1043
- # Filter and process trades
1044
- whale_moves = []
1045
-
1046
- for trade in trades_data:
1047
- try:
1048
- # Filter out trades older than 24 hours
1049
- trade_timestamp = trade.get('timestamp', 0)
1050
- if trade_timestamp < twenty_four_hours_ago:
1051
- continue
1052
-
1053
- # Filter out sports-related slugs
1054
- slug = trade.get('slug', '').lower()
1055
- if any(keyword in slug for keyword in sports_keywords):
1056
- continue
1057
-
1058
- # Extract data
1059
- size = float(trade.get('size', 0))
1060
- price = float(trade.get('price', 0))
1061
- amount = size * price # Calculate total amount
1062
-
1063
- title_text = trade.get('title', 'Unknown Market')
1064
- outcome = trade.get('outcome', '')
1065
- side = trade.get('side', '') # BUY or SELL
1066
- event_slug = trade.get('eventSlug', '')
1067
-
1068
- # Build title with outcome: "OUTCOME" Market
1069
- if outcome:
1070
- title = f'"{outcome}" {title_text}'
1071
- else:
1072
- title = title_text
1073
-
1074
- # Use slug from trade, or generate from title
1075
- if event_slug:
1076
- slug_for_display = event_slug
1077
- else:
1078
- slug_for_display = title_text.lower().replace(' ', '-').replace('?', '').replace("'", '')[:50]
1079
-
1080
- # Get user information
1081
- user_name = trade.get('name', trade.get('pseudonym', 'Anonymous'))
1082
-
1083
- # Filter out trades from specific user
1084
- if user_name.lower() == 'kamakamakamata':
1085
- continue
1086
-
1087
- profile_image = trade.get('profileImageOptimized') or trade.get('profileImage', '')
1088
-
1089
- # Get market icon if available
1090
- market_icon = trade.get('icon', '')
1091
-
1092
- whale_moves.append({
1093
- "title": title,
1094
- "market": title_text,
1095
- "event": title_text,
1096
- "slug": slug_for_display,
1097
- "amount": amount,
1098
- "outcome": outcome, # Store for aggregation
1099
- "side": side, # Store for aggregation
1100
- "user_name": user_name,
1101
- "user_profile_url": user_name,
1102
- "distinct_positions": 1,
1103
- "timestamp": datetime.now().isoformat(),
1104
- "profile_image": profile_image, # Store for enrichment
1105
- "market_icon": market_icon # Store for enrichment
1106
- })
1107
-
1108
- except (KeyError, ValueError, TypeError) as e:
1109
- print(f" Warning: Failed to process trade data: {e}")
1110
- continue
1111
-
1112
- # Aggregate trades: combine trades with same market, outcome, side, and user
1113
- print(f" Aggregating {len(whale_moves)} trades...")
1114
- aggregated_moves = {}
1115
-
1116
- for move in whale_moves:
1117
- # Create aggregation key: (market, outcome, side, user_name)
1118
- agg_key = (
1119
- move.get('market', ''),
1120
- move.get('outcome', ''),
1121
- move.get('side', ''),
1122
- move.get('user_name', '')
1123
- )
1124
-
1125
- if agg_key in aggregated_moves:
1126
- # Combine with existing trade: sum amounts and update distinct_positions
1127
- existing = aggregated_moves[agg_key]
1128
- existing['amount'] += move['amount']
1129
- existing['distinct_positions'] += 1
1130
- # Update title: "OUTCOME" Market
1131
- outcome = move.get('outcome', '')
1132
- market = existing.get('market', '')
1133
- if outcome:
1134
- existing['title'] = f'"{outcome}" {market}'
1135
- else:
1136
- existing['title'] = market
1137
- # Keep the most recent profile_image and market_icon if current one is empty
1138
- if not existing.get('profile_image') and move.get('profile_image'):
1139
- existing['profile_image'] = move.get('profile_image')
1140
- if not existing.get('market_icon') and move.get('market_icon'):
1141
- existing['market_icon'] = move.get('market_icon')
1142
- else:
1143
- # First trade for this combination
1144
- aggregated_moves[agg_key] = move.copy()
1145
-
1146
- # Convert back to list
1147
- whale_moves = list(aggregated_moves.values())
1148
- print(f" Aggregated to {len(whale_moves)} unique trades")
1149
-
1150
- # Sort by amount (descending) and limit to top 10
1151
- whale_moves.sort(key=lambda x: x['amount'], reverse=True)
1152
- whale_moves = whale_moves[:10]
1153
-
1154
- if not whale_moves:
1155
- print(" No whale moves found after filtering")
1156
- print(" Falling back to placeholder whale move data")
1157
- return self._placeholder_whale_moves()
1158
-
1159
- print(f" Found {len(whale_moves)} whale moves after filtering")
1160
-
1161
- # Enrich with market images and user images
1162
- whale_moves = self._enrich_whale_moves_with_images(whale_moves)
1163
-
1164
- return whale_moves[:self.num_items]
1165
-
1166
- def _enrich_whale_moves_with_images(self, whale_moves: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1167
- """Enrich whale moves with market images and user profile images."""
1168
- print(" Fetching market images for whale moves...")
1169
- default_user_image = "https://res.cloudinary.com/db1zelfhi/image/upload/v1765869030/polygraph/images/jhvsxfndu0boigqz3kjw.png"
1170
-
1171
- for move in whale_moves:
1172
- # Use profile image from API if available, otherwise use default
1173
- profile_image = move.pop('profile_image', '') # Remove from dict after getting
1174
- if profile_image and profile_image.strip():
1175
- move['user_image'] = profile_image
1176
- else:
1177
- move['user_image'] = default_user_image
1178
-
1179
- # Use market icon from API if available, otherwise fetch using slug
1180
- market_icon = move.pop('market_icon', '') # Remove from dict after getting
1181
- if market_icon and market_icon.strip():
1182
- move['market_image'] = market_icon
1183
- print(f" ✓ Using icon from API for {move.get('market', 'Unknown')[:50]}")
1184
- else:
1185
- # Fetch market image using slug as fallback
1186
- slug = move.get('slug', '')
1187
- if slug:
1188
- try:
1189
- details = self.fetch_market_details(slug)
1190
- market_image = details.get('image', '')
1191
- move['market_image'] = market_image
1192
- if market_image:
1193
- print(f" ✓ Found image for {slug[:50]}")
1194
- else:
1195
- print(f" ⚠ No image found for {slug[:50]}")
1196
- except Exception as e:
1197
- print(f" ⚠ Error fetching image for {slug[:50]}: {e}")
1198
- move['market_image'] = ''
1199
- else:
1200
- print(f" ⚠ No slug available for market: {move.get('market', 'Unknown')}")
1201
- move['market_image'] = ''
1202
-
1203
- return whale_moves
1204
-
1205
- def _placeholder_whale_moves(self) -> List[Dict[str, Any]]:
1206
- """Return fallback whale move data when Polymarket API is unavailable."""
1207
- placeholder_moves = _fresh_placeholder_whales()
1208
- print(f" Using {len(placeholder_moves)} placeholder whale entries")
1209
- # Enrich placeholder moves with images too
1210
- placeholder_moves = self._enrich_whale_moves_with_images(placeholder_moves)
1211
- return placeholder_moves[:self.num_items]
1212
-
1213
- def _placeholder_comments(self) -> List[Dict[str, Any]]:
1214
- """Return fallback comment data when Polymarket APIs are unavailable."""
1215
- comments = _fresh_placeholder_comments()
1216
- print(f" Using {len(comments)} placeholder comment entries")
1217
- return comments[:self.num_items]
1218
-
1219
- def fetch_top_comments(self) -> List[Dict[str, Any]]:
1220
- """Fetch top comments with smart filtering to avoid staleness"""
1221
- print("Fetching top comments with smart filtering...")
1222
-
1223
- try:
1224
- from dateutil import parser
1225
- from datetime import timezone
1226
- now = datetime.now(timezone.utc)
1227
-
1228
- # Get top active markets for variety
1229
- events_response = requests.get(
1230
- "https://gamma-api.polymarket.com/events",
1231
- params={
1232
- "order": "volume24hr",
1233
- "ascending": "false",
1234
- "active": "true",
1235
- "limit": 30 # Check more markets for variety
1236
- },
1237
- timeout=10,
1238
- impersonate="chrome110"
1239
- )
1240
- events_response.raise_for_status()
1241
- events = events_response.json()
1242
-
1243
- all_comments = []
1244
- markets_with_comments = set() # Track which markets we've gotten comments from
1245
-
1246
- # Strategy 1: Get recent high-engagement comments (last 7 days)
1247
- for event in events[:25]:
1248
- event_id = event.get('id')
1249
- event_title = event.get('title', 'Unknown Market')
1250
- event_slug = event.get('slug', '')
1251
-
1252
- if not event_id:
1253
- continue
1254
-
1255
- # Limit comments per market for variety
1256
- if event_title in markets_with_comments and len(markets_with_comments) < 10:
1257
- continue
1258
-
1259
- try:
1260
- # Fetch both recent and top comments
1261
- for order_type in ["reactionCount", "createdAt"]:
1262
- comments_response = requests.get(
1263
- "https://gamma-api.polymarket.com/comments",
1264
- params={
1265
- "order": order_type,
1266
- "ascending": "false",
1267
- "limit": 10,
1268
- "parent_entity_type": "Event",
1269
- "parent_entity_id": event_id
1270
- },
1271
- timeout=5,
1272
- impersonate="chrome110"
1273
- )
1274
-
1275
- if comments_response.status_code == 200:
1276
- comments = comments_response.json()
1277
-
1278
- for comment in comments:
1279
- body = comment.get('body', '').strip()
1280
-
1281
- # Filter out short/low-quality comments
1282
- if len(body) < 20: # Too short
1283
- continue
1284
- if body.lower() in ['nice', 'lol', 'based', 'bullish', 'bearish', 'lfg', 'hodl', 'moon']:
1285
- continue
1286
-
1287
- created_at = comment.get('createdAt', '')
1288
- likes = comment.get('reactionCount', 0)
1289
-
1290
- # Calculate age in days
1291
- age_days = 1000 # Default to very old
1292
- if created_at:
1293
- try:
1294
- comment_date = parser.parse(created_at)
1295
- if comment_date.tzinfo is None:
1296
- comment_date = comment_date.replace(tzinfo=timezone.utc)
1297
- age = now - comment_date
1298
- age_days = max(age.total_seconds() / 86400, 0.1) # Minimum 0.1 days
1299
- except:
1300
- continue
1301
-
1302
- # Skip if too old (>14 days) unless it has exceptional engagement
1303
- if age_days > 14 and likes < 20:
1304
- continue
1305
-
1306
- # Skip if too recent with no engagement
1307
- if age_days < 2 and likes < 3:
1308
- continue
1309
-
1310
- # Skip comments with 0 likes unless they're brand new and substantial
1311
- if likes == 0 and (age_days > 1 or len(body) < 100):
1312
- continue
1313
-
1314
- # Calculate weighted score: likes / (age_in_days + 1)
1315
- # This favors recent comments with good engagement
1316
- weighted_score = likes / (age_days + 1)
1317
-
1318
- # Boost very recent highly engaging comments
1319
- if age_days < 2 and likes > 10:
1320
- weighted_score *= 2
1321
-
1322
- all_comments.append({
1323
- "comment": body,
1324
- "author": comment.get('profile', {}).get('name',
1325
- comment.get('profile', {}).get('pseudonym', 'Anonymous')),
1326
- "market_title": event_title,
1327
- "slug": event_slug,
1328
- "likes": likes,
1329
- "createdAt": created_at,
1330
- "age_days": age_days,
1331
- "weighted_score": weighted_score
1332
- })
1333
-
1334
- markets_with_comments.add(event_title)
1335
-
1336
- except Exception as e:
1337
- continue
1338
-
1339
- # Remove duplicates (same comment text)
1340
- seen_comments = set()
1341
- unique_comments = []
1342
- for comment in all_comments:
1343
- comment_key = comment['comment'][:100].lower() # Use first 100 chars as key
1344
- if comment_key not in seen_comments:
1345
- seen_comments.add(comment_key)
1346
- unique_comments.append(comment)
1347
-
1348
- # Sort by weighted score
1349
- unique_comments.sort(key=lambda x: x['weighted_score'], reverse=True)
1350
-
1351
- # Take top N, ensuring variety
1352
- final_comments = []
1353
- markets_included = set()
1354
-
1355
- for comment in unique_comments:
1356
- # Limit 2 comments per market for variety
1357
- market_count = sum(1 for c in final_comments if c['market_title'] == comment['market_title'])
1358
- if market_count >= 2:
1359
- continue
1360
-
1361
- final_comments.append(comment)
1362
- markets_included.add(comment['market_title'])
1363
-
1364
- if len(final_comments) >= self.num_items:
1365
- break
1366
-
1367
- # Log some stats for debugging
1368
- if final_comments:
1369
- avg_age = sum(c['age_days'] for c in final_comments) / len(final_comments)
1370
- print(f" Selected {len(final_comments)} comments from {len(markets_included)} markets")
1371
- print(f" Average age: {avg_age:.1f} days (vs 36 days before)")
1372
- return final_comments
1373
-
1374
- print(" No qualifying comments returned - using placeholder comments")
1375
- return self._placeholder_comments()
1376
-
1377
- except Exception as e:
1378
- print(f" Warning: API error: {e}")
1379
- return self._placeholder_comments()
1380
-
1381
- def display_menu(self) -> None:
1382
- """Display the menu of available markets"""
1383
- self.build_menu(verbose=True)
1384
-
1385
- def build_menu(self, verbose: bool = False) -> List[Dict[str, Any]]:
1386
- """Populate structured menu entries and optionally print them."""
1387
- sections = [
1388
- ("ending_soon", "Ending Soon", self.fetch_ending_soon, "??"),
1389
- ("just_listed", "New", self.fetch_just_listed, "??"),
1390
- ("breaking_news", "Breaking News", self.fetch_breaking_news, "??"),
1391
- ("hot_markets", "Hot Markets", self.fetch_hot_markets, "??"),
1392
- ("whale_moves", "Whale Moves", self.fetch_whale_moves, "??"),
1393
- ("top_comments", "Top Comments", self.fetch_top_comments, "??"),
1394
- ("sports_events", "Top Sports", self.fetch_sports_events, "??"),
1395
- ]
1396
-
1397
- if verbose:
1398
- print("\n" + "=" * 60)
1399
- print("POLYMARKET DATA EMAIL MENU")
1400
- print("=" * 60)
1401
-
1402
- menu_entries: List[Dict[str, Any]] = []
1403
- counter = 1
1404
- self.menu_items = {}
1405
-
1406
- for key, label, fetcher, emoji in sections:
1407
- items = fetcher()
1408
-
1409
- if verbose:
1410
- print(f"\n{emoji} {label.upper()}")
1411
- print("-" * 40)
1412
-
1413
- for item in items:
1414
- self.menu_items[counter] = (key, item)
1415
- url = self._resolve_item_url(key, item)
1416
- # Create Dub tracking link if API key is set and not skipping
1417
- if url and not self.skip_dub:
1418
- title = self._format_menu_entry_text(key, item, url)[0].strip('"')
1419
- url = create_dub_tracking_link(url, title, tags=["polygraph"], skip_dub=self.skip_dub)
1420
- menu_entries.append({
1421
- "id": counter,
1422
- "category": key,
1423
- "label": label,
1424
- "emoji": emoji,
1425
- "data": item,
1426
- "url": url,
1427
- })
1428
-
1429
- if verbose:
1430
- title_line, detail_line = self._format_menu_entry_text(key, item, url)
1431
- print(f"{counter}. {title_line}")
1432
- if detail_line:
1433
- print(detail_line)
1434
-
1435
- counter += 1
1436
-
1437
- if verbose:
1438
- print("\n" + "=" * 60)
1439
-
1440
- return menu_entries
1441
-
1442
- def _format_menu_entry_text(self, category: str, data: Dict[str, Any], url: str) -> Tuple[str, str]:
1443
- """Return printable title/detail strings for menu entries."""
1444
- if category == "top_comments":
1445
- text = data.get("comment", "").strip()
1446
- snippet = text if len(text) <= 200 else f"{text[:200]}..."
1447
- likes = data.get("likes", 0)
1448
- market = data.get("market_title", "Unknown Market")
1449
- author = data.get("author", "Anonymous")
1450
- detail = f" - {author} on {market} (?? {likes})"
1451
- return f"\"{snippet}\"", detail
1452
-
1453
- if category == "whale_moves":
1454
- title = data.get("title", "Large position")
1455
- market = data.get("market", "N/A")
1456
- return title, f" Market: {market}"
1457
-
1458
- title = data.get("title") or data.get("event") or "Untitled market"
1459
- detail = f" {url}" if url else ""
1460
- return title, detail
1461
-
1462
- def _resolve_item_url(self, category: str, data: Dict[str, Any]) -> str:
1463
- """Resolve the canonical Polymarket link for a menu entry."""
1464
- slug = data.get("slug") or data.get("market_slug")
1465
- if slug:
1466
- return f"https://polymarket.com/event/{slug}"
1467
-
1468
- if category == "whale_moves" and data.get("event"):
1469
- event_slug = re.sub(r"[^a-z0-9-]", "-", data["event"].lower()).strip("-")
1470
- if event_slug:
1471
- return f"https://polymarket.com/event/{event_slug}"
1472
-
1473
- return data.get("link", "")
1474
- def get_user_selections(self) -> List[tuple]:
1475
- """Get user's market selections with optional 's' suffix for sports section"""
1476
- print("\n>>> Enter Your Selections (comma-separated numbers):")
1477
- print(" Example: 2, 3, 4, 9, 10, 35")
1478
- print(" Add 's' to move to Sports section: 21s, 25s")
1479
- print(" Or press Enter to skip selection")
1480
- print(" Or input 'z' to go back\n")
1481
-
1482
- user_input = input(">>> ").strip()
1483
-
1484
- if user_input.lower() == 'z':
1485
- return 'BACK'
1486
-
1487
- if not user_input:
1488
- return []
1489
-
1490
- try:
1491
- valid_selections = []
1492
- for item in user_input.split(','):
1493
- item = item.strip()
1494
-
1495
- # Check if item has 's' suffix for sports override
1496
- force_sports = False
1497
- if item.endswith('s') or item.endswith('S'):
1498
- force_sports = True
1499
- item = item[:-1] # Remove the 's' suffix
1500
-
1501
- # Convert to integer
1502
- sel = int(item)
1503
-
1504
- # Validate selection
1505
- if sel in self.menu_items:
1506
- valid_selections.append((sel, force_sports))
1507
- else:
1508
- print(f"Warning: Selection {sel} is invalid (out of range)")
1509
-
1510
- return valid_selections
1511
- except ValueError as e:
1512
- print(f"Error: Invalid input. Please enter comma-separated numbers (optionally with 's' suffix).")
1513
- return self.get_user_selections()
1514
-
1515
- def get_poly_archive_tweets(self) -> List[Dict[str, Any]]:
1516
- """Prompt user for poly_archive tweet image URLs and associated markets"""
1517
- print("\n" + "="*60)
1518
- print("📸 POLY_ARCHIVE TWEET SCREENSHOTS (optional)")
1519
- print("="*60)
1520
- print("Add tweet screenshots from @poly_archive to Top Comments section")
1521
- print("Right-click on tweet image → 'Copy Image Address' → paste here")
1522
- print("Press Enter to skip, or input 'z' to go back\n")
1523
-
1524
- tweets = []
1525
-
1526
- while True:
1527
- image_url = input(">>> Image URL from poly_archive tweet (or Enter to finish, 'z' to go back): ").strip()
1528
-
1529
- if image_url.lower() == 'z':
1530
- return 'BACK'
1531
-
1532
- if not image_url:
1533
- break
1534
-
1535
- # Validate it's an image URL
1536
- if not any(ext in image_url.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', 'pbs.twimg.com']):
1537
- print(" ⚠️ Doesn't look like an image URL. Please paste the direct image URL")
1538
- continue
1539
-
1540
- # Prompt for the market slug
1541
- print(">>> Polymarket market slug or full URL for this tweet (or 'z' to go back):")
1542
- market_input = input(">>> ").strip()
1543
-
1544
- if market_input.lower() == 'z':
1545
- return 'BACK'
1546
-
1547
- if not market_input:
1548
- print(" ⚠️ Market required. Skipping this tweet.")
1549
- continue
1550
-
1551
- # Extract slug from URL if full URL provided
1552
- if 'polymarket.com' in market_input:
1553
- # Extract slug from URL like https://polymarket.com/event/slug-here?tid=123
1554
- import re
1555
- slug_match = re.search(r'/event/([^?/#]+)', market_input)
1556
- if slug_match:
1557
- market_slug = slug_match.group(1)
1558
- else:
1559
- print(" ⚠️ Could not extract market slug from URL. Skipping.")
1560
- continue
1561
- else:
1562
- market_slug = market_input
1563
-
1564
- # Download the image
1565
- print(f" Downloading image...")
1566
- try:
1567
- headers = {
1568
- 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
1569
- }
1570
- img_response = requests.get(image_url, headers=headers, timeout=10, impersonate="chrome110")
1571
-
1572
- if img_response.status_code != 200:
1573
- print(f" ⚠️ Failed to download image: HTTP {img_response.status_code}")
1574
- continue
1575
-
1576
- image_data = img_response.content
1577
-
1578
- except Exception as e:
1579
- print(f" ⚠️ Error downloading image: {e}")
1580
- continue
1581
-
1582
- # Upload to Cloudinary (no resizing)
1583
- date_folder = datetime.now().strftime("%-m-%-d-%y")
1584
- filename = f"poly_archive_{len(tweets)}.png"
1585
-
1586
- print(f" Uploading to Cloudinary...")
1587
- cloudinary_url = upload_to_cloudinary(image_data, filename, date_folder, apply_transform=False)
1588
-
1589
- if cloudinary_url and cloudinary_url != "https://via.placeholder.com/48":
1590
- tweets.append({
1591
- "market_slug": market_slug,
1592
- "image_url": cloudinary_url,
1593
- "is_tweet_screenshot": True
1594
- })
1595
- print(f" ✅ Added tweet screenshot for market: {market_slug}\n")
1596
- else:
1597
- print(f" ⚠️ Failed to upload image. Skipping.\n")
1598
-
1599
- if tweets:
1600
- print(f"\n✅ Added {len(tweets)} poly_archive tweet screenshot(s)\n")
1601
-
1602
- return tweets
1603
-
1604
- def organize_selections(self, selections: List[tuple]) -> Dict[str, List[Dict]]:
1605
- """Organize selected markets by category with sports override support"""
1606
- organized = defaultdict(list)
1607
-
1608
- for item in selections:
1609
- # Handle both old format (int) and new format (tuple)
1610
- if isinstance(item, tuple):
1611
- sel, force_sports = item
1612
- else:
1613
- sel = item
1614
- force_sports = False
1615
-
1616
- category, market_data = self.menu_items[sel]
1617
-
1618
- # Override category to sports_events if 's' suffix was used
1619
- if force_sports and category != "sports_events":
1620
- print(f" Moving item {sel} from {category} to sports section")
1621
- category = "sports_events"
1622
-
1623
- organized[category].append(market_data)
1624
-
1625
- return organized
1626
-
1627
- def collect_top_stories(self) -> List[Dict[str, str]]:
1628
- """Collect top 3 stories for the header section"""
1629
- print("\n" + "="*60)
1630
- print("📰 TOP STORIES & INTRO")
1631
- print("="*60)
1632
-
1633
- # First collect intro text
1634
- print("\n📝 INTRO TEXT (appears below date, above Top Stories):")
1635
- print(" This is your opening message or summary")
1636
- print(" Press Enter to skip, or input 'z' to go back\n")
1637
- intro_text = input("Intro text: ").strip()
1638
-
1639
- if intro_text.lower() == 'z':
1640
- return 'BACK'
1641
-
1642
- print("\nNow enter 3 top stories to highlight")
1643
- print("Press Enter to skip any story, or input 'z' to go back\n")
1644
-
1645
- stories = []
1646
-
1647
- for i in range(1, 4):
1648
- print(f"STORY {i}:")
1649
- print("-" * 30)
1650
-
1651
- headline = input(f" Headline {i}: ").strip()
1652
- if headline.lower() == 'z':
1653
- return 'BACK'
1654
- if not headline:
1655
- print(f" Skipping story {i}")
1656
- continue
1657
-
1658
- body_text = input(f" Copy/Description: ").strip()
1659
- if body_text.lower() == 'z':
1660
- return 'BACK'
1661
- if not body_text:
1662
- body_text = ""
1663
-
1664
- # Get the Polymarket link
1665
- link = input(f" Polymarket URL (or slug): ").strip()
1666
- if link.lower() == 'z':
1667
- return 'BACK'
1668
- if link:
1669
- # If it's just a slug, convert to full URL
1670
- if not link.startswith("http"):
1671
- link = f"https://polymarket.com/event/{link}"
1672
-
1673
- # Create tracking link if Dub is enabled
1674
- tracking_link = create_dub_tracking_link(link, headline, tags=["polygraph"], skip_dub=self.skip_dub)
1675
-
1676
- stories.append({
1677
- "headline": headline,
1678
- "body": body_text,
1679
- "link": tracking_link
1680
- })
1681
- print(f" ✅ Added story {i}")
1682
- else:
1683
- print(f" ⚠️ Skipping story {i} - no link provided")
1684
-
1685
- result = {"stories": stories}
1686
- if intro_text:
1687
- result["intro_text"] = intro_text
1688
-
1689
- if stories:
1690
- print(f"\n✅ Added {len(stories)} top stories")
1691
- if intro_text:
1692
- print(f"✅ Added intro text")
1693
- if not stories and not intro_text:
1694
- print("\nℹ️ No top stories or intro added")
1695
-
1696
- return result
1697
-
1698
- def run(self):
1699
- """Main execution flow with support for going back"""
1700
- print("\nStarting Polymarket Data Collection")
1701
- print(f" Items per section: {self.num_items}")
1702
-
1703
- # Check for Cloudinary
1704
- if os.getenv('CLOUDINARY_URL'):
1705
- print("✅ Cloudinary configured - tweet images will be uploaded")
1706
- else:
1707
- print("⚠️ CLOUDINARY_URL not set - tweet images won't be uploaded")
1708
-
1709
- # Check for Dub API
1710
- if os.getenv('DUB_API_KEY'):
1711
- print("✅ Dub.co configured - links will be tracked")
1712
- else:
1713
- print("⚠️ DUB_API_KEY not set - links won't be tracked")
1714
-
1715
- # State management for going back
1716
- step = 1
1717
- selections = None
1718
- organized = None
1719
- poly_archive_tweets = None
1720
- top_stories = None
1721
-
1722
- while True:
1723
- if step == 1:
1724
- # Display menu and get selections
1725
- self.display_menu()
1726
- selections = self.get_user_selections()
1727
-
1728
- if selections == 'BACK':
1729
- print("\n⚠️ Can't go back - this is the first step")
1730
- continue
1731
-
1732
- if not selections:
1733
- print("\nWarning: No selections made. Exiting.")
1734
- return
1735
-
1736
- print(f"\nSelected items: {[s[0] if isinstance(s, tuple) else s for s in selections]}")
1737
- organized = self.organize_selections(selections)
1738
- step = 2
1739
-
1740
- elif step == 2:
1741
- # Get poly_archive tweets
1742
- poly_archive_tweets = self.get_poly_archive_tweets()
1743
-
1744
- if poly_archive_tweets == 'BACK':
1745
- print("\n↩️ Going back to market selections...\n")
1746
- step = 1
1747
- continue
1748
-
1749
- # Add poly_archive tweets to top_comments section
1750
- if poly_archive_tweets:
1751
- if "top_comments" not in organized:
1752
- organized["top_comments"] = []
1753
- organized["top_comments"].extend(poly_archive_tweets)
1754
-
1755
- step = 3
1756
-
1757
- elif step == 3:
1758
- # Collect top stories
1759
- top_stories = self.collect_top_stories()
1760
-
1761
- if top_stories == 'BACK':
1762
- print("\n↩️ Going back to poly_archive tweets...\n")
1763
- # Remove previously added tweets from organized
1764
- if poly_archive_tweets and "top_comments" in organized:
1765
- for tweet in poly_archive_tweets:
1766
- if tweet in organized["top_comments"]:
1767
- organized["top_comments"].remove(tweet)
1768
- step = 2
1769
- continue
1770
-
1771
- # All steps complete
1772
- break
1773
-
1774
- # Summary
1775
- print("\n✅ Data collection complete!")
1776
- print(f" Total sections: {len(organized)}")
1777
- print(f" Total items: {len(selections)}")
1778
-
1779
- for category, items in organized.items():
1780
- print(f" • {category}: {len(items)} items")
1781
-
1782
- print(f"\n🎉 Done! Data is ready for use.")
1783
-
1784
-
1785
- def main():
1786
- """Main entry point"""
1787
- parser = argparse.ArgumentParser(description='Collect Polymarket Data (Markets, Comments, Whale Moves)')
1788
- parser.add_argument(
1789
- 'num_items',
1790
- type=int,
1791
- nargs='?',
1792
- default=10,
1793
- help='Number of items per section (default: 10)'
1794
- )
1795
- parser.add_argument(
1796
- '--selections',
1797
- type=str,
1798
- help='Comma-separated list of selections (e.g., "1,3,5,7,10")'
1799
- )
1800
- parser.add_argument(
1801
- '--nodub',
1802
- action='store_true',
1803
- help='Skip creating Dub tracking links, use direct Polymarket URLs'
1804
- )
1805
-
1806
- args = parser.parse_args()
1807
-
1808
- # Validate num_items
1809
- if args.num_items < 1:
1810
- print("Error: Number of items must be at least 1")
1811
- sys.exit(1)
1812
-
1813
- if args.num_items > 50:
1814
- print("Warning: Large number of items may make the menu hard to navigate")
1815
-
1816
- # Create and run generator with nodub flag
1817
- generator = PolymarketEmailGenerator(num_items=args.num_items, skip_dub=args.nodub)
1818
-
1819
- # Show status if nodub is enabled
1820
- if args.nodub:
1821
- print("ℹ️ Dub tracking links disabled - using direct Polymarket URLs")
1822
-
1823
- # If selections provided via command line, use them
1824
- if args.selections:
1825
- generator.display_menu()
1826
-
1827
- # Parse selections with 's' suffix support
1828
- valid_selections = []
1829
- for item in args.selections.split(','):
1830
- item = item.strip()
1831
-
1832
- # Check for 's' suffix
1833
- force_sports = False
1834
- if item.endswith('s') or item.endswith('S'):
1835
- force_sports = True
1836
- item = item[:-1]
1837
-
1838
- try:
1839
- sel = int(item)
1840
- if sel in generator.menu_items:
1841
- valid_selections.append((sel, force_sports))
1842
- else:
1843
- print(f"Warning: Selection {sel} is invalid (out of range)")
1844
- except ValueError:
1845
- print(f"Warning: Invalid selection '{item}'")
1846
-
1847
- print(f"\nUsing command-line selections: {[s[0] if isinstance(s, tuple) else s for s in valid_selections]}")
1848
-
1849
- if valid_selections:
1850
- # Organize selections
1851
- organized = generator.organize_selections(valid_selections)
1852
-
1853
- # Collect top stories
1854
- top_stories = generator.collect_top_stories()
1855
-
1856
- # Summary
1857
- print("\n✅ Data collection complete!")
1858
- print(f" Total sections: {len(organized)}")
1859
- print(f" Total items: {len(valid_selections)}")
1860
- for category, items in organized.items():
1861
- print(f" • {category}: {len(items)} items")
1862
- print(f"\n🎉 Done! Data is ready for use.")
1863
- else:
1864
- generator.run()
1865
-
1866
-
1867
- if __name__ == "__main__":
1868
- main()
1869
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/fetch_menu_data.py CHANGED
@@ -1,29 +1,30 @@
1
  #!/usr/bin/env python3
2
  """
3
- Standalone script to fetch Polymarket menu data and export as JSON.
4
- Run this on your local machine to avoid network timeout issues.
5
 
6
  Usage:
7
- python fetch_menu_data.py [--num-items N] [--output output.json]
 
 
 
 
 
8
  """
9
 
10
  import argparse
11
  import json
12
  import sys
13
- from datetime import datetime
14
  from pathlib import Path
15
- from dotenv import load_dotenv
16
-
17
- # Load environment variables from .env file
18
- load_dotenv()
19
 
20
- # Import the generator from email3.py
21
- from email3 import PolymarketEmailGenerator
22
 
23
 
24
  def main():
25
  parser = argparse.ArgumentParser(
26
- description="Fetch Polymarket menu data and export as JSON"
27
  )
28
  parser.add_argument(
29
  "--num-items",
@@ -31,61 +32,62 @@ def main():
31
  default=10,
32
  help="Number of items per section (default: 10)",
33
  )
34
- parser.add_argument(
35
- "--skip-dub",
36
- action="store_true",
37
- help="Skip creating Dub tracking links, use direct Polymarket URLs",
38
- )
39
  parser.add_argument(
40
  "--output",
41
  type=str,
42
- default="menu_data.json",
43
- help="Output JSON file path (default: menu_data.json)",
44
  )
45
-
 
 
 
 
 
46
  args = parser.parse_args()
47
-
48
- print("🚀 Starting Polymarket menu data fetch...")
49
  print(f" Items per section: {args.num_items}")
50
- print(f" Skip Dub links: {args.skip_dub}")
51
  print()
52
-
53
  try:
54
  # Create generator instance
55
- generator = PolymarketEmailGenerator(
56
- num_items=args.num_items,
57
- skip_dub=args.skip_dub,
58
- )
59
-
60
- # Build menu (this fetches all data including breaking news)
61
- print("📊 Fetching menu data...")
62
- menu_entries = generator.build_menu(verbose=True)
63
-
64
  # Format as MenuResponse structure
65
  menu_response = {
66
- "generated_at": datetime.utcnow().isoformat(),
 
 
67
  "items": menu_entries,
68
  }
69
-
70
  # Write to JSON file
71
  output_path = Path(args.output)
72
  with open(output_path, "w", encoding="utf-8") as f:
73
  json.dump(menu_response, f, indent=2, ensure_ascii=False)
74
-
75
  print()
76
  print(f"✅ Success! Menu data exported to: {output_path}")
77
  print(f" Total items: {len(menu_entries)}")
78
-
79
  # Show breakdown by category
80
  categories = {}
81
  for item in menu_entries:
82
  cat = item.get("category", "unknown")
83
  categories[cat] = categories.get(cat, 0) + 1
84
-
85
- print(f" Categories: {categories}")
86
-
 
 
87
  return 0
88
-
89
  except KeyboardInterrupt:
90
  print("\n⚠️ Interrupted by user")
91
  return 1
@@ -97,5 +99,4 @@ def main():
97
 
98
 
99
  if __name__ == "__main__":
100
- sys.exit(main())
101
-
 
1
  #!/usr/bin/env python3
2
  """
3
+ Standalone script to fetch Kalshi menu data and export as JSON.
4
+ Run this on your local machine to fetch data from Kalshi's public API.
5
 
6
  Usage:
7
+ python fetch_kalshi_menu.py [--num-items N] [--output output.json]
8
+
9
+ Examples:
10
+ python fetch_kalshi_menu.py
11
+ python fetch_kalshi_menu.py --num-items 15 --output kalshi_data.json
12
+ python fetch_kalshi_menu.py --verbose
13
  """
14
 
15
  import argparse
16
  import json
17
  import sys
18
+ from datetime import datetime, timezone
19
  from pathlib import Path
 
 
 
 
20
 
21
+ # Import the generator from kalshi_data.py
22
+ from kalshi_data import KalshiDataGenerator
23
 
24
 
25
  def main():
26
  parser = argparse.ArgumentParser(
27
+ description="Fetch Kalshi menu data and export as JSON"
28
  )
29
  parser.add_argument(
30
  "--num-items",
 
32
  default=10,
33
  help="Number of items per section (default: 10)",
34
  )
 
 
 
 
 
35
  parser.add_argument(
36
  "--output",
37
  type=str,
38
+ default="kalshi_menu_data.json",
39
+ help="Output JSON file path (default: kalshi_menu_data.json)",
40
  )
41
+ parser.add_argument(
42
+ "--verbose",
43
+ action="store_true",
44
+ help="Print detailed menu output during fetch",
45
+ )
46
+
47
  args = parser.parse_args()
48
+
49
+ print("🚀 Starting Kalshi menu data fetch...")
50
  print(f" Items per section: {args.num_items}")
51
+ print(f" Output file: {args.output}")
52
  print()
53
+
54
  try:
55
  # Create generator instance
56
+ generator = KalshiDataGenerator(num_items=args.num_items)
57
+
58
+ # Build menu (this fetches all data)
59
+ print("📊 Fetching menu data from Kalshi API...")
60
+ menu_entries = generator.build_menu(verbose=args.verbose)
61
+
 
 
 
62
  # Format as MenuResponse structure
63
  menu_response = {
64
+ "generated_at": datetime.now(timezone.utc).isoformat(),
65
+ "source": "Kalshi API",
66
+ "api_base": "https://api.elections.kalshi.com/trade-api/v2",
67
  "items": menu_entries,
68
  }
69
+
70
  # Write to JSON file
71
  output_path = Path(args.output)
72
  with open(output_path, "w", encoding="utf-8") as f:
73
  json.dump(menu_response, f, indent=2, ensure_ascii=False)
74
+
75
  print()
76
  print(f"✅ Success! Menu data exported to: {output_path}")
77
  print(f" Total items: {len(menu_entries)}")
78
+
79
  # Show breakdown by category
80
  categories = {}
81
  for item in menu_entries:
82
  cat = item.get("category", "unknown")
83
  categories[cat] = categories.get(cat, 0) + 1
84
+
85
+ print(" Breakdown by section:")
86
+ for cat, count in categories.items():
87
+ print(f" • {cat}: {count} items")
88
+
89
  return 0
90
+
91
  except KeyboardInterrupt:
92
  print("\n⚠️ Interrupted by user")
93
  return 1
 
99
 
100
 
101
  if __name__ == "__main__":
102
+ sys.exit(main())
 
app/kalshi_data.py ADDED
@@ -0,0 +1,819 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Kalshi Data Collection Script
4
+ Fetches and organizes market data from Kalshi's public API
5
+
6
+ Sections implemented:
7
+ - Ending Soon: Markets closing within the next few days
8
+ - Just Listed: Recently created markets
9
+ - Biggest Movers: Markets with largest 24h price changes (replaces "Breaking News")
10
+ - Hot Markets: Markets with highest 24h volume
11
+ - Sports Events: Sports-related markets
12
+
13
+ Note: Whale Moves is NOT implemented as Kalshi's API does not expose trader identity.
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from datetime import datetime, timezone
20
+ from typing import Dict, List, Any, Optional
21
+ import requests
22
+ from collections import defaultdict
23
+
24
+ # Kalshi API base URL (works for all markets, not just elections)
25
+ BASE_URL = "https://api.elections.kalshi.com/trade-api/v2"
26
+
27
+
28
+ def calculate_time_remaining(close_time_str: str) -> str:
29
+ """Calculate time remaining until market closes"""
30
+ try:
31
+ close_time = datetime.fromisoformat(close_time_str.replace('Z', '+00:00'))
32
+ now = datetime.now(timezone.utc)
33
+ delta = close_time - now
34
+
35
+ if delta.total_seconds() <= 0:
36
+ return "Closed"
37
+ elif delta.days > 0:
38
+ return f"{delta.days}d"
39
+ elif delta.seconds > 3600:
40
+ hours = delta.seconds // 3600
41
+ return f"{hours}h"
42
+ else:
43
+ minutes = delta.seconds // 60
44
+ return f"{minutes}m"
45
+ except Exception:
46
+ return "Soon"
47
+
48
+
49
+ def calculate_time_since(created_time_str: str) -> str:
50
+ """Calculate time since market was created"""
51
+ try:
52
+ if not created_time_str:
53
+ return "Recently"
54
+
55
+ created_time = datetime.fromisoformat(created_time_str.replace('Z', '+00:00'))
56
+ now = datetime.now(timezone.utc)
57
+ delta = now - created_time
58
+ total_seconds = delta.total_seconds()
59
+
60
+ if delta.days > 30:
61
+ months = delta.days // 30
62
+ return f"{months}mo ago"
63
+ elif delta.days > 0:
64
+ return f"{delta.days}d ago"
65
+ elif total_seconds > 3600:
66
+ hours = int(total_seconds // 3600)
67
+ return f"{hours}h ago"
68
+ elif total_seconds > 60:
69
+ minutes = int(total_seconds // 60)
70
+ return f"{minutes}m ago"
71
+ else:
72
+ return "Just now"
73
+ except Exception:
74
+ return "Recently"
75
+
76
+
77
+ class KalshiDataGenerator:
78
+ """Main class for collecting and organizing Kalshi market data"""
79
+
80
+ # Keywords to exclude for non-sports/non-weather sections
81
+ WEATHER_KEYWORDS = [
82
+ 'temperature', 'degrees', 'rain', 'snow', 'weather', 'storm',
83
+ 'hurricane', 'tornado', 'hotter', 'colder', 'warmest', 'coldest',
84
+ 'precipitation', 'humidity', 'forecast', 'climate', 'kxhighny',
85
+ 'kxlowny', 'kxhighla', 'kxhighchi', 'temp'
86
+ ]
87
+
88
+ SPORTS_KEYWORDS = [
89
+ 'vs.', ' vs ', 'NFL', 'NBA', 'MLB', 'NHL', 'UFC', 'MMA',
90
+ 'Soccer', 'Football', 'Basketball', 'Baseball', 'Hockey',
91
+ 'Tennis', 'Golf', 'Boxing', 'Premier League', 'Champions League',
92
+ 'World Cup', 'Super Bowl', 'fight', 'bout', 'F1', 'Racing',
93
+ 'tournament', 'championship', 'Rookie of the Year', 'MVP',
94
+ 'Heisman', 'playoffs', 'ncaa', 'bowl game'
95
+ ]
96
+
97
+ CRYPTO_KEYWORDS = [
98
+ 'bitcoin', 'ethereum', 'btc', 'eth', 'solana', 'sol', 'xrp',
99
+ 'crypto', 'doge', 'cardano', 'bnb', 'polygon', 'avalanche'
100
+ ]
101
+
102
+ def __init__(self, num_items: int = 10):
103
+ """Initialize with number of items per section"""
104
+ self.num_items = num_items
105
+ self.menu_items = {}
106
+ self.event_metadata_cache = {}
107
+
108
+ def _api_get(self, endpoint: str, params: Optional[Dict] = None) -> Dict:
109
+ """Make a GET request to the Kalshi API"""
110
+ url = f"{BASE_URL}{endpoint}"
111
+ try:
112
+ response = requests.get(url, params=params, timeout=15)
113
+ response.raise_for_status()
114
+ return response.json()
115
+ except requests.exceptions.RequestException as e:
116
+ print(f" Warning: API request failed: {e}")
117
+ return {}
118
+
119
+ def _fetch_all_markets(self, status: str = "open", limit: int = 1000) -> List[Dict]:
120
+ """Fetch all markets with pagination support"""
121
+ all_markets = []
122
+ cursor = None
123
+
124
+ while True:
125
+ params = {"status": status, "limit": min(limit, 1000), "mve_filter": "exclude"}
126
+ if cursor:
127
+ params["cursor"] = cursor
128
+
129
+ data = self._api_get("/markets", params)
130
+ markets = data.get("markets", [])
131
+ all_markets.extend(markets)
132
+
133
+ cursor = data.get("cursor", "")
134
+ if not cursor or len(markets) < params["limit"]:
135
+ break
136
+
137
+ return all_markets
138
+
139
+ def _fetch_event_metadata(self, event_ticker: str) -> Dict:
140
+ """Fetch event metadata including images"""
141
+ if event_ticker in self.event_metadata_cache:
142
+ return self.event_metadata_cache[event_ticker]
143
+
144
+ try:
145
+ data = self._api_get(f"/events/{event_ticker}/metadata")
146
+ self.event_metadata_cache[event_ticker] = data
147
+ return data
148
+ except Exception:
149
+ return {}
150
+
151
+ def _enrich_with_images(self, markets: List[Dict]) -> List[Dict]:
152
+ """Fetch and add images for a list of markets (call only on final items)"""
153
+ if not markets:
154
+ return markets
155
+
156
+ print(f" Fetching images for {len(markets)} markets...")
157
+ for market in markets:
158
+ event_ticker = market.get("event_ticker", "")
159
+ if event_ticker:
160
+ metadata = self._fetch_event_metadata(event_ticker)
161
+ market["image"] = metadata.get("image_url", "")
162
+ else:
163
+ market["image"] = ""
164
+ return markets
165
+
166
+ def _is_weather_market(self, title: str, ticker: str) -> bool:
167
+ """Check if market is weather-related"""
168
+ title_lower = title.lower()
169
+ ticker_lower = ticker.lower()
170
+ return any(kw.lower() in title_lower or kw.lower() in ticker_lower
171
+ for kw in self.WEATHER_KEYWORDS)
172
+
173
+ def _is_sports_market(self, title: str, ticker: str, category: str = "") -> bool:
174
+ """Check if market is sports-related"""
175
+ title_lower = title.lower()
176
+ ticker_lower = ticker.lower()
177
+ category_lower = category.lower() if category else ""
178
+
179
+ if category_lower == "sports":
180
+ return True
181
+
182
+ return any(kw.lower() in title_lower or kw.lower() in ticker_lower
183
+ for kw in self.SPORTS_KEYWORDS)
184
+
185
+ def _is_crypto_market(self, title: str, ticker: str) -> bool:
186
+ """Check if market is crypto-related"""
187
+ title_lower = title.lower()
188
+ ticker_lower = ticker.lower()
189
+ return any(kw.lower() in title_lower or kw.lower() in ticker_lower
190
+ for kw in self.CRYPTO_KEYWORDS)
191
+
192
+ def fetch_ending_soon(self) -> List[Dict[str, Any]]:
193
+ """Fetch markets ending soon - excludes sports, weather, crypto"""
194
+ print("Fetching markets ending soon...")
195
+
196
+ now = datetime.now(timezone.utc)
197
+ max_close_ts = int(now.timestamp() + (3 * 24 * 60 * 60))
198
+ min_close_ts = int(now.timestamp())
199
+
200
+ params = {
201
+ "status": "open",
202
+ "min_close_ts": min_close_ts,
203
+ "max_close_ts": max_close_ts,
204
+ "limit": 200,
205
+ "mve_filter": "exclude"
206
+ }
207
+
208
+ data = self._api_get("/markets", params)
209
+ markets = data.get("markets", [])
210
+
211
+ # Filter and process (without images)
212
+ valid_markets = []
213
+ event_ticker_to_market = {} # For deduplication by event_ticker
214
+
215
+ for market in markets:
216
+ title = market.get("title", "")
217
+ ticker = market.get("ticker", "")
218
+ category = market.get("category", "")
219
+ event_ticker = market.get("event_ticker", "")
220
+
221
+ if self._is_weather_market(title, ticker):
222
+ continue
223
+ if self._is_sports_market(title, ticker, category):
224
+ continue
225
+ if self._is_crypto_market(title, ticker):
226
+ continue
227
+
228
+ close_time = market.get("close_time", "")
229
+ if not close_time or not event_ticker:
230
+ continue
231
+
232
+ market_data = {
233
+ "title": title,
234
+ "ticker": ticker,
235
+ "event_ticker": event_ticker,
236
+ "slug": event_ticker.lower(),
237
+ "close_time": close_time,
238
+ "time_remaining": calculate_time_remaining(close_time),
239
+ "volume": market.get("volume", 0),
240
+ "volume_24h": market.get("volume_24h", 0),
241
+ "yes_bid": market.get("yes_bid", 0),
242
+ "yes_ask": market.get("yes_ask", 0),
243
+ "last_price": market.get("last_price", 0),
244
+ "liquidity": market.get("liquidity", 0),
245
+ "category": category
246
+ }
247
+
248
+ # Deduplicate by event_ticker - keep the one with highest volume_24h
249
+ if event_ticker not in event_ticker_to_market:
250
+ event_ticker_to_market[event_ticker] = market_data
251
+ else:
252
+ if market_data["volume_24h"] > event_ticker_to_market[event_ticker]["volume_24h"]:
253
+ event_ticker_to_market[event_ticker] = market_data
254
+
255
+ # Convert dict values to list
256
+ valid_markets = list(event_ticker_to_market.values())
257
+
258
+ # Sort by volume_24h (highest first), then by close time (soonest first) as tiebreaker
259
+ valid_markets.sort(key=lambda x: (-x["volume_24h"], x["close_time"]))
260
+
261
+ # Take top N and THEN fetch images
262
+ final_markets = valid_markets[:self.num_items]
263
+ final_markets = self._enrich_with_images(final_markets)
264
+
265
+ print(f" Found {len(valid_markets)} total, returning top {len(final_markets)}")
266
+ return final_markets
267
+
268
+ def fetch_just_listed(self) -> List[Dict[str, Any]]:
269
+ """Fetch recently created markets - excludes sports, weather, crypto"""
270
+ print("Fetching newly listed markets...")
271
+
272
+ now = datetime.now(timezone.utc)
273
+ min_created_ts = int(now.timestamp() - (14 * 24 * 60 * 60))
274
+
275
+ params = {
276
+ "status": "open",
277
+ "min_created_ts": min_created_ts,
278
+ "limit": 200,
279
+ "mve_filter": "exclude"
280
+ }
281
+
282
+ data = self._api_get("/markets", params)
283
+ markets = data.get("markets", [])
284
+
285
+ # Filter and process (without images)
286
+ valid_markets = []
287
+ event_ticker_to_market = {} # For deduplication by event_ticker
288
+
289
+ for market in markets:
290
+ title = market.get("title", "")
291
+ ticker = market.get("ticker", "")
292
+ category = market.get("category", "")
293
+ event_ticker = market.get("event_ticker", "")
294
+
295
+ if self._is_weather_market(title, ticker):
296
+ continue
297
+ if self._is_sports_market(title, ticker, category):
298
+ continue
299
+ if self._is_crypto_market(title, ticker):
300
+ continue
301
+
302
+ created_time = market.get("created_time", "")
303
+ if not event_ticker:
304
+ continue
305
+
306
+ market_data = {
307
+ "title": title,
308
+ "ticker": ticker,
309
+ "event_ticker": event_ticker,
310
+ "slug": event_ticker.lower(),
311
+ "created_time": created_time,
312
+ "time_since": calculate_time_since(created_time),
313
+ "volume": market.get("volume", 0),
314
+ "volume_24h": market.get("volume_24h", 0),
315
+ "yes_bid": market.get("yes_bid", 0),
316
+ "yes_ask": market.get("yes_ask", 0),
317
+ "last_price": market.get("last_price", 0),
318
+ "liquidity": market.get("liquidity", 0),
319
+ "category": category
320
+ }
321
+
322
+ # Deduplicate by event_ticker - keep the one with highest volume_24h
323
+ if event_ticker not in event_ticker_to_market:
324
+ event_ticker_to_market[event_ticker] = market_data
325
+ else:
326
+ if market_data["volume_24h"] > event_ticker_to_market[event_ticker]["volume_24h"]:
327
+ event_ticker_to_market[event_ticker] = market_data
328
+
329
+ # Convert dict values to list
330
+ valid_markets = list(event_ticker_to_market.values())
331
+
332
+ # Sort by volume_24h (highest first), then by created time (newest first) as tiebreaker
333
+ # Sort twice: first by created_time descending (newest first), then by volume_24h descending (stable sort preserves order)
334
+ valid_markets.sort(key=lambda x: x["created_time"], reverse=True)
335
+ valid_markets.sort(key=lambda x: x["volume_24h"], reverse=True)
336
+
337
+ # Take top N and THEN fetch images
338
+ final_markets = valid_markets[:self.num_items]
339
+ final_markets = self._enrich_with_images(final_markets)
340
+
341
+ print(f" Found {len(valid_markets)} total, returning top {len(final_markets)}")
342
+ return final_markets
343
+
344
+ def fetch_biggest_movers(self) -> List[Dict[str, Any]]:
345
+ """
346
+ Fetch markets with the biggest price movements in the last 24h.
347
+ Uses last_price vs previous_price fields from Kalshi API.
348
+ """
349
+ print("Fetching biggest movers (24h price change)...")
350
+
351
+ markets = self._fetch_all_markets(status="open", limit=1000)
352
+
353
+ # Filter and calculate price changes (without images)
354
+ movers = []
355
+ event_ticker_to_market = {} # For deduplication by event_ticker
356
+
357
+ for market in markets:
358
+ title = market.get("title", "")
359
+ ticker = market.get("ticker", "")
360
+ category = market.get("category", "")
361
+ event_ticker = market.get("event_ticker", "")
362
+
363
+ if self._is_weather_market(title, ticker):
364
+ continue
365
+ if self._is_sports_market(title, ticker, category):
366
+ continue
367
+ if self._is_crypto_market(title, ticker):
368
+ continue
369
+
370
+ if not event_ticker:
371
+ continue
372
+
373
+ last_price = market.get("last_price", 0) or 0
374
+ previous_price = market.get("previous_price", 0) or 0
375
+ volume_24h = market.get("volume_24h", 0) or 0
376
+
377
+ if previous_price == 0 or last_price == 0:
378
+ continue
379
+ if volume_24h < 100:
380
+ continue
381
+
382
+ price_change = last_price - previous_price
383
+ price_change_pct = ((last_price - previous_price) / previous_price) * 100
384
+
385
+ if abs(price_change) < 3:
386
+ continue
387
+
388
+ market_data = {
389
+ "title": title,
390
+ "ticker": ticker,
391
+ "event_ticker": event_ticker,
392
+ "slug": event_ticker.lower(),
393
+ "current_price": last_price,
394
+ "previous_price": previous_price,
395
+ "price_change": price_change,
396
+ "price_change_pct": round(price_change_pct, 1),
397
+ "volume_24h": volume_24h,
398
+ "yes_bid": market.get("yes_bid", 0),
399
+ "yes_ask": market.get("yes_ask", 0),
400
+ "category": category
401
+ }
402
+
403
+ # Deduplicate by event_ticker - keep the one with highest absolute price change
404
+ if event_ticker not in event_ticker_to_market:
405
+ event_ticker_to_market[event_ticker] = market_data
406
+ else:
407
+ if abs(market_data["price_change"]) > abs(event_ticker_to_market[event_ticker]["price_change"]):
408
+ event_ticker_to_market[event_ticker] = market_data
409
+
410
+ # Convert dict values to list
411
+ movers = list(event_ticker_to_market.values())
412
+
413
+ # Sort by absolute price change (biggest movers first)
414
+ movers.sort(key=lambda x: abs(x["price_change"]), reverse=True)
415
+
416
+ # Take top N and THEN fetch images
417
+ final_markets = movers[:self.num_items]
418
+ final_markets = self._enrich_with_images(final_markets)
419
+
420
+ print(f" Found {len(movers)} total, returning top {len(final_markets)}")
421
+ return final_markets
422
+
423
+ def fetch_hot_markets(self) -> List[Dict[str, Any]]:
424
+ """Fetch markets with highest 24h volume - excludes weather"""
425
+ print("Fetching hot markets (by 24h volume)...")
426
+
427
+ markets = self._fetch_all_markets(status="open", limit=1000)
428
+
429
+ # Filter by volume (without images)
430
+ hot_markets = []
431
+ event_ticker_to_market = {} # For deduplication by event_ticker
432
+
433
+ for market in markets:
434
+ title = market.get("title", "")
435
+ ticker = market.get("ticker", "")
436
+ category = market.get("category", "")
437
+ event_ticker = market.get("event_ticker", "")
438
+
439
+ if self._is_weather_market(title, ticker):
440
+ continue
441
+
442
+ if not event_ticker:
443
+ continue
444
+
445
+ volume_24h = market.get("volume_24h", 0) or 0
446
+ if volume_24h < 100:
447
+ continue
448
+
449
+ market_data = {
450
+ "title": title,
451
+ "ticker": ticker,
452
+ "event_ticker": event_ticker,
453
+ "slug": event_ticker.lower(),
454
+ "volume_24h": volume_24h,
455
+ "volume": market.get("volume", 0),
456
+ "yes_bid": market.get("yes_bid", 0),
457
+ "yes_ask": market.get("yes_ask", 0),
458
+ "last_price": market.get("last_price", 0),
459
+ "liquidity": market.get("liquidity", 0),
460
+ "category": category
461
+ }
462
+
463
+ # Deduplicate by event_ticker - keep the one with highest volume_24h
464
+ if event_ticker not in event_ticker_to_market:
465
+ event_ticker_to_market[event_ticker] = market_data
466
+ else:
467
+ if market_data["volume_24h"] > event_ticker_to_market[event_ticker]["volume_24h"]:
468
+ event_ticker_to_market[event_ticker] = market_data
469
+
470
+ # Convert dict values to list
471
+ hot_markets = list(event_ticker_to_market.values())
472
+
473
+ # Sort by 24h volume (highest first)
474
+ hot_markets.sort(key=lambda x: x["volume_24h"], reverse=True)
475
+
476
+ # Take top N and THEN fetch images
477
+ final_markets = hot_markets[:self.num_items]
478
+ final_markets = self._enrich_with_images(final_markets)
479
+
480
+ print(f" Found {len(hot_markets)} total, returning top {len(final_markets)}")
481
+ return final_markets
482
+
483
+ def fetch_sports_events(self) -> List[Dict[str, Any]]:
484
+ """Fetch sports-related markets"""
485
+ print("Fetching sports events...")
486
+
487
+ markets = self._fetch_all_markets(status="open", limit=1000)
488
+
489
+ # Filter for sports (without images)
490
+ sports_markets = []
491
+ event_ticker_to_market = {} # For deduplication by event_ticker
492
+
493
+ for market in markets:
494
+ title = market.get("title", "")
495
+ ticker = market.get("ticker", "")
496
+ category = market.get("category", "")
497
+ event_ticker = market.get("event_ticker", "")
498
+
499
+ if not self._is_sports_market(title, ticker, category):
500
+ continue
501
+
502
+ if not event_ticker:
503
+ continue
504
+
505
+ # Determine sport type
506
+ sport = "Sports"
507
+ title_lower = title.lower()
508
+ if any(x in title_lower for x in ["nfl", "football", "packers", "bears", "cowboys", "patriots", "super bowl"]):
509
+ sport = "NFL"
510
+ elif any(x in title_lower for x in ["nba", "basketball", "lakers", "celtics", "warriors"]):
511
+ sport = "NBA"
512
+ elif any(x in title_lower for x in ["mlb", "baseball", "yankees", "dodgers"]):
513
+ sport = "MLB"
514
+ elif any(x in title_lower for x in ["nhl", "hockey"]):
515
+ sport = "NHL"
516
+ elif any(x in title_lower for x in ["ufc", "mma", "fight", "boxing"]):
517
+ sport = "UFC/MMA"
518
+ elif any(x in title_lower for x in ["soccer", "premier league", "champions league", "world cup"]):
519
+ sport = "Soccer"
520
+ elif "golf" in title_lower:
521
+ sport = "Golf"
522
+ elif any(x in title_lower for x in ["tennis", "wimbledon"]):
523
+ sport = "Tennis"
524
+ elif any(x in title_lower for x in ["f1", "racing", "nascar"]):
525
+ sport = "Racing"
526
+
527
+ market_data = {
528
+ "title": title,
529
+ "ticker": ticker,
530
+ "event_ticker": event_ticker,
531
+ "slug": event_ticker.lower(),
532
+ "close_time": market.get("close_time", ""),
533
+ "time_remaining": calculate_time_remaining(market.get("close_time", "")),
534
+ "volume": market.get("volume", 0),
535
+ "volume_24h": market.get("volume_24h", 0),
536
+ "yes_bid": market.get("yes_bid", 0),
537
+ "yes_ask": market.get("yes_ask", 0),
538
+ "last_price": market.get("last_price", 0),
539
+ "sport": sport
540
+ }
541
+
542
+ # Deduplicate by event_ticker - keep the one with highest volume_24h
543
+ if event_ticker not in event_ticker_to_market:
544
+ event_ticker_to_market[event_ticker] = market_data
545
+ else:
546
+ if market_data["volume_24h"] > event_ticker_to_market[event_ticker]["volume_24h"]:
547
+ event_ticker_to_market[event_ticker] = market_data
548
+
549
+ # Convert dict values to list
550
+ sports_markets = list(event_ticker_to_market.values())
551
+
552
+ # Sort by 24h volume (most active first)
553
+ sports_markets.sort(key=lambda x: x.get("volume_24h", 0), reverse=True)
554
+
555
+ # Take top N and THEN fetch images
556
+ final_markets = sports_markets[:self.num_items]
557
+ final_markets = self._enrich_with_images(final_markets)
558
+
559
+ print(f" Found {len(sports_markets)} total, returning top {len(final_markets)}")
560
+ return final_markets
561
+
562
+ def display_menu(self) -> None:
563
+ """Display the menu of available markets"""
564
+ self.build_menu(verbose=True)
565
+
566
+ def build_menu(self, verbose: bool = False) -> List[Dict[str, Any]]:
567
+ """Populate structured menu entries and optionally print them."""
568
+ sections = [
569
+ ("ending_soon", "Ending Soon", self.fetch_ending_soon, "⏰"),
570
+ ("just_listed", "New", self.fetch_just_listed, "🆕"),
571
+ ("biggest_movers", "Biggest Movers", self.fetch_biggest_movers, "📈"),
572
+ ("hot_markets", "Hot Markets", self.fetch_hot_markets, "🔥"),
573
+ ("sports_events", "Top Sports", self.fetch_sports_events, "🏆"),
574
+ ]
575
+
576
+ if verbose:
577
+ print("\n" + "=" * 60)
578
+ print("KALSHI DATA MENU")
579
+ print("=" * 60)
580
+
581
+ menu_entries: List[Dict[str, Any]] = []
582
+ counter = 1
583
+ self.menu_items = {}
584
+
585
+ for key, label, fetcher, emoji in sections:
586
+ items = fetcher()
587
+
588
+ if verbose:
589
+ print(f"\n{emoji} {label.upper()}")
590
+ print("-" * 40)
591
+
592
+ for item in items:
593
+ self.menu_items[counter] = (key, item)
594
+ url = f"https://kalshi.com/markets/{item.get('event_ticker', '').lower()}"
595
+
596
+ menu_entries.append({
597
+ "id": counter,
598
+ "category": key,
599
+ "label": label,
600
+ "emoji": emoji,
601
+ "data": item,
602
+ "url": url,
603
+ })
604
+
605
+ if verbose:
606
+ title = item.get("title", "Untitled")
607
+ print(f"{counter}. {title}")
608
+
609
+ if key == "biggest_movers":
610
+ change = item.get("price_change", 0)
611
+ change_pct = item.get("price_change_pct", 0)
612
+ sign = "+" if change > 0 else ""
613
+ print(f" {sign}{change}¢ ({sign}{change_pct}%) | Vol 24h: {item.get('volume_24h', 0):,}")
614
+ elif key == "ending_soon":
615
+ print(f" Ends: {item.get('time_remaining', 'Soon')} | Price: {item.get('last_price', 0)}¢")
616
+ elif key == "just_listed":
617
+ print(f" Listed: {item.get('time_since', 'Recently')} | Price: {item.get('last_price', 0)}¢")
618
+ elif key == "hot_markets":
619
+ print(f" Vol 24h: {item.get('volume_24h', 0):,} | Price: {item.get('last_price', 0)}¢")
620
+ elif key == "sports_events":
621
+ print(f" {item.get('sport', 'Sports')} | {item.get('time_remaining', 'Soon')}")
622
+
623
+ counter += 1
624
+
625
+ if verbose:
626
+ print("\n" + "=" * 60)
627
+
628
+ return menu_entries
629
+
630
+ def get_user_selections(self) -> List[tuple]:
631
+ """Get user's market selections with optional 's' suffix for sports section"""
632
+ print("\n>>> Enter Your Selections (comma-separated numbers):")
633
+ print(" Example: 2, 3, 4, 9, 10, 35")
634
+ print(" Add 's' to move to Sports section: 21s, 25s")
635
+ print(" Or press Enter to skip selection")
636
+ print(" Or input 'z' to go back\n")
637
+
638
+ user_input = input(">>> ").strip()
639
+
640
+ if user_input.lower() == 'z':
641
+ return 'BACK'
642
+
643
+ if not user_input:
644
+ return []
645
+
646
+ try:
647
+ valid_selections = []
648
+ for item in user_input.split(','):
649
+ item = item.strip()
650
+
651
+ force_sports = False
652
+ if item.endswith('s') or item.endswith('S'):
653
+ force_sports = True
654
+ item = item[:-1]
655
+
656
+ sel = int(item)
657
+ if sel in self.menu_items:
658
+ valid_selections.append((sel, force_sports))
659
+ else:
660
+ print(f"Warning: Selection {sel} is invalid (out of range)")
661
+
662
+ return valid_selections
663
+ except ValueError:
664
+ print("Error: Invalid input. Please enter comma-separated numbers.")
665
+ return self.get_user_selections()
666
+
667
+ def organize_selections(self, selections: List[tuple]) -> Dict[str, List[Dict]]:
668
+ """Organize selected markets by category with sports override support"""
669
+ organized = defaultdict(list)
670
+
671
+ for item in selections:
672
+ if isinstance(item, tuple):
673
+ sel, force_sports = item
674
+ else:
675
+ sel = item
676
+ force_sports = False
677
+
678
+ category, market_data = self.menu_items[sel]
679
+
680
+ if force_sports and category != "sports_events":
681
+ print(f" Moving item {sel} from {category} to sports section")
682
+ category = "sports_events"
683
+
684
+ organized[category].append(market_data)
685
+
686
+ return organized
687
+
688
+ def export_json(self, filename: str = "kalshi_data.json") -> None:
689
+ """Export all fetched data to JSON"""
690
+ menu_entries = self.build_menu(verbose=False)
691
+
692
+ output = {
693
+ "generated_at": datetime.now(timezone.utc).isoformat(),
694
+ "source": "Kalshi API",
695
+ "sections": {}
696
+ }
697
+
698
+ for entry in menu_entries:
699
+ category = entry["category"]
700
+ if category not in output["sections"]:
701
+ output["sections"][category] = {
702
+ "label": entry["label"],
703
+ "emoji": entry["emoji"],
704
+ "items": []
705
+ }
706
+ output["sections"][category]["items"].append(entry["data"])
707
+
708
+ with open(filename, 'w') as f:
709
+ json.dump(output, f, indent=2)
710
+
711
+ print(f"\n✅ Data exported to {filename}")
712
+
713
+ def run(self):
714
+ """Main execution flow"""
715
+ print("\nStarting Kalshi Data Collection")
716
+ print(f" Items per section: {self.num_items}")
717
+
718
+ self.display_menu()
719
+ selections = self.get_user_selections()
720
+
721
+ if selections == 'BACK':
722
+ print("\n⚠️ Can't go back - this is the first step")
723
+ return
724
+
725
+ if not selections:
726
+ print("\nWarning: No selections made. Exiting.")
727
+ return
728
+
729
+ print(f"\nSelected items: {[s[0] if isinstance(s, tuple) else s for s in selections]}")
730
+ organized = self.organize_selections(selections)
731
+
732
+ print("\n✅ Data collection complete!")
733
+ print(f" Total sections: {len(organized)}")
734
+ print(f" Total items: {len(selections)}")
735
+
736
+ for category, items in organized.items():
737
+ print(f" • {category}: {len(items)} items")
738
+
739
+ print(f"\n🎉 Done! Data is ready for use.")
740
+
741
+
742
+ def main():
743
+ """Main entry point"""
744
+ parser = argparse.ArgumentParser(description='Collect Kalshi Market Data')
745
+ parser.add_argument(
746
+ 'num_items',
747
+ type=int,
748
+ nargs='?',
749
+ default=10,
750
+ help='Number of items per section (default: 10)'
751
+ )
752
+ parser.add_argument(
753
+ '--selections',
754
+ type=str,
755
+ help='Comma-separated list of selections (e.g., "1,3,5,7,10")'
756
+ )
757
+ parser.add_argument(
758
+ '--export',
759
+ type=str,
760
+ help='Export all data to JSON file (e.g., --export data.json)'
761
+ )
762
+ parser.add_argument(
763
+ '--no-interactive',
764
+ action='store_true',
765
+ help='Run without interactive selection (use with --export)'
766
+ )
767
+
768
+ args = parser.parse_args()
769
+
770
+ if args.num_items < 1:
771
+ print("Error: Number of items must be at least 1")
772
+ sys.exit(1)
773
+
774
+ if args.num_items > 50:
775
+ print("Warning: Large number of items may slow down API requests")
776
+
777
+ generator = KalshiDataGenerator(num_items=args.num_items)
778
+
779
+ if args.export:
780
+ generator.export_json(args.export)
781
+ return
782
+
783
+ if args.selections:
784
+ generator.display_menu()
785
+
786
+ valid_selections = []
787
+ for item in args.selections.split(','):
788
+ item = item.strip()
789
+ force_sports = False
790
+ if item.endswith('s') or item.endswith('S'):
791
+ force_sports = True
792
+ item = item[:-1]
793
+
794
+ try:
795
+ sel = int(item)
796
+ if sel in generator.menu_items:
797
+ valid_selections.append((sel, force_sports))
798
+ else:
799
+ print(f"Warning: Selection {sel} is invalid")
800
+ except ValueError:
801
+ print(f"Warning: Invalid selection '{item}'")
802
+
803
+ if valid_selections:
804
+ print(f"\nUsing command-line selections: {[s[0] for s in valid_selections]}")
805
+ organized = generator.organize_selections(valid_selections)
806
+
807
+ print("\n✅ Data collection complete!")
808
+ print(f" Total sections: {len(organized)}")
809
+ print(f" Total items: {len(valid_selections)}")
810
+ for category, items in organized.items():
811
+ print(f" • {category}: {len(items)} items")
812
+ return
813
+
814
+ if not args.no_interactive:
815
+ generator.run()
816
+
817
+
818
+ if __name__ == "__main__":
819
+ main()
app/main.py CHANGED
@@ -16,7 +16,7 @@ from .post_process import convert_polymarket_links, minify_html
16
  from .schemas import TemplateifyResponse
17
  from .template_manager import TemplateManager
18
  from .templateify_new_service import TemplateifyNewService
19
- from .email3 import PolymarketEmailGenerator
20
 
21
  logger = logging.getLogger("template-manager")
22
 
@@ -378,32 +378,29 @@ async def post_note_to_substack(
378
  async def fetch_menu_data(
379
  request: dict = Body(...),
380
  ) -> dict:
381
- """Fetch menu data from Polymarket APIs using curl_cffi.
382
 
383
  Request body:
384
  num_items: Number of items per section (default: 10)
385
- skip_dub: Skip creating Dub tracking links (default: False)
386
  """
387
  try:
388
  num_items = request.get("num_items", 10)
389
- skip_dub = request.get("skip_dub", False)
390
 
391
- logger.info(f"Fetching menu data (num_items={num_items}, skip_dub={skip_dub})...")
392
 
393
  # Create generator instance
394
- generator = PolymarketEmailGenerator(
395
- num_items=num_items,
396
- skip_dub=skip_dub,
397
- )
398
 
399
- # Build menu (this fetches all data including breaking news)
400
  logger.info("Building menu data...")
401
- menu_entries = generator.build_menu(verbose=True)
402
 
403
  # Format as MenuResponse structure
404
- from datetime import datetime
405
  menu_response = {
406
- "generated_at": datetime.utcnow().isoformat(),
 
 
407
  "items": menu_entries,
408
  }
409
 
 
16
  from .schemas import TemplateifyResponse
17
  from .template_manager import TemplateManager
18
  from .templateify_new_service import TemplateifyNewService
19
+ from .kalshi_data import KalshiDataGenerator
20
 
21
  logger = logging.getLogger("template-manager")
22
 
 
378
  async def fetch_menu_data(
379
  request: dict = Body(...),
380
  ) -> dict:
381
+ """Fetch menu data from Kalshi API.
382
 
383
  Request body:
384
  num_items: Number of items per section (default: 10)
 
385
  """
386
  try:
387
  num_items = request.get("num_items", 10)
 
388
 
389
+ logger.info(f"Fetching menu data from Kalshi API (num_items={num_items})...")
390
 
391
  # Create generator instance
392
+ generator = KalshiDataGenerator(num_items=num_items)
 
 
 
393
 
394
+ # Build menu (this fetches all data)
395
  logger.info("Building menu data...")
396
+ menu_entries = generator.build_menu(verbose=False)
397
 
398
  # Format as MenuResponse structure
399
+ from datetime import datetime, timezone
400
  menu_response = {
401
+ "generated_at": datetime.now(timezone.utc).isoformat(),
402
+ "source": "Kalshi API",
403
+ "api_base": "https://api.elections.kalshi.com/trade-api/v2",
404
  "items": menu_entries,
405
  }
406
 
app/mini_converter.py CHANGED
@@ -32,7 +32,7 @@ class EmailConverter:
32
  def __init__(self, settings: Settings):
33
  self.settings = settings
34
  self.template_dir = Path(settings.template_root).resolve()
35
- self.supabase_base_url = "https://tyknilsxnmoujlfwpdge.supabase.co/storage/v1/object/public/Polygrapher"
36
  self.header_image_width = None # Will be set when processing header image
37
 
38
  # Initialize Cloudinary if configured
 
32
  def __init__(self, settings: Settings):
33
  self.settings = settings
34
  self.template_dir = Path(settings.template_root).resolve()
35
+ self.supabase_base_url = "https://tyknilsxnmoujlfwpdge.supabase.co/storage/v1/object/public/Kalshifier"
36
  self.header_image_width = None # Will be set when processing header image
37
 
38
  # Initialize Cloudinary if configured
app/storage_client.py CHANGED
@@ -11,7 +11,7 @@ class TemplateStorageClient:
11
  def __init__(self, url: Optional[str], key: Optional[str], prefix: str = "") -> None:
12
  self.url = url
13
  self.key = key
14
- self.bucket = "Polygrapher"
15
  self.logger = logging.getLogger("template-storage")
16
 
17
  @property
 
11
  def __init__(self, url: Optional[str], key: Optional[str], prefix: str = "") -> None:
12
  self.url = url
13
  self.key = key
14
+ self.bucket = "Kalshifier"
15
  self.logger = logging.getLogger("template-storage")
16
 
17
  @property