polymarket-browse: add --search, --telegram, --matches-only, --non-matches-only flags; fix partial fetch warnings; clean up output formatting
This commit is contained in:
@@ -12,73 +12,74 @@ Browse tradeable Polymarket prediction market events by game category.
|
|||||||
|
|
||||||
**For Hermes Agent users:**
|
**For Hermes Agent users:**
|
||||||
```bash
|
```bash
|
||||||
hermes skills install https://git.fbrns.co/shoko/jujutsu-skills#polymarket-browse
|
hermes skills install https://github.com/shokollm/jujutsu-skills#polymarket-browse
|
||||||
```
|
```
|
||||||
|
|
||||||
**For OpenClaw users:**
|
**For OpenClaw users:**
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
# Clone the repo
|
||||||
git clone https://git.fbrns.co/shoko/jujutsu-skills.git ~/jujutsu-skills
|
git clone https://github.com/shokollm/jujutsu-skills.git ~/jujutsu-skills
|
||||||
|
|
||||||
# Copy skill to your OpenClaw skills folder
|
# Copy skill to your OpenClaw skills folder
|
||||||
cp -r ~/jujutsu-skills/skills/polymarket-browse ~/.openclaw/skills/
|
cp -r ~/jujutsu-skills/skills/polymarket-browse ~/.openclaw/skills/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Manual installation:**
|
**Optional: For better experience, install the Polymarket MCP server:**
|
||||||
|
This lets the agent answer questions about Polymarket markets, rules, and trading mechanics.
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
# Ask your agent to install it for you, or add to config.yaml:
|
||||||
git clone https://git.fbrns.co/shoko/jujutsu-skills.git ~/jujutsu-skills
|
hermes mcp add polymarket https://docs.polymarket.com/mcp
|
||||||
|
|
||||||
# Copy skill to your Hermes skills folder
|
|
||||||
cp -r ~/jujutsu-skills/skills/polymarket-browse ~/.hermes/skills/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
polymarket-browse [--category "Counter Strike"] [--limit 5] [--detail N]
|
polymarket-browse [--category "Counter Strike"] [--limit 5] [--matches N] [--non-matches N] [--search "TeamName"] [--matches-only] [--non-matches-only] [--detail N] [--raw] [--telegram]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Arguments
|
## Arguments
|
||||||
|
|
||||||
- `--category` : Game category to browse. Options: All Esports, Counter Strike, League of Legends, Dota 2, Valorant, NBA, NFL, UFC, Tennis (default: Counter Strike)
|
- `--category` : Game category to browse. Options: All Esports, Counter Strike, League of Legends, Dota 2, Valorant, NBA, NFL, UFC, Tennis (default: Counter Strike)
|
||||||
- `--limit` : Max number of events to show (default: 5)
|
- `--limit` : Max events per section (match + non-match). Default: 5
|
||||||
- `--detail` : Index of event (1-indexed) to show detailed markets for. Defaults to 1 (first event). Set to 0 to disable.
|
- `--matches` : Max match markets to show. Default: --limit
|
||||||
|
- `--non-matches` : Max non-match markets to show. Default: --limit
|
||||||
|
- `--search` : Free-text team/term search within the selected category. Appends to the category query. Example: `--category "Counter Strike" --search "FlyQuest"`
|
||||||
|
- `--matches-only` : Show only match markets (suppress non-match section).
|
||||||
|
- `--non-matches-only` : Show only non-match markets (suppress match section).
|
||||||
|
- `--detail` : Index of match event (1-indexed) to show detailed markets. Default: 1. Set to 0 to disable.
|
||||||
- `--list-categories` : List available game categories and exit
|
- `--list-categories` : List available game categories and exit
|
||||||
|
- `--raw` : Show all events without tradeable filter (for debugging). Includes fetch stats.
|
||||||
|
- `--telegram` : Send results to Telegram. Requires `BOT_TOKEN` and `CHAT_ID` in environment variables.
|
||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
|
|
||||||
Each event displays in a **6-line format**:
|
Output is split into two sections:
|
||||||
|
|
||||||
```
|
```
|
||||||
=== DOTA 2 ===
|
=== COUNTER STRIKE ===
|
||||||
Query: 'Dota2' | Total API: 54 | Tradeable: 3
|
Current time (WIB): 17:00 WIB | Mar 24, 2026
|
||||||
Current time (WIB): 13:58 WIB | Mar 24, 2026
|
|
||||||
|
|
||||||
1. [Dota 2: Yakult Brothers vs BetBoom Team (BO2)](https://polymarket.com/market/...)
|
MATCH MARKETS
|
||||||
Mar 24, 19:00 WIB | In 5h
|
|
||||||
|
|
||||||
Vol: $4,952
|
1. [Counter-Strike: TheMongolz vs Spirit (BO3)](https://polymarket.com/market/...)
|
||||||
Tournament: ESL One Birmingham Group A
|
Mar 24, 03:45 WIB | LIVE
|
||||||
Odds: Yakult Brothers 29c | 71c BetBoom Team
|
Vol: $2,626,029
|
||||||
|
Tournament: BLAST Open Rotterdam Group B
|
||||||
|
Odds: TheMongolz 100c | 0c Spirit
|
||||||
|
|
||||||
2. [Dota 2: GamerLegion vs Team Yandex (BO2)](https://polymarket.com/market/...)
|
NON-MATCH MARKETS
|
||||||
Mar 24, 19:00 WIB | In 5h
|
|
||||||
|
|
||||||
Vol: $3,944
|
1. [Blast Open Rotterdam 2026: Winner](https://polymarket.com/event/...)
|
||||||
Tournament: ESL One Birmingham Group A
|
Feb 28, 04:43 WIB | 24d ago
|
||||||
Odds: GamerLegion 28c | 72c Team Yandex
|
Markets: 17 | Total Vol: $958,116
|
||||||
```
|
```
|
||||||
|
|
||||||
Format per event:
|
**Match Markets** are actual head-to-head matches with moneyline odds (sorted by volume).
|
||||||
- **Line 1**: `[number]. [Title](url)` — title without tournament name
|
**Non-match Markets** are tournament futures, props, and other markets without direct match odds.
|
||||||
- **Line 2**: `Date, Time WIB | Relative` (e.g., "Mar 24, 19:00 WIB | In 5h")
|
|
||||||
- **Line 3**: (empty separator)
|
|
||||||
- **Line 4**: `Vol: $XXX`
|
|
||||||
- **Line 5**: `Tournament: [tournament name]`
|
|
||||||
- **Line 6**: `Odds: [Team A] [Odds A] | [Odds B] [Team B]`
|
|
||||||
|
|
||||||
Time shows in WIB (UTC+7 for Indonesian users). Relative time shows "In Xh", "Xd ago", or "LIVE".
|
Stats line (Fetched / Total / Match counts) only shown when `--raw` flag is used.
|
||||||
|
|
||||||
|
If a fetch is interrupted (API error/timeout), a `WARNING: Partial fetch` line appears showing data may be incomplete.
|
||||||
|
|
||||||
## Game Categories
|
## Game Categories
|
||||||
|
|
||||||
@@ -96,7 +97,13 @@ Time shows in WIB (UTC+7 for Indonesian users). Relative time shows "In Xh", "Xd
|
|||||||
|
|
||||||
## Filters Applied
|
## Filters Applied
|
||||||
|
|
||||||
Events are filtered to show only **tradeable** matches:
|
The script classifies every event into one of two categories:
|
||||||
|
|
||||||
|
**Match Markets**: Events that are actual head-to-head matches (have `seriesSlug` + `gameId`, OR title contains " vs ").
|
||||||
|
|
||||||
|
**Non-match Markets**: Everything else — tournament futures, prop bets, player props, etc.
|
||||||
|
|
||||||
|
Tradeable match markets additionally require:
|
||||||
- Must have seriesSlug + gameId (actual match, not tournament future)
|
- Must have seriesSlug + gameId (actual match, not tournament future)
|
||||||
- ML volume > 0
|
- ML volume > 0
|
||||||
- acceptingOrders = true
|
- acceptingOrders = true
|
||||||
@@ -105,22 +112,19 @@ Events are filtered to show only **tradeable** matches:
|
|||||||
- ML bestAsk > 0.01 (market hasn't converged toward underdog)
|
- ML bestAsk > 0.01 (market hasn't converged toward underdog)
|
||||||
- BO2 matches that ended in a tie (1-1) are filtered out
|
- BO2 matches that ended in a tie (1-1) are filtered out
|
||||||
- Event endDate has not passed (API doesn't always close ML market promptly)
|
- Event endDate has not passed (API doesn't always close ML market promptly)
|
||||||
|
- Match startTime is not more than 4 hours in the past (matches that already ended are filtered out)
|
||||||
|
|
||||||
Markets are filtered to only tradeable ones:
|
Use `--raw` to disable the tradeable filter and see all match markets regardless of volume or open orders.
|
||||||
- acceptingOrders = true
|
|
||||||
- bestBid < 0.99
|
## Pagination
|
||||||
- bestAsk > 0.01
|
|
||||||
- closed = false
|
The script fetches **ALL pages** until the API runs out of results (up to 100 pages as a safety cap).
|
||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
|
|
||||||
- Exponential backoff: 2s → 4s → 8s → 16s → 32s
|
- Exponential backoff: 2s → 4s → 8s → 16s → 32s
|
||||||
- Max 5 retries before aborting
|
- Max 5 retries before aborting
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
|
|
||||||
- **BO2 matches that end in a tie (1-1)**: Filtered out by checking if all child_moneyline markets are closed.
|
|
||||||
|
|
||||||
## Odds Format
|
## Odds Format
|
||||||
|
|
||||||
All odds are shown in **cents** format:
|
All odds are shown in **cents** format:
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ from datetime import datetime, timezone, timedelta
|
|||||||
# CONFIG
|
# CONFIG
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
FETCH_PAGES = 4
|
PAGE_SIZE = 50
|
||||||
PAGE_SIZE = 5
|
|
||||||
DISPLAY_MAX = 10
|
|
||||||
MAX_RETRIES = 5
|
MAX_RETRIES = 5
|
||||||
INITIAL_RETRY_DELAY = 2 # exponential backoff starts at 2s
|
INITIAL_RETRY_DELAY = 2 # exponential backoff starts at 2s
|
||||||
|
|
||||||
@@ -66,20 +64,29 @@ def fetch_page(q, page=1, max_retries=MAX_RETRIES, initial_delay=INITIAL_RETRY_D
|
|||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def fetch_all_pages(q, num_pages=FETCH_PAGES):
|
def fetch_all_pages(q, max_pages=100):
|
||||||
|
"""
|
||||||
|
Fetch ALL pages until pagination ends.
|
||||||
|
max_pages is a safety cap to prevent infinite loops.
|
||||||
|
"""
|
||||||
all_events = []
|
all_events = []
|
||||||
total_raw = 0
|
total_raw = 0
|
||||||
for page in range(1, num_pages + 1):
|
for page in range(1, max_pages + 1):
|
||||||
time.sleep(1)
|
time.sleep(0.2) # small delay between pages (API rate limit is generous)
|
||||||
data = fetch_page(q, page)
|
data = fetch_page(q, page)
|
||||||
if data is None:
|
if data is None:
|
||||||
break
|
break
|
||||||
events = data.get("events", [])
|
events = data.get("events", [])
|
||||||
total_raw = data.get("pagination", {}).get("totalResults", "?")
|
total_raw = data.get("pagination", {}).get("totalResults", 0)
|
||||||
all_events.extend(events)
|
all_events.extend(events)
|
||||||
if len(events) < PAGE_SIZE:
|
# Stop when we get 0 events (no more pages),
|
||||||
|
# OR when we've fetched >= total results
|
||||||
|
if len(events) == 0:
|
||||||
break
|
break
|
||||||
return {"events": all_events, "total_raw": total_raw}
|
if len(all_events) >= total_raw:
|
||||||
|
break
|
||||||
|
partial = (total_raw > 0 and len(all_events) < total_raw)
|
||||||
|
return {"events": all_events, "total_raw": total_raw, "partial": partial}
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# FILTERS
|
# FILTERS
|
||||||
@@ -88,6 +95,16 @@ def fetch_all_pages(q, num_pages=FETCH_PAGES):
|
|||||||
def is_match_market(e):
|
def is_match_market(e):
|
||||||
return (e.get("seriesSlug") and e.get("gameId")) or " vs " in e.get("title", "")
|
return (e.get("seriesSlug") and e.get("gameId")) or " vs " in e.get("title", "")
|
||||||
|
|
||||||
|
def get_event_url(e):
|
||||||
|
"""Return the correct Polymarket URL for an event.
|
||||||
|
Match markets use /market/, non-match events use /event/.
|
||||||
|
"""
|
||||||
|
slug = e.get("slug", "")
|
||||||
|
if is_match_market(e):
|
||||||
|
return f"https://polymarket.com/market/{slug}"
|
||||||
|
else:
|
||||||
|
return f"https://polymarket.com/event/{slug}"
|
||||||
|
|
||||||
def get_ml_market(e):
|
def get_ml_market(e):
|
||||||
for m in e.get("markets", []):
|
for m in e.get("markets", []):
|
||||||
if m.get("sportsMarketType") == "moneyline":
|
if m.get("sportsMarketType") == "moneyline":
|
||||||
@@ -152,6 +169,20 @@ def is_tradeable_event(e):
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Filter: match has already started (startTime is in the past)
|
||||||
|
start_str = e.get("startTime") or e.get("startDate", "")
|
||||||
|
if start_str:
|
||||||
|
try:
|
||||||
|
start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if start_dt < now:
|
||||||
|
# Check if it's recently started (within 4h) — consider those "live" still
|
||||||
|
hours_ago = (now - start_dt).total_seconds() / 3600
|
||||||
|
if hours_ago > 4:
|
||||||
|
return False
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_tradeable_market(m):
|
def is_tradeable_market(m):
|
||||||
@@ -273,8 +304,22 @@ def get_match_time_str(e):
|
|||||||
except:
|
except:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def filter_events(events):
|
def filter_events(events, tradeable_only=True):
|
||||||
return [e for e in events if is_match_market(e) and is_tradeable_event(e)]
|
"""
|
||||||
|
Classify events into match_markets and non_match_markets.
|
||||||
|
If tradeable_only=True, also filter out non-tradeable events.
|
||||||
|
"""
|
||||||
|
match_events = []
|
||||||
|
non_match_events = []
|
||||||
|
|
||||||
|
for e in events:
|
||||||
|
if is_match_market(e):
|
||||||
|
if not tradeable_only or is_tradeable_event(e):
|
||||||
|
match_events.append(e)
|
||||||
|
else:
|
||||||
|
non_match_events.append(e)
|
||||||
|
|
||||||
|
return match_events, non_match_events
|
||||||
|
|
||||||
def sort_events(events):
|
def sort_events(events):
|
||||||
return sorted(events, key=get_ml_volume, reverse=True)
|
return sorted(events, key=get_ml_volume, reverse=True)
|
||||||
@@ -283,16 +328,20 @@ def sort_events(events):
|
|||||||
# BROWSE
|
# BROWSE
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def browse_events(q, display_max=DISPLAY_MAX):
|
def browse_events(q, matches_max=10, non_matches_max=10, tradeable_only=True):
|
||||||
result = fetch_all_pages(q, FETCH_PAGES)
|
result = fetch_all_pages(q)
|
||||||
events = result["events"]
|
events = result["events"]
|
||||||
filtered = filter_events(events)
|
match_events, non_match_events = filter_events(events, tradeable_only)
|
||||||
sorted_events = sort_events(filtered)
|
sorted_match = sort_events(match_events)
|
||||||
return {
|
return {
|
||||||
"query": q,
|
"query": q,
|
||||||
"total_raw": result["total_raw"],
|
"total_raw": result["total_raw"],
|
||||||
"total_filtered": len(filtered),
|
"total_fetched": len(events),
|
||||||
"events": sorted_events[:display_max],
|
"total_match": len(match_events),
|
||||||
|
"total_non_match": len(non_match_events),
|
||||||
|
"match_events": sorted_match[:matches_max],
|
||||||
|
"non_match_events": non_match_events[:non_matches_max],
|
||||||
|
"partial": result.get("partial", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -312,7 +361,7 @@ def format_event(e):
|
|||||||
"title": e.get("title", ""),
|
"title": e.get("title", ""),
|
||||||
"time_status": time_status,
|
"time_status": time_status,
|
||||||
"time_urgency": urgency,
|
"time_urgency": urgency,
|
||||||
"url": f"https://polymarket.com/market/{e.get('slug')}",
|
"url": get_event_url(e),
|
||||||
"livestream": e.get("resolutionSource"),
|
"livestream": e.get("resolutionSource"),
|
||||||
"outcomes": outcomes,
|
"outcomes": outcomes,
|
||||||
"prices": prices,
|
"prices": prices,
|
||||||
@@ -335,7 +384,7 @@ def format_detail_event(e):
|
|||||||
return {
|
return {
|
||||||
"title": e.get("title", ""),
|
"title": e.get("title", ""),
|
||||||
"time_status": time_status,
|
"time_status": time_status,
|
||||||
"url": f"https://polymarket.com/market/{e.get('slug')}",
|
"url": get_event_url(e),
|
||||||
"livestream": e.get("resolutionSource"),
|
"livestream": e.get("resolutionSource"),
|
||||||
"outcomes": json.loads(ml.get("outcomes", "[]")) if ml else [],
|
"outcomes": json.loads(ml.get("outcomes", "[]")) if ml else [],
|
||||||
"prices": json.loads(ml.get("outcomePrices", "[]")) if ml else [],
|
"prices": json.loads(ml.get("outcomePrices", "[]")) if ml else [],
|
||||||
@@ -389,8 +438,8 @@ def get_start_time_wib(e):
|
|||||||
else:
|
else:
|
||||||
hours_until = delta.total_seconds() / 3600
|
hours_until = delta.total_seconds() / 3600
|
||||||
if hours_until < 1:
|
if hours_until < 1:
|
||||||
mins = int(delta.total_seconds() / 60)
|
mins_until = int(delta.total_seconds() / 60)
|
||||||
rel_str = f"In {mins}m"
|
rel_str = f"In {mins_until}m"
|
||||||
elif hours_until < 24:
|
elif hours_until < 24:
|
||||||
rel_str = f"In {int(hours_until)}h"
|
rel_str = f"In {int(hours_until)}h"
|
||||||
else:
|
else:
|
||||||
@@ -416,62 +465,82 @@ def get_tournament(title):
|
|||||||
return " - ".join(parts[1:]).strip()
|
return " - ".join(parts[1:]).strip()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def print_browse(events, category, total_raw, total_filtered):
|
def print_browse(match_events, non_match_events, category, total_raw, total_fetched, total_match, total_non_match, raw_mode=False, partial=False, non_matches_max=5, matches_only=False, non_matches_only=False):
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
now_utc = datetime.now(timezone.utc)
|
now_utc = datetime.now(timezone.utc)
|
||||||
utc7 = timezone(timedelta(hours=7))
|
utc7 = timezone(timedelta(hours=7))
|
||||||
now_utc7 = now_utc.astimezone(utc7)
|
now_utc7 = now_utc.astimezone(utc7)
|
||||||
header_date = get_header_date()
|
header_date = get_header_date()
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
print(f"\n=== {category.upper()}{' [RAW]' if raw_mode else ''} ===")
|
||||||
print(f"=== {category.upper()} ===")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
print(f"Query: '{GAME_CATEGORIES[category]}' | Total API: {total_raw} | Tradeable: {total_filtered}")
|
|
||||||
print(f"Current time (WIB): {now_utc7.strftime('%H:%M WIB')} | {header_date}")
|
print(f"Current time (WIB): {now_utc7.strftime('%H:%M WIB')} | {header_date}")
|
||||||
|
|
||||||
if not events:
|
if raw_mode:
|
||||||
print(" No tradeable events found.")
|
print(f"Fetched: {total_fetched} / Total API: {total_raw} | Match: {total_match} | Non-match: {total_non_match}")
|
||||||
return
|
if partial:
|
||||||
|
print(f"WARNING: Partial fetch (API error or timeout) — data may be incomplete")
|
||||||
|
|
||||||
for i, e in enumerate(events, 1):
|
# --- MATCH MARKETS ---
|
||||||
f = format_event(e)
|
if not matches_only and not non_matches_only:
|
||||||
ml = get_ml_market(e)
|
# Default: show both
|
||||||
outcomes = json.loads(ml.get("outcomes", "[]")) if ml else []
|
show_matches = True
|
||||||
prices = json.loads(ml.get("outcomePrices", "[]")) if ml else []
|
show_non_matches = True
|
||||||
vol = f["volume"]
|
elif matches_only:
|
||||||
title = f["title"]
|
show_matches = True
|
||||||
url = f["url"]
|
show_non_matches = False
|
||||||
start_time_wib, rel_time = get_start_time_wib(e)
|
else:
|
||||||
|
show_matches = False
|
||||||
|
show_non_matches = True
|
||||||
|
|
||||||
team_a = outcomes[0] if len(outcomes) > 0 else "?"
|
if show_matches:
|
||||||
team_b = outcomes[1] if len(outcomes) > 1 else "?"
|
print(f"\nMATCH MARKETS")
|
||||||
odds_a = format_odds(float(prices[0])) if len(prices) > 0 else "?"
|
if not match_events:
|
||||||
odds_b = format_odds(float(prices[1])) if len(prices) > 1 else "?"
|
print(" No match markets found.")
|
||||||
|
|
||||||
# Extract title without tournament for line 1
|
|
||||||
# Title format: "Category: TeamA vs TeamB (BO/X) - Tournament"
|
|
||||||
# We want: "Category: TeamA vs TeamB (BO/X)"
|
|
||||||
if " - " in title:
|
|
||||||
title_clean = title.split(" - ")[0].strip()
|
|
||||||
else:
|
else:
|
||||||
title_clean = title
|
for i, e in enumerate(match_events, 1):
|
||||||
|
f = format_event(e)
|
||||||
|
ml = get_ml_market(e)
|
||||||
|
outcomes = json.loads(ml.get("outcomes", "[]")) if ml else []
|
||||||
|
prices = json.loads(ml.get("outcomePrices", "[]")) if ml else []
|
||||||
|
vol = f["volume"]
|
||||||
|
title = f["title"]
|
||||||
|
url = f["url"]
|
||||||
|
start_time_wib, rel_time = get_start_time_wib(e)
|
||||||
|
|
||||||
tournament = get_tournament(title)
|
team_a = outcomes[0] if len(outcomes) > 0 else "?"
|
||||||
|
team_b = outcomes[1] if len(outcomes) > 1 else "?"
|
||||||
|
odds_a = format_odds(float(prices[0])) if len(prices) > 0 else "?"
|
||||||
|
odds_b = format_odds(float(prices[1])) if len(prices) > 1 else "?"
|
||||||
|
|
||||||
# New format:
|
if " - " in title:
|
||||||
# 1. [Title](url) <- title without tournament
|
title_clean = title.split(" - ")[0].strip()
|
||||||
# 2. Date, Time WIB | Relative
|
else:
|
||||||
# (empty)
|
title_clean = title
|
||||||
# Vol: $XXX
|
|
||||||
# Tournament: XXXXX
|
tournament = get_tournament(title)
|
||||||
# Odds: TeamA Xc | Xc TeamB
|
|
||||||
print(f"\n {i}. [{title_clean}]({url})")
|
print(f"\n {i}. [{title_clean}]({url})")
|
||||||
print(f" {start_time_wib} | {rel_time}")
|
print(f" {start_time_wib} | {rel_time}")
|
||||||
print(f"")
|
print(f" Vol: ${vol:,.0f}")
|
||||||
print(f" Vol: ${vol:,.0f}")
|
if tournament:
|
||||||
if tournament:
|
print(f" Tournament: {tournament}")
|
||||||
print(f" Tournament: {tournament}")
|
print(f" Odds: {team_a} {odds_a} | {odds_b} {team_b}")
|
||||||
print(f" Odds: {team_a} {odds_a} | {odds_b} {team_b}")
|
|
||||||
|
# --- NON-MATCH MARKETS ---
|
||||||
|
if show_non_matches and non_match_events:
|
||||||
|
print(f"\nNON-MATCH MARKETS")
|
||||||
|
|
||||||
|
for i, e in enumerate(non_match_events[:non_matches_max], 1):
|
||||||
|
title = e.get("title", "?")
|
||||||
|
url = get_event_url(e)
|
||||||
|
start_time_wib, rel_time = get_start_time_wib(e)
|
||||||
|
|
||||||
|
total_vol = sum(float(m.get("volume", 0)) for m in e.get("markets", []))
|
||||||
|
market_count = len(e.get("markets", []))
|
||||||
|
|
||||||
|
print(f"\n {i}. [{title}]({url})")
|
||||||
|
print(f" {start_time_wib} | {rel_time}")
|
||||||
|
print(f" Markets: {market_count} | Total Vol: ${total_vol:,.0f}")
|
||||||
|
|
||||||
def print_detail(e, detail):
|
def print_detail(e, detail):
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
@@ -479,9 +548,7 @@ def print_detail(e, detail):
|
|||||||
utc7 = timezone(timedelta(hours=7))
|
utc7 = timezone(timedelta(hours=7))
|
||||||
now_utc7 = now_utc.astimezone(utc7)
|
now_utc7 = now_utc.astimezone(utc7)
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{detail['title']}")
|
||||||
print(f"=== DETAIL: {detail['title'][:60]} ===")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
print(f"URL: {detail['url']}")
|
print(f"URL: {detail['url']}")
|
||||||
print(f"Livestream: {detail['livestream']}")
|
print(f"Livestream: {detail['livestream']}")
|
||||||
|
|
||||||
@@ -499,6 +566,143 @@ def print_detail(e, detail):
|
|||||||
print(f" Vol: ${m['volume']:,.0f} | {spread_str}")
|
print(f" Vol: ${m['volume']:,.0f} | {spread_str}")
|
||||||
print(f" URL: {m['url']}")
|
print(f" URL: {m['url']}")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# TELEGRAM
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def send_to_telegram(match_events, non_match_events, category, matches_only=False, non_matches_only=False):
|
||||||
|
"""Send browse results to Telegram. Reads BOT_TOKEN and CHAT_ID from environment."""
|
||||||
|
import os
|
||||||
|
bot_token = os.environ.get("BOT_TOKEN")
|
||||||
|
chat_id = os.environ.get("CHAT_ID")
|
||||||
|
if not bot_token or not chat_id:
|
||||||
|
print("WARNING: BOT_TOKEN or CHAT_ID not set in environment. Skipping Telegram send.")
|
||||||
|
return
|
||||||
|
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
utc7 = timezone(timedelta(hours=7))
|
||||||
|
now_utc7 = now_utc.astimezone(utc7)
|
||||||
|
header_date = now_utc7.strftime("%b %d, %Y")
|
||||||
|
|
||||||
|
# Determine sections to show
|
||||||
|
show_matches = (not matches_only and not non_matches_only) or matches_only
|
||||||
|
show_non_matches = (not matches_only and not non_matches_only) or non_matches_only
|
||||||
|
|
||||||
|
def send(text):
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-s", f"https://api.telegram.org/bot{bot_token}/sendMessage",
|
||||||
|
"-d", f"chat_id={chat_id}",
|
||||||
|
"-d", f"text={text}",
|
||||||
|
"-d", "parse_mode=HTML",
|
||||||
|
"-d", "disable_web_page_preview=true"],
|
||||||
|
capture_output=True
|
||||||
|
)
|
||||||
|
resp = json.loads(result.stdout.decode())
|
||||||
|
if resp.get("ok"):
|
||||||
|
print(f" Sent msg {resp['result']['message_id']}")
|
||||||
|
else:
|
||||||
|
print(f" Error: {resp.get('description')}")
|
||||||
|
|
||||||
|
# Build sections
|
||||||
|
lines = [f"<b>{category.upper()}</b> | {header_date}"]
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if show_matches:
|
||||||
|
lines.append("MATCH MARKETS")
|
||||||
|
lines.append("")
|
||||||
|
if not match_events:
|
||||||
|
lines.append(" No match markets found.")
|
||||||
|
else:
|
||||||
|
for i, e in enumerate(match_events, 1):
|
||||||
|
ml = get_ml_market(e)
|
||||||
|
outcomes = json.loads(ml.get("outcomes", "[]")) if ml else []
|
||||||
|
prices = json.loads(ml.get("outcomePrices", "[]")) if ml else []
|
||||||
|
vol = get_ml_volume(e)
|
||||||
|
title = e.get("title", "?")
|
||||||
|
url = get_event_url(e)
|
||||||
|
start_time_wib, rel_time = get_start_time_wib(e)
|
||||||
|
team_a = outcomes[0] if len(outcomes) > 0 else "?"
|
||||||
|
team_b = outcomes[1] if len(outcomes) > 1 else "?"
|
||||||
|
odds_a = format_odds(float(prices[0])) if len(prices) > 0 else "?"
|
||||||
|
odds_b = format_odds(float(prices[1])) if len(prices) > 1 else "?"
|
||||||
|
tournament = get_tournament(title)
|
||||||
|
title_clean = title.split(" - ")[0].strip() if " - " in title else title
|
||||||
|
lines.append(f"<b>{i}.</b> <a href=\"{url}\">{title_clean}</a>")
|
||||||
|
lines.append(f" {start_time_wib} | {rel_time}")
|
||||||
|
lines.append(f" Vol: ${vol:,.0f}")
|
||||||
|
if tournament:
|
||||||
|
lines.append(f" Tournament: {tournament}")
|
||||||
|
lines.append(f" Odds: {team_a} {odds_a} | {odds_b} {team_b}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if show_non_matches:
|
||||||
|
lines.append("NON-MATCH MARKETS")
|
||||||
|
lines.append("")
|
||||||
|
if not non_match_events:
|
||||||
|
lines.append(" No non-match markets found.")
|
||||||
|
else:
|
||||||
|
for i, e in enumerate(non_match_events, 1):
|
||||||
|
title = e.get("title", "?")
|
||||||
|
url = get_event_url(e)
|
||||||
|
start_time_wib, rel_time = get_start_time_wib(e)
|
||||||
|
total_vol = sum(float(m.get("volume", 0)) for m in e.get("markets", []))
|
||||||
|
market_count = len(e.get("markets", []))
|
||||||
|
lines.append(f"<b>{i}.</b> <a href=\"{url}\">{title}</a>")
|
||||||
|
lines.append(f" {start_time_wib} | {rel_time}")
|
||||||
|
lines.append(f" Markets: {market_count} | Total Vol: ${total_vol:,.0f}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Chunk by 10 items (events), respecting 4096 char Telegram limit
|
||||||
|
text = "\n".join(lines)
|
||||||
|
if len(text) <= 4096:
|
||||||
|
send(text)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Split into chunks of 10 events
|
||||||
|
all_items = []
|
||||||
|
in_match = True
|
||||||
|
for line in lines:
|
||||||
|
if line == "MATCH MARKETS":
|
||||||
|
in_match = True
|
||||||
|
elif line == "NON-MATCH MARKETS":
|
||||||
|
in_match = False
|
||||||
|
elif line.startswith("<b>") and ". " in line and "</a>" in line:
|
||||||
|
all_items.append((in_match, line))
|
||||||
|
|
||||||
|
chunk = []
|
||||||
|
chunk_len = 0
|
||||||
|
chunk_num = 1
|
||||||
|
|
||||||
|
# Header is always first
|
||||||
|
header = f"<b>{category.upper()}</b> | {header_date}\n"
|
||||||
|
if show_matches:
|
||||||
|
header += "\nMATCH MARKETS\n\n"
|
||||||
|
if show_non_matches:
|
||||||
|
header += "\nNON-MATCH MARKETS\n\n"
|
||||||
|
|
||||||
|
for is_match, item_line in all_items:
|
||||||
|
test_chunk = chunk + [item_line, ""]
|
||||||
|
test_text = header + "\n".join(chunk) + "\n".join(test_chunk)
|
||||||
|
if len(test_text) > 4096 or len(chunk) >= 10:
|
||||||
|
# Send current chunk
|
||||||
|
msg = header + "\n".join(chunk)
|
||||||
|
send(msg)
|
||||||
|
chunk = [item_line, ""]
|
||||||
|
header = f"<b>{category.upper()}</b> (cont.) | {header_date}\n"
|
||||||
|
if show_matches and is_match:
|
||||||
|
header += "\nMATCH MARKETS\n\n"
|
||||||
|
elif show_non_matches and not is_match:
|
||||||
|
header += "\nNON-MATCH MARKETS\n\n"
|
||||||
|
else:
|
||||||
|
chunk.extend([item_line, ""])
|
||||||
|
|
||||||
|
if chunk:
|
||||||
|
msg = header + "\n".join(chunk)
|
||||||
|
send(msg)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# MAIN
|
# MAIN
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -509,11 +713,25 @@ def main():
|
|||||||
choices=list(GAME_CATEGORIES.keys()),
|
choices=list(GAME_CATEGORIES.keys()),
|
||||||
help="Game category to browse")
|
help="Game category to browse")
|
||||||
parser.add_argument("--limit", type=int, default=5,
|
parser.add_argument("--limit", type=int, default=5,
|
||||||
help="Max events to show")
|
help="Max events per section (match + non-match). Default: 5")
|
||||||
|
parser.add_argument("--matches", type=int, default=None,
|
||||||
|
help="Max match markets to show. Default: --limit")
|
||||||
|
parser.add_argument("--non-matches", type=int, default=None,
|
||||||
|
help="Max non-match markets to show. Default: --limit")
|
||||||
|
parser.add_argument("--search", type=str, default=None,
|
||||||
|
help="Free-text team/term search within the selected category. Overrides default query.")
|
||||||
|
parser.add_argument("--matches-only", action="store_true",
|
||||||
|
help="Show only match markets (suppress non-match section).")
|
||||||
|
parser.add_argument("--non-matches-only", action="store_true",
|
||||||
|
help="Show only non-match markets (suppress match section).")
|
||||||
parser.add_argument("--list-categories", action="store_true",
|
parser.add_argument("--list-categories", action="store_true",
|
||||||
help="List available game categories and exit")
|
help="List available game categories and exit")
|
||||||
parser.add_argument("--detail", type=int, default=1,
|
parser.add_argument("--detail", type=int, default=1,
|
||||||
help="Index of event (1-indexed) to show detailed markets for. Default: 1. Set to 0 to disable.")
|
help="Index of match event (1-indexed) to show detailed markets. Default: 1. Set to 0 to disable.")
|
||||||
|
parser.add_argument("--raw", action="store_true",
|
||||||
|
help="Show all events without tradeable filter (for debugging).")
|
||||||
|
parser.add_argument("--telegram", action="store_true",
|
||||||
|
help="Send results to Telegram (BOT_TOKEN and CHAT_ID must be set in environment).")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.list_categories:
|
if args.list_categories:
|
||||||
@@ -522,28 +740,53 @@ def main():
|
|||||||
print(f" - {name}")
|
print(f" - {name}")
|
||||||
return
|
return
|
||||||
|
|
||||||
search_term = GAME_CATEGORIES[args.category]
|
category_term = GAME_CATEGORIES[args.category]
|
||||||
|
search_term = f"{category_term} {args.search}" if args.search else category_term
|
||||||
|
tradeable_only = not args.raw
|
||||||
|
matches_max = args.matches if args.matches is not None else args.limit
|
||||||
|
non_matches_max = args.non_matches if args.non_matches is not None else args.limit
|
||||||
|
|
||||||
print(f"\nFetching {args.category} events...")
|
if args.search:
|
||||||
|
print(f"\nFetching {args.category} events matching '{args.search}'...")
|
||||||
|
else:
|
||||||
|
print(f"\nFetching {args.category} events...")
|
||||||
|
|
||||||
result = browse_events(search_term, display_max=args.limit)
|
result = browse_events(search_term, matches_max=matches_max, non_matches_max=non_matches_max, tradeable_only=tradeable_only)
|
||||||
|
|
||||||
print_browse(
|
print_browse(
|
||||||
result["events"],
|
result["match_events"],
|
||||||
|
result["non_match_events"],
|
||||||
args.category,
|
args.category,
|
||||||
result["total_raw"],
|
result["total_raw"],
|
||||||
result["total_filtered"]
|
result["total_fetched"],
|
||||||
|
result["total_match"],
|
||||||
|
result["total_non_match"],
|
||||||
|
raw_mode=args.raw,
|
||||||
|
partial=result.get("partial", False),
|
||||||
|
non_matches_max=non_matches_max,
|
||||||
|
matches_only=args.matches_only,
|
||||||
|
non_matches_only=args.non_matches_only
|
||||||
)
|
)
|
||||||
|
|
||||||
# Print detail for selected event if any
|
# Print detail for selected event if any
|
||||||
if result["events"] and args.detail > 0:
|
if result["match_events"] and args.detail > 0:
|
||||||
print("\n")
|
print("\n")
|
||||||
idx = args.detail - 1
|
idx = args.detail - 1
|
||||||
if idx < 0 or idx >= len(result["events"]):
|
if idx < 0 or idx >= len(result["match_events"]):
|
||||||
idx = 0
|
idx = 0
|
||||||
detail_event = result["events"][idx]
|
detail_event = result["match_events"][idx]
|
||||||
detail = format_detail_event(detail_event)
|
detail = format_detail_event(detail_event)
|
||||||
print_detail(detail_event, detail)
|
print_detail(detail_event, detail)
|
||||||
|
|
||||||
|
# Send to Telegram if requested
|
||||||
|
if args.telegram:
|
||||||
|
send_to_telegram(
|
||||||
|
result["match_events"],
|
||||||
|
result["non_match_events"],
|
||||||
|
args.category,
|
||||||
|
matches_only=args.matches_only,
|
||||||
|
non_matches_only=args.non_matches_only
|
||||||
|
)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user