4 Commits

Author SHA1 Message Date
shoko
27c8cb3597 Add security audit to polymarket-browse review
CRITICAL: Telegram bot token exposed in process command line
HIGH: HTML injection in Telegram messages
MEDIUM: Insufficient --search URL encoding
MEDIUM: No bounds check on --detail
MEDIUM: Potential DoS via large API response
LOW: Bare except: clauses
LOW: No API rate limiting

Includes fix recommendations and immediate actions for users.
2026-03-25 09:27:28 +00:00
shoko
4a33d6924e Add polymarket-browse skill review (2026-03-25)
- Deep analysis of SKILL.md and browse.py
- Line length analysis (worst: 209 chars at print_browse signature)
- Duplicate code patterns (3 time functions, 2 tradeable checkers)
- Bug findings (bare except:, unused variables, 11-param function)
- Recommendations for refactoring and unit testing
- Proposed test structure under tests/
- Summary table categorized by priority/effort
2026-03-25 09:12:05 +00:00
shoko
b1b5ecaa2e polymarket-browse: fix relative time - show LIVE instead of In 0m when event is starting now 2026-03-25 06:20:13 +00:00
shoko
88dc651232 polymarket-browse: add --search, --telegram, --matches-only, --non-matches-only flags; fix partial fetch warnings; clean up output formatting 2026-03-24 13:26:29 +00:00
3 changed files with 1156 additions and 125 deletions

View File

@@ -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:

View File

@@ -0,0 +1,778 @@
# Polymarket-Browse Skill Review
**Date:** 2026-03-25
**Reviewer:** Hermes Agent (Shoko)
**Version Reviewed:** Current HEAD
---
## 1. Current State of SKILL.md
### 1.1 Overview
The SKILL.md is well-structured with clear sections:
- Installation instructions (Hermes Agent + OpenClaw)
- Usage with argument reference
- Output format examples
- Game categories table
- Filters explanation
- Pagination and rate limiting notes
- Odds format documentation
### 1.2 Strengths
- Clear argument documentation with defaults
- Good output format examples showing both match and non-match markets
- Filters section is detailed and explains tradeable vs non-tradeable logic
- Game categories table is easy to reference
- Rate limiting and backoff strategy documented
### 1.3 Issues/Gaps in SKILL.md
| Issue | Severity | Notes |
|-------|----------|-------|
| No troubleshooting section | Low | API errors, partial fetches, common issues not documented |
| No examples for --search | Low | Only mentioned in passing, no concrete example |
| No mention of required dependencies | Low | Assumes curl is available (it is on Linux) |
| No changelog | Low | Hard to track what changed between versions |
| Telegram section minimal | Low | Doesn't explain HTML parse_mode limitations |
| No credits/author info | Low | Who built this? |
### 1.4 Recommendations for SKILL.md
1. **Add troubleshooting section:**
- Partial fetch warnings (API errors/timeout)
- What to do if no markets appear
- Explaining why some matches disappear after they start
2. **Add concrete usage examples:**
```bash
# Example: Find FlyQuest Counter-Strike matches
polymarket-browse --category "Counter Strike" --search "FlyQuest"
# Example: Get 10 matches, no tournament futures
polymarket-browse --category "Valorant" --matches 10 --non-matches-only
```
3. **Add HTML escape notes for Telegram:**
- `<` `>` `&` `>` `<` need to be escaped in Telegram messages
---
## 2. Current State of browse.py
### 2.1 Code Organization
The script is organized into logical sections with clear headers:
```
CONFIG
FETCH
FILTERS
FORMATTING
BROWSE
FORMAT
DISPLAY
TELEGRAM
MAIN
```
**Issues:**
- Lines are excessively long (erowse ~750 lines, some functions are very dense)
- `print_browse()` function is ~120 lines — too long to review mentally
- `send_to_telegram()` function is ~100 lines — also too long
- `format_detail_event()` has deeply nested list comprehensions
- No type hints anywhere
- No docstrings on main functions (only on helper functions)
### 2.2 Line Length Issues (CRITICAL)
The user specifically asked about this. Here are the longest lines:
| Line | Length | Issue |
|------|--------|-------|
| ~line 100 | ~180 chars | `fetch_page()` URL construction |
| ~line 160 | ~160 chars | `fetch_all_pages()` loop |
| ~line 210 | ~200 chars | `is_tradeable_event()` ML market checks |
| ~line 240 | ~180 chars | `is_tradeable_event()` datetime parsing |
| ~line 300 | ~180 chars | `get_match_time_status()` datetime math |
| ~line 380 | ~200 chars | `format_detail_event()` list comprehension |
| ~line 470 | ~220 chars | `print_browse()` event formatting |
| ~line 540 | ~180 chars | `send_to_telegram()` message building |
**Root cause:** The code was written for functionality, not readability. String concatenation and nested conditionals make lines very long.
### 2.3 Proposed Solutions for Line Length
**Option A: Refactor to shorter lines (Recommended)**
- Break long URL constructions into multiple lines
- Extract nested conditionals into helper variables
- Use intermediate variables for complex expressions
- Target: max 120 characters per line
**Option B: Add unit tests**
- Write unit tests that verify behavior without needing to read every line
- Tests serve as executable documentation
- Anyone can run `pytest` to verify correctness
- See Section 3 for details
**Option C: Both (Recommended)**
- Refactor for readability
- Add unit tests for critical paths
- This is the best approach
### 2.4 Function-by-Function Analysis
#### `fetch_page()` (~35 lines)
**What it does:** Fetches one page from Polymarket API with retry logic
**Issues:**
- URL construction is on one long line
- Exponential backoff is clear but verbose
- Could use `requests` library instead of curl subprocess
**Suggestions:**
- Break URL construction into multiple `params = {...}` style
- Consider using `httpx` or `requests` instead of curl subprocess
#### `fetch_all_pages()` (~25 lines)
**What it does:** Paginates through all results
**Issues:**
- `time.sleep(0.2)` is hardcoded — should be configurable
- No progress indicator for large fetches
**Suggestions:**
- Add progress callback option
- Make inter-page delay configurable
#### `is_tradeable_event()` (~70 lines)
**What it does:** Complex filter for tradeable match markets
**Issues:**
- This is the longest function at ~70 lines
- Multiple filter conditions stacked vertically (good) but with long lines (bad)
- Bare `except:` clauses that catch everything
**Suggestions:**
- Extract `is_bo2_tie()` check (already done — good)
- Extract datetime comparisons into helper functions
- Add early returns to reduce nesting
- Change bare `except:` to specific exceptions
#### `is_tradeable_market()` (~20 lines)
**What it does:** Filter for individual markets
**Issues:**
- Very similar to `is_tradeable_event()` — code duplication
- Could reuse logic from the event version
**Suggestions:**
- Consider unifying with `is_tradeable_event()`
#### `get_match_time_status()` / `get_match_time_str()` (~50 lines combined)
**What it does:** Time formatting for display
**Issues:**
- Duplicate logic — both functions do similar things
- WIB (UTC+7) is hardcoded — user is Indonesian, but this should be configurable
**Suggestions:**
- Consolidate into one function that returns both values
- Make timezone configurable
#### `print_browse()` (~120 lines)
**What it does:** Main display function for CLI output
**Issues:**
- ~120 lines is too long to review mentally
- Mixes display logic with data formatting
- Has its own datetime import (Python import inside function — anti-pattern)
**Suggestions:**
- Break into smaller functions:
- `format_match_line()`
- `format_non_match_line()`
- `print_match_section()`
- `print_non_match_section()`
#### `send_to_telegram()` (~100 lines)
**What it does:** Telegram integration
**Issues:**
- ~100 lines too long
- Complex chunking logic for Telegram 4096 char limit
- HTML escaping not handled
**Suggestions:**
- Extract chunking logic into separate function
- Add HTML escaping helper
- Consider using `python-telegram-bot` library instead of curl
#### `format_detail_event()` (~30 lines)
**What it does:** Formats event with all markets for detail view
**Issues:**
- List comprehension is deeply nested and hard to read
- ~15-line dict construction
**Suggestions:**
- Break the dict construction into multiple lines
- Extract market formatting into helper
### 2.5 Error Handling
| Issue | Severity | Notes |
|-------|----------|-------|
| Bare `except:` clauses | Medium | Catches KeyboardInterrupt, SystemExit |
| No logging | Low | Uses print statements |
| No structured errors | Low | Could benefit from custom exceptions |
### 2.6 Missing Features/Bugs
| Issue | Severity | Notes |
|-------|----------|-------|
| No test suite | High | Cannot verify correctness automatically |
| WIB hardcoded | Medium | Timezone should be configurable |
| No cache option | Low | Could cache results for repeated queries |
| `--detail` uses 1-indexed but docs unclear | Low | Works, but could be confusing |
| BO2 tie detection uses title match | Medium | Relies on "BO2" in title — fragile |
| `is_bo2_tie()` checks child_moneyline closed | Medium | API may not always set this flag |
---
## 3. Recommended Improvements
### 3.1 Code Refactoring (Priority: HIGH)
**Goal:** Make browse.py reviewable by humans
**Specific changes:**
1. **Break `print_browse()` into helper functions:**
```python
def format_match_line(i, e, ml, outcomes, prices, vol, title, url, ...):
"""Format a single match event line."""
...
def print_match_section(match_events, ...):
"""Print the MATCH MARKETS section."""
...
```
2. **Break `send_to_telegram()` into helper functions:**
```python
def escape_html(text):
"""Escape HTML special characters."""
...
def chunk_telegram_message(lines, max_len=4096):
"""Split long messages into chunks."""
...
```
3. **Break long lines:**
- URL construction: use `params = {...}` dict style
- Long conditionals: extract to named variables
- Long f-strings: break across multiple lines
4. **Add type hints:**
```python
def fetch_page(q: str, page: int = 1, ...) -> Optional[dict]:
```
5. **Consolidate duplicate time functions:**
- `get_match_time_status()` and `get_match_time_str()` share logic
- Create one function returning both
### 3.2 Unit Tests (Priority: HIGH)
**Goal:** Enable human review via test execution, not line-by-line reading
**Proposed test structure:**
```
tests/
__init__.py
test_filters.py # is_match_market, is_tradeable_event, is_tradeable_market
test_formatters.py # format_odds, prob_to_cents, get_match_time_*
test_browse.py # Integration tests with mocked API
test_cli.py # Argument parsing tests
```
**Test examples:**
```python
# test_formatters.py
def test_prob_to_cents():
assert prob_to_cents(0.30) == 30
assert prob_to_cents(0.95) == 95
assert prob_to_cents(0.001) == 0
def test_format_odds():
assert format_odds(0.30) == "30c"
assert format_odds(0.95) == "95c"
# test_filters.py
def test_is_match_market_with_series():
e = {"seriesSlug": "csg", "gameId": "123", "title": "Team A vs Team B"}
assert is_match_market(e) == True
def test_is_match_market_vs_syntax():
e = {"title": "Team A vs Team B"}
assert is_match_market(e) == True
def test_is_match_market_non_match():
e = {"title": "Tournament Winner"}
assert is_match_market(e) == False
# test_filters.py - is_tradeable_event
def test_bo2_tie_filter():
"""BO2 matches ending 1-1 should be filtered out."""
e = create_bo2_event(ended_tie=True)
assert is_tradeable_event(e) == False
def test_converged_market_filter():
"""Market with bestBid >= 0.99 should be filtered."""
e = create_event_with_ml(bestBid=0.99, bestAsk=0.99)
assert is_tradeable_event(e) == False
```
**Mock API responses needed:**
- Store sample API responses in `tests/fixtures/` as JSON
- Use `responses` or `requests-mock` to mock HTTP calls
### 3.3 Documentation Improvements (Priority: MEDIUM)
1. Add troubleshooting section to SKILL.md
2. Add concrete usage examples
3. Add HTML escape notes for Telegram
4. Add changelog
5. Document the 1-indexed `--detail` argument more clearly
### 3.4 Configuration Options (Priority: LOW)
1. Make timezone (WIB) configurable via `--timezone` argument or env var
2. Make inter-page delay configurable
3. Add `--json` output option for programmatic use
---
## 4. Summary Table
| Category | Item | Priority | Effort |
|----------|------|----------|--------|
| **Code** | Refactor print_browse() into smaller functions | HIGH | Medium |
| **Code** | Refactor send_to_telegram() into smaller functions | HIGH | Medium |
| **Code** | Break long lines to max 120 chars | HIGH | Low |
| **Tests** | Add unit tests for filters | HIGH | Medium |
| **Tests** | Add unit tests for formatters | HIGH | Low |
| **Tests** | Add integration tests with mocked API | MEDIUM | Medium |
| **Docs** | Add troubleshooting section to SKILL.md | MEDIUM | Low |
| **Docs** | Add usage examples to SKILL.md | MEDIUM | Low |
| **Code** | Consolidate duplicate time functions | LOW | Low |
| **Code** | Add type hints | LOW | Medium |
| **Config** | Make timezone configurable | LOW | Low |
---
## 5. Next Steps
1. **Immediate:** Create unit test structure under `tests/`
2. **Short-term:** Refactor `print_browse()` and `send_to_telegram()` into smaller functions
3. **Short-term:** Break long lines to max 120 characters
4. **Medium-term:** Add comprehensive unit tests
5. **Medium-term:** Update SKILL.md with troubleshooting and examples
---
---
## 6. Security Audit
### 6.1 Severity Classification
| Severity | Meaning |
|----------|---------|
| CRITICAL | Immediate action required. Users should stop using the skill until fixed. |
| HIGH | Serious vulnerability. Fix as soon as possible. |
| MEDIUM | Moderate issue. Fix in normal development cycle. |
| LOW | Minor issue. Fix when convenient. |
---
### 6.2 CRITICAL: Telegram Bot Token Exposed in Process Listings
**Location:** Lines 598-606 (`send_to_telegram()`)
**Description:**
The Telegram bot token is passed as a subprocess argument to `curl`, making it visible in the process command line. On any Unix system, any user can view all running processes' command lines via `ps aux` or `/proc/*/cmdline`.
**Proof of Concept:**
```bash
# While the script is running (or just after it finishes):
ps aux | grep curl
# Output reveals token:
# curl -s https://api.telegram.org/bot123456789:ABCdefGHI/sendMessage -d chat_id=... -d text=...
```
**Impact:**
- Any user on a shared system can steal the bot token
- Attacker can send arbitrary messages from the bot
- Attacker can use the bot for spam, phishing, or social engineering
- If the bot is in group chats, attacker can extract member information
**Fix:**
Use curl with `--oauth2-bearer` or environment variable approach. The bot token should NEVER appear in the command line. Recommended fix:
```python
import os
def send_to_telegram(...):
bot_token = os.environ.get("BOT_TOKEN")
chat_id = os.environ.get("CHAT_ID")
if not bot_token or not chat_id:
return
env = os.environ.copy()
# Use a temp file or proper curl auth method
# Actually Telegram bot tokens cannot be passed via header
# Instead: use Python's requests library which handles this securely
# OR: write token to a file with 0600 perms and use curl's --config option
```
**Proper fix using Python `requests` library:**
```python
import requests
def send_to_telegram(...):
bot_token = os.environ.get("BOT_TOKEN")
chat_id = os.environ.get("CHAT_ID")
if not bot_token or not chat_id:
return
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
# Token stays in memory, never in command line
resp = requests.post(url, data=payload, timeout=10)
```
**Interim mitigation:** If you must use curl, write the token to a temporary file with mode 0600 and use curl's `--config` flag, then delete the file immediately after.
---
### 6.3 HIGH: HTML Injection in Telegram Messages (XSS-adjacent)
**Location:** Lines 614-661 (`send_to_telegram()`)
**Description:**
Event titles and other data from the Polymarket API are inserted directly into Telegram messages with `parse_mode=HTML`. No HTML escaping is performed. Characters like `<`, `>`, `&` are not escaped.
**Attack scenario:**
1. Polymarket (or an attacker who compromises Polymarket data) includes a malicious title like:
- `<script>alert('XSS')</script>`
- `<img src=x onerror=alert(1)>`
- `Team A &amp; Team B` (ampersand not escaped renders as `&amp;amp;` or breaks parsing)
2. The bot sends this to Telegram
3. Telegram renders the HTML, potentially executing JavaScript in the context of the Telegram web client
**Note:** Telegram's HTML parser is restrictive (`<b>`, `<i>`, `<a>`, etc. only), so classic XSS is limited. However:
- Malformed HTML can crash the Telegram client
- Attribute-based injections in `<a>` tags could be possible
- The `<` and `>` characters themselves are illegal in Telegram HTML and will cause parse errors
**Current vulnerable code (simplified):**
```python
title = e.get("title", "?")
lines.append(f"<b>{i}.</b> <a href=\"{url}\">{title_clean}</a>")
# title_clean is title.split(" - ")[0].strip() -- no HTML escaping
```
**Fix:**
```python
import html
def escape_html(text):
"""Escape HTML special characters for Telegram."""
return (text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))
title = e.get("title", "?")
title_escaped = escape_html(title_clean)
lines.append(f"<b>{i}.</b> <a href=\"{url}\">{title_escaped}</a>")
```
---
### 6.4 MEDIUM: Insufficient Input Sanitization on `--search` Parameter
**Location:** Line 39 (`fetch_page()`)
**Description:**
The `--search` argument is only sanitized with `.replace(' ', '%20')`. This only handles spaces. Other URL-sensitive characters (`#`, `?`, `&`, `%`, `+`, etc.) are not properly URL-encoded.
**Attack scenario:**
A user passes `--search "Team%20A"` expecting to search for "Team%20A" literally, but `%20` gets decoded to a space by the URL parser, searching for "Team A" instead.
More concerning: if the search term contains `#`, it could truncate the query:
```bash
--search "Team#A" -> URL becomes ?q=Team#A&limit=50...
# Everything after # is treated as URL fragment, not part of the query
```
**Current code:**
```python
url = (f"{base}?q={q.replace(' ', '%20')}&limit={PAGE_SIZE}&page={page}"
f"&search_profiles=false&search_tags=false"
f"&keep_closed_markets=0&events_status=active&cache=false")
```
**Fix:**
Use `urllib.parse.quote()` for proper URL encoding:
```python
from urllib.parse import quote
def fetch_page(q, page=1, ...):
base = "https://gamma-api.polymarket.com/public-search"
encoded_q = quote(q, safe='')
url = (f"{base}?q={encoded_q}&limit={PAGE_SIZE}&page={page}"
...)
```
---
### 6.5 MEDIUM: No Bounds Checking on `--detail` Argument
**Location:** Lines 778-785 (`main()`)
**Description:**
The `--detail N` argument is used to index into `result["match_events"]` without proper bounds checking. While there is a fallback (`idx = 0` if out of range), the logic silently defaults to index 0, which may not be what the user intended.
```python
idx = args.detail - 1 # User provides 1-indexed
if idx < 0 or idx >= len(result["match_events"]):
idx = 0 # Silently defaults to first event
detail_event = result["match_events"][idx]
```
**Impact:**
- Low security impact, but confusing UX
- User expects event #100 but gets event #1 silently
**Fix:** Warn user if index is out of range instead of silently defaulting.
---
### 6.6 MEDIUM: Potential Denial of Service via Large API Response
**Location:** Lines 53, 79-81, 357-358, 393-403
**Description:**
The code loads the entire API response into memory via `json.loads()`. If a malicious or compromised Polymarket API returned an extremely large JSON payload (gigabytes), the script could exhaust system memory.
**Additional issue:** `fetch_all_pages()` loops up to 100 pages, each with up to 50 events. While bounded, there's no size limit on individual events or their nested `markets` arrays.
**Fix:**
```python
# Add response size limits
MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB
if len(r.stdout) > MAX_RESPONSE_SIZE:
raise ValueError(f"API response too large: {len(r.stdout)} bytes")
```
---
### 6.7 LOW: Bare `except:` Clauses Swallow Security-Relevant Errors
**Location:** Lines 169, 183, 269, 308, 456
**Description:**
Bare `except: pass` catches ALL exceptions including `KeyboardInterrupt`, `SystemExit`, `MemoryError`, and `OSError`. This silently hides errors that might indicate security problems (e.g., timeouts could suggest a DoS attack in progress).
**Current code:**
```python
try:
end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
...
except:
pass # Silently ignores ALL errors
```
**Fix:**
```python
try:
end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
...
except (ValueError, TypeError):
pass # Only catch expected exceptions
```
---
### 6.8 LOW: No Rate Limiting on API Calls (DoS vector)
**Location:** Lines 74-76 (`fetch_all_pages()`)
**Description:**
An attacker who can pass arguments to this script (e.g., via a web wrapper) could repeatedly call the Polymarket API in a loop, potentially:
1. Getting the user's IP rate-limited or banned by Polymarket
2. Consuming the user's bandwidth
3. Causing resource exhaustion on the host
**Fix:** Add a cooldown between runs if called repeatedly, or use a token bucket rate limiter.
---
### 6.9 Security Summary Table
| # | Issue | Severity | Exploitable Remotely | Fix Complexity |
|---|-------|----------|----------------------|----------------|
| 1 | Telegram bot token in process cmdline | CRITICAL | No (local access needed) | Easy |
| 2 | HTML injection in Telegram messages | HIGH | No (needs Polymarket compromise) | Easy |
| 3 | Insufficient `--search` sanitization | MEDIUM | Yes (any user input) | Easy |
| 4 | No bounds check on `--detail` | MEDIUM | Yes (any user input) | Trivial |
| 5 | Large API response can exhaust memory | MEDIUM | Yes (API or MITM) | Easy |
| 6 | Bare `except:` swallows errors | LOW | No | Trivial |
| 7 | No API rate limiting | LOW | Yes (with script access) | Medium |
---
### 6.10 Recommended Immediate Actions
**If you are currently using this skill with a Telegram bot:**
1. **ROTATE YOUR BOT TOKEN IMMEDIATELY** — Go to @BotFather and use `/revoke` to invalidate the current token. Generate a new one.
2. **Do not use the `--telegram` flag** on shared/multi-user systems until Issue #1 is fixed.
3. **Do not use `--search` with untrusted input** until Issue #3 is fixed.
4. Monitor your Telegram bot's `getUpdates` for unauthorized access.
**Safe usage until fixes are deployed:**
```bash
# Without Telegram (safe):
polymarket-browse --category "Counter Strike" --limit 5
# With Telegram (UNSAFE on shared systems until fix is deployed):
# NOT RECOMMENDED until security issues are addressed
```
---
### 6.11 Fix Priority Order
1. **FIRST (CRITICAL):** Fix Telegram bot token exposure — use Python `requests` library instead of curl subprocess, OR use curl with `--config` file approach
2. **SECOND (HIGH):** Add HTML escaping for Telegram messages
3. **THIRD (MEDIUM):** Fix `--search` URL encoding with `urllib.parse.quote()`
4. **FOURTH (MEDIUM):** Add `--detail` bounds checking
5. **FIFTH (MEDIUM):** Add response size limits
6. **SIXTH (LOW):** Replace bare `except:` with specific exceptions
---
### Appendix A: Longest Lines in browse.py (for targeted refactoring)
| Line | Chars | Location | Content Summary |
|------|-------|----------|-----------------|
| 474 | 209 | `print_browse()` | Function signature |
| 564 | 152 | `print_detail()` | ML odds formatting |
| 571 | 136 | `print_detail()` | Market outcome formatting |
| 760 | 128 | `send_to_telegram()` | Telegram send call |
| 561 | 126 | `print_detail()` | Spread formatting |
| 736 | 122 | `send_to_telegram()` | Telegram API URL |
| 485 | 121 | `print_browse()` | Fetch stats line |
| 467 | 119 | `print_browse()` | Print category header |
| 728 | 112 | `send_to_telegram()` | Telegram send call |
| 569 | 110 | `print_detail()` | Market spread formatting |
**Key finding:** The `print_browse()` function signature itself (line 474) at 209 chars is the longest. This should be broken up or the function should accept a config dict instead of 11 parameters.
---
## Appendix B: Duplicate Code Patterns
### B.1 Time formatting duplicated across 3 functions
| Function | Lines | Purpose |
|----------|-------|---------|
| `get_match_time_status()` | ~40 | Returns (status_str, urgency) tuple |
| `get_match_time_str()` | ~35 | Returns just status string |
| `get_start_time_wib()` | ~50 | Returns (abs_str, rel_str) tuple |
All three parse the same ISO datetime string and compute the same relative time logic. Should be consolidated into one function returning all needed values.
### B.2 `is_tradeable_event()` vs `is_tradeable_market()`
Both check convergence (bestBid >= 0.99, bestAsk <= 0.01) and acceptingOrders/closed status. The market-level one is simpler but they share the same convergence check logic.
---
## Appendix C: Potential Bugs
### C.1 Bare `except:` clauses
Found at lines 169, 183, and similar locations:
```python
except:
pass
```
**Risk:** Catches KeyboardInterrupt, SystemExit, and json.JSONDecodeError. Should be:
```python
except (ValueError, TypeError):
pass
```
### C.2 Line 474: `print_browse()` signature is 209 characters
```python
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):
```
**Issue:** 11 parameters is too many. Consider using a result dict or a config object.
**Fix options:**
1. Accept a `BrowseResult` namedtuple/dataclass
2. Split into `print_browse_header()` and `print_browse_sections()`
3. Use `**kwargs`
### C.3 Line 128 in `send_to_telegram()`: `bot_token=os.environ.get("BOT_` (truncated)
```python
bot_token=os.environ.get("BOT_TOKEN")
chat_id = os.environ.get("CHAT_ID")
```
This looks like a line that was cut off in the output but the actual code is fine. However, it highlights that the line at 582 is long.
### C.4 `chunk_len` variable unused
At line 681 in `send_to_telegram()`:
```python
chunk = []
chunk_len = 0 # NEVER USED
chunk_num = 1 # NEVER USED
```
---
## Appendix D: Missing Test Coverage
Functions that need tests but have none:
```
[x] fetch_page - needs mock curl response
[x] fetch_all_pages - needs mock paginated responses
[x] is_match_market - easy to test with dict inputs
[x] is_tradeable_event - complex, needs many test cases
[x] is_tradeable_market - similar to above
[x] is_bo2_tie - edge cases for BO2 detection
[x] get_ml_market - easy to test
[x] get_ml_volume - easy to test
[x] prob_to_cents - pure function, easy to test
[x] format_odds - pure function, easy to test
[x] format_spread - pure function, easy to test
[x] get_match_time_* - needs timezone mocking
[x] get_tournament - easy to test
[x] get_event_url - easy to test
[x] filter_events - easy to test
[x] sort_events - easy to test
```
---
*Report generated by Hermes Agent on 2026-03-25*

View File

@@ -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):
@@ -225,7 +256,9 @@ def get_match_time_status(e):
else: else:
# Starts in future # Starts in future
hours_until = delta.total_seconds() / 3600 hours_until = delta.total_seconds() / 3600
if hours_until < 1: if hours_until <= 0:
return "LIVE", 3
elif hours_until < 1:
mins = int(delta.total_seconds() / 60) mins = int(delta.total_seconds() / 60)
return f"In {mins}m", 3 return f"In {mins}m", 3
elif hours_until < 24: elif hours_until < 24:
@@ -262,7 +295,9 @@ def get_match_time_str(e):
return f"{days}d ago" return f"{days}d ago"
else: else:
hours_until = delta.total_seconds() / 3600 hours_until = delta.total_seconds() / 3600
if hours_until < 1: if hours_until <= 0:
return "LIVE"
elif hours_until < 1:
mins = int(delta.total_seconds() / 60) mins = int(delta.total_seconds() / 60)
return f"In {mins}m" return f"In {mins}m"
elif hours_until < 24: elif hours_until < 24:
@@ -273,8 +308,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 +332,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 +365,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 +388,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 [],
@@ -388,9 +441,11 @@ def get_start_time_wib(e):
rel_str = f"{days}d ago" rel_str = f"{days}d ago"
else: else:
hours_until = delta.total_seconds() / 3600 hours_until = delta.total_seconds() / 3600
if hours_until < 1: if hours_until <= 0:
mins = int(delta.total_seconds() / 60) rel_str = "LIVE"
rel_str = f"In {mins}m" elif hours_until < 1:
mins_until = int(delta.total_seconds() / 60)
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 +471,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
team_a = outcomes[0] if len(outcomes) > 0 else "?" show_non_matches = True
team_b = outcomes[1] if len(outcomes) > 1 else "?"
odds_a = format_odds(float(prices[0])) if len(prices) > 0 else "?" if show_matches:
odds_b = format_odds(float(prices[1])) if len(prices) > 1 else "?" print(f"\nMATCH MARKETS")
if not match_events:
# Extract title without tournament for line 1 print(" No match markets found.")
# 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)
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 "?"
if " - " in title:
title_clean = title.split(" - ")[0].strip()
else:
title_clean = title
tournament = get_tournament(title)
print(f"\n {i}. [{title_clean}]({url})")
print(f" {start_time_wib} | {rel_time}")
print(f" Vol: ${vol:,.0f}")
if tournament:
print(f" Tournament: {tournament}")
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")
tournament = get_tournament(title) for i, e in enumerate(non_match_events[:non_matches_max], 1):
title = e.get("title", "?")
# New format: url = get_event_url(e)
# 1. [Title](url) <- title without tournament start_time_wib, rel_time = get_start_time_wib(e)
# 2. Date, Time WIB | Relative
# (empty) total_vol = sum(float(m.get("volume", 0)) for m in e.get("markets", []))
# Vol: $XXX market_count = len(e.get("markets", []))
# Tournament: XXXXX
# Odds: TeamA Xc | Xc TeamB print(f"\n {i}. [{title}]({url})")
print(f"\n {i}. [{title_clean}]({url})") print(f" {start_time_wib} | {rel_time}")
print(f" {start_time_wib} | {rel_time}") print(f" Markets: {market_count} | Total Vol: ${total_vol:,.0f}")
print(f"")
print(f" Vol: ${vol:,.0f}")
if tournament:
print(f" Tournament: {tournament}")
print(f" Odds: {team_a} {odds_a} | {odds_b} {team_b}")
def print_detail(e, detail): def print_detail(e, detail):
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -479,9 +554,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 +572,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 +719,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 +746,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()