Package gametime_watcher
Gametime ticket-price watcher.
A small, dependency-free toolkit for listing current Gametime ticket prices for an event and alerting when a desired number of seats in chosen sections drops below a target per-ticket price.
Public API: extract_event_id(url_or_id) -> str fetch_event_html(event_id, …) -> str parse_event(html) -> Event parse_listings(html) -> list[Listing] search_events(query, …) -> list[Event] get_performer_events(id, …) -> list[Event] SectionMatcher.parse(spec) -> SectionMatcher filter_listings(…) -> list[Listing]
Sub-modules
gametime_watcher.api-
Fetching and parsing Gametime event pages …
gametime_watcher.cli-
Command-line interface for the Gametime ticket watcher.
gametime_watcher.filters-
Filtering of listings by section, price, and quantity.
gametime_watcher.models-
Data models for Gametime listings.
Functions
def extract_event_id(url_or_id: str,
*,
user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
timeout: float = 30.0) ‑> str-
Expand source code
def extract_event_id( url_or_id: str, *, user_agent: str = DEFAULT_USER_AGENT, timeout: float = 30.0, ) -> str: """Resolve a Gametime event id from a URL, short link, or bare id. Accepts: * a bare 24-hex event id * any URL containing ``/events/<id>`` (event or listing pages) * a short link (e.g. ``https://gtix.co/...``) which is followed via HTTP redirects until an event id is found. """ value = url_or_id.strip() if _HEX24.match(value): return value m = _EVENT_ID_IN_URL.search(value) if m: return m.group(1) if "://" not in value: raise GametimeError( f"Could not find an event id in {url_or_id!r}; pass a gametime.co " "event/listing URL, a short link, or a 24-character event id." ) # Short link or other URL without an inline id: follow redirects and inspect # both the final URL and the response body. try: resp = _http_get(value, user_agent, timeout) final_url = resp.geturl() body_head = _read_body(resp)[:200_000] except urllib.error.URLError as exc: # pragma: no cover - network failure path raise GametimeError(f"Failed to resolve short link {url_or_id!r}: {exc}") from exc m = _EVENT_ID_IN_URL.search(final_url) or _EVENT_ID_IN_URL.search(body_head) if m: return m.group(1) raise GametimeError(f"Could not resolve an event id from {url_or_id!r}.")Resolve a Gametime event id from a URL, short link, or bare id.
Accepts
- a bare 24-hex event id
- any URL containing
/events/<id>(event or listing pages) - a short link (e.g.
https://gtix.co/...) which is followed via HTTP redirects until an event id is found.
def fetch_event_html(event_id: str,
*,
user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
timeout: float = 30.0,
retries: int = 3,
backoff_base: float = 1.0) ‑> str-
Expand source code
def fetch_event_html( event_id: str, *, user_agent: str = DEFAULT_USER_AGENT, timeout: float = 30.0, retries: int = 3, backoff_base: float = 1.0, ) -> str: """Download the server-rendered event page HTML for ``event_id``. Makes up to *retries* additional attempts with exponential backoff on transient HTTP errors (502, 503, 504, 429). """ url = f"https://gametime.co/events/{event_id}" last_exc: Optional[Exception] = None for attempt in range(1 + retries): try: resp = _http_get(url, user_agent, timeout) return _read_body(resp) except urllib.error.HTTPError as exc: last_exc = exc if exc.code in _RETRYABLE_HTTP_CODES and attempt < retries: time.sleep(backoff_base * (2**attempt)) continue raise GametimeError(f"Failed to fetch event page for {event_id!r}: {exc}") from exc except urllib.error.URLError as exc: # pragma: no cover - network failure path raise GametimeError(f"Failed to fetch event page for {event_id!r}: {exc}") from exc # Should not be reached, but just in case: raise GametimeError( # pragma: no cover f"Failed to fetch event page for {event_id!r}: {last_exc}" )Download the server-rendered event page HTML for
event_id.Makes up to retries additional attempts with exponential backoff on transient HTTP errors (502, 503, 504, 429).
def filter_listings(listings: Iterable[Listing],
*,
sections: Union[str, Sequence[str], SectionMatcher, None] = None,
max_price_dollars: Optional[float] = None,
quantity: Optional[int] = None,
allow_larger: bool = False) ‑> List[Listing]-
Expand source code
def filter_listings( listings: Iterable[Listing], *, sections: Union[str, Sequence[str], SectionMatcher, None] = None, max_price_dollars: Optional[float] = None, quantity: Optional[int] = None, allow_larger: bool = False, ) -> List[Listing]: """Return listings matching all provided criteria, cheapest first. Args: sections: section spec (see :class:`SectionMatcher`) or a matcher. max_price_dollars: maximum all-in price *per ticket*, in dollars. quantity: number of seats you want to buy together; only listings that offer this lot size are kept (see :meth:`Listing.can_buy`). allow_larger: if True, also keep listings offering a larger lot. """ matcher = sections if isinstance(sections, SectionMatcher) else SectionMatcher.parse(sections) result = [] for listing in listings: if not matcher.matches(listing): continue if max_price_dollars is not None and listing.price_total_dollars > max_price_dollars: continue if quantity is not None and not listing.can_buy(quantity, allow_larger=allow_larger): continue result.append(listing) result.sort(key=lambda listing: (listing.price_total, listing.section, listing.row or "")) return resultReturn listings matching all provided criteria, cheapest first.
Args
sections- section spec (see :class:
SectionMatcher) or a matcher. max_price_dollars- maximum all-in price per ticket, in dollars.
quantity- number of seats you want to buy together; only listings that
offer this lot size are kept (see :meth:
Listing.can_buy()). allow_larger- if True, also keep listings offering a larger lot.
def get_performer_events(performer_id: str,
*,
user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
timeout: float = 30.0) ‑> List[Event]-
Expand source code
def get_performer_events( performer_id: str, *, user_agent: str = DEFAULT_USER_AGENT, timeout: float = 30.0, ) -> List[Event]: """Fetch *all* upcoming events for a performer using the paginated API. This uses the ``/v1/events?performer_id=`` endpoint which returns events in pages and supports cursor-based pagination, ensuring all games are returned (not just the first ~10 from the search endpoint). Each returned :class:`Event` has ``extra["is_home"]`` set to ``True`` when the performer is the primary (home) performer for that event. """ results: List[Event] = [] cursor: Optional[str] = None max_pages = 20 # Safety limit for _ in range(max_pages): url = f"https://mobile.gametime.co/v1/events?performer_id={performer_id}&per_page=50" if cursor: url += f"&cursor={cursor}" try: resp = _http_get(url, user_agent, timeout) body = _read_body(resp) except urllib.error.URLError as exc: # pragma: no cover raise GametimeError(f"Failed to fetch events for performer {performer_id!r}: {exc}") from exc try: data = json.loads(body) except json.JSONDecodeError as exc: raise GametimeError(f"Invalid response for performer {performer_id!r}: {exc}") from exc for entry in data.get("events", []): ev = entry.get("event", entry) event_id = ev.get("id", "") event_url = f"https://gametime.co/events/{event_id}" if event_id else None # Determine if this performer is the home (primary) team performers = ev.get("performers", []) is_home = any(p.get("id") == performer_id and p.get("primary", False) for p in performers) results.append( Event( id=event_id, name=ev.get("name"), datetime_local=ev.get("datetime_local"), venue_id=ev.get("venue_id"), extra={ "url": event_url, "min_price_total": ev.get("min_price", {}).get("total"), "is_home": is_home, }, ) ) if not data.get("more"): break cursor = data.get("cursor") if not cursor: break return resultsFetch all upcoming events for a performer using the paginated API.
This uses the
/v1/events?performer_id=endpoint which returns events in pages and supports cursor-based pagination, ensuring all games are returned (not just the first ~10 from the search endpoint).Each returned :class:
Eventhasextra["is_home"]set toTruewhen the performer is the primary (home) performer for that event. def parse_event(html: str) ‑> Event | None-
Expand source code
def parse_event(html: str) -> Optional[Event]: """Extract lightweight event metadata, if present in the page. The server-rendered event object names the matchup with ``"name":"...","nameOverride":...,"performers"`` and uses camelCase ``datetimeLocal``; we anchor on those to avoid picking up unrelated ``name`` fields (performers, sections, etc.). """ name = None mname = re.search(r'"name":"((?:[^"\\]|\\.)*)","nameOverride":[^,]*,"performers"', html) if mname: name = json.loads('"%s"' % mname.group(1)) dt = None mdt = re.search(r'"datetimeLocal":"([^"]+)"', html) or re.search(r'"datetime_local":"([^"]+)"', html) if mdt: dt = mdt.group(1) venue = None mven = re.search(r'"venue_id":"([a-f0-9]{24})"', html) or re.search(r'"venueId":"([a-f0-9]{24})"', html) if mven: venue = mven.group(1) eid = None meid = re.search(r'"id":"([a-f0-9]{24})","performers"', html) if meid: eid = meid.group(1) if not any([name, dt, venue, eid]): return None return Event(id=eid or "", name=name, datetime_local=dt, venue_id=venue)Extract lightweight event metadata, if present in the page.
The server-rendered event object names the matchup with
"name":"...","nameOverride":...,"performers"and uses camelCasedatetimeLocal; we anchor on those to avoid picking up unrelatednamefields (performers, sections, etc.). def parse_listings(html: str) ‑> List[Listing]-
Expand source code
def parse_listings(html: str) -> List[Listing]: """Parse all ticket listings embedded in an event page. Each listing object in the Next.js stream begins with ``{"availableLots":`` and carries an ``id``, ``price`` block (all-in ``total`` in cents per ticket), ``seats`` and a ``spot`` describing ``section``/``row``. We split on the stable ``availableLots`` anchor and extract fields per chunk, which is resilient to key-ordering changes. """ listings: List[Listing] = [] seen = set() parts = html.split('{"availableLots":') for part in parts[1:]: chunk = part[:4000] lots_match = re.match(r"\[([0-9,\s]*)\]", chunk) if not lots_match: continue available_lots = [int(x) for x in re.findall(r"\d+", lots_match.group(1))] # All-in per-ticket price: the "total" inside the price block. price_match = re.search(r'"price":\{[^{}]*?"total":(\d+)\}', chunk) if not price_match: continue price_total = int(price_match.group(1)) section = _find_string(chunk, "section") if section is None: continue listing_id = _find_string(chunk, "id") if not listing_id or listing_id in seen: # Skip duplicates (e.g. a listing echoed in map-pin data). if listing_id and listing_id in seen: continue if listing_id: seen.add(listing_id) listings.append( Listing( id=listing_id or "", section=section, section_group=_find_string(chunk, "sectionGroup"), row=_find_string(chunk, "row"), seats=_find_str_list(chunk, "seats"), available_lots=available_lots, price_total=price_total, face_value=_find_int(chunk, "faceValue"), event_id=_find_string(chunk, "eventId"), url=_find_string(chunk, "seoUrl"), ) ) return listingsParse all ticket listings embedded in an event page.
Each listing object in the Next.js stream begins with
{"availableLots":and carries anid,priceblock (all-intotalin cents per ticket),seatsand aspotdescribingsection/row. We split on the stableavailableLotsanchor and extract fields per chunk, which is resilient to key-ordering changes. def search_events(query: str,
*,
user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
timeout: float = 30.0) ‑> List[Event]-
Expand source code
def search_events( query: str, *, user_agent: str = DEFAULT_USER_AGENT, timeout: float = 30.0, ) -> List[Event]: """Search Gametime for upcoming events matching ``query`` (e.g. a team name). Returns a list of :class:`Event` objects with ``id``, ``name``, ``datetime_local``, and ``extra["url"]`` populated. .. note:: The search endpoint only returns ~10 results. Use :func:`get_performer_events` with a performer id to retrieve *all* upcoming events for a team/performer. """ url = f"https://mobile.gametime.co/v1/search?q={urllib.request.quote(query)}" try: resp = _http_get(url, user_agent, timeout) body = _read_body(resp) except urllib.error.URLError as exc: # pragma: no cover - network failure path raise GametimeError(f"Search request failed for {query!r}: {exc}") from exc try: data = json.loads(body) except json.JSONDecodeError as exc: raise GametimeError(f"Invalid search response for {query!r}: {exc}") from exc results: List[Event] = [] for entry in data.get("events", []): ev = entry.get("event", entry) event_id = ev.get("id", "") event_url = f"https://gametime.co/events/{event_id}" if event_id else None performers = ev.get("performers", []) matched_id = _find_performer_id_in_entry(entry, query) is_home = bool(matched_id) and any(p.get("primary", False) for p in performers if p.get("id") == matched_id) results.append( Event( id=event_id, name=ev.get("name"), datetime_local=ev.get("datetime_local"), venue_id=ev.get("venue_id"), extra={ "url": event_url, "min_price_total": ev.get("min_price", {}).get("total"), "is_home": is_home, }, ) ) return resultsSearch Gametime for upcoming events matching
query(e.g. a team name).Returns a list of :class:
Eventobjects withid,name,datetime_local, andextra["url"]populated.Note
The search endpoint only returns ~10 results. Use :func:
get_performer_events()with a performer id to retrieve all upcoming events for a team/performer.
Classes
class Event (id: str,
name: Optional[str] = None,
datetime_local: Optional[str] = None,
venue_id: Optional[str] = None,
extra: dict = <factory>)-
Expand source code
@dataclass class Event: """Lightweight metadata about an event.""" id: str name: Optional[str] = None datetime_local: Optional[str] = None venue_id: Optional[str] = None extra: dict = field(default_factory=dict) def to_dict(self) -> dict: return { "id": self.id, "name": self.name, "datetime_local": self.datetime_local, "venue_id": self.venue_id, }Lightweight metadata about an event.
Instance variables
var datetime_local : str | Nonevar extra : dictvar id : strvar name : str | Nonevar venue_id : str | None
Methods
def to_dict(self) ‑> dict-
Expand source code
def to_dict(self) -> dict: return { "id": self.id, "name": self.name, "datetime_local": self.datetime_local, "venue_id": self.venue_id, }
class Listing (id: str,
section: str,
section_group: Optional[str],
row: Optional[str],
seats: List[str],
available_lots: List[int],
price_total: int,
face_value: Optional[int],
event_id: Optional[str] = None,
url: Optional[str] = None)-
Expand source code
@dataclass(frozen=True) class Listing: """A single resale ticket listing for an event. Prices are stored in whole cents (as Gametime returns them). ``price_total`` is the *all-in price per ticket* (what the buyer pays for one seat, including fees), matching the price shown on gametime.co. """ id: str section: str section_group: Optional[str] row: Optional[str] seats: List[str] # Group sizes that can be purchased, e.g. [2, 4] means you may buy 2 or 4. available_lots: List[int] price_total: int # all-in price per ticket, in cents face_value: Optional[int] # in cents, if known event_id: Optional[str] = None url: Optional[str] = None @property def price_total_dollars(self) -> float: """All-in per-ticket price in dollars.""" return self.price_total / 100.0 @property def face_value_dollars(self) -> Optional[float]: if self.face_value is None: return None return self.face_value / 100.0 @property def section_is_numeric(self) -> bool: return self.section.isdigit() @property def section_number(self) -> Optional[int]: return int(self.section) if self.section.isdigit() else None def can_buy(self, quantity: int, allow_larger: bool = False) -> bool: """Whether ``quantity`` seats can be purchased from this listing. By default Gametime only lets you buy one of the exact offered lot sizes, so this checks membership in ``available_lots``. With ``allow_larger`` it also accepts any offered lot larger than the requested quantity. """ if quantity in self.available_lots: return True if allow_larger and any(lot >= quantity for lot in self.available_lots): return True return False def to_dict(self) -> dict: return { "id": self.id, "section": self.section, "section_group": self.section_group, "row": self.row, "seats": self.seats, "available_lots": self.available_lots, "price_total_cents": self.price_total, "price_total_dollars": round(self.price_total_dollars, 2), "face_value_cents": self.face_value, "event_id": self.event_id, "url": self.url, }A single resale ticket listing for an event.
Prices are stored in whole cents (as Gametime returns them).
price_totalis the all-in price per ticket (what the buyer pays for one seat, including fees), matching the price shown on gametime.co.Instance variables
var available_lots : List[int]var event_id : str | Nonevar face_value : int | Noneprop face_value_dollars : Optional[float]-
Expand source code
@property def face_value_dollars(self) -> Optional[float]: if self.face_value is None: return None return self.face_value / 100.0 var id : strvar price_total : intprop price_total_dollars : float-
Expand source code
@property def price_total_dollars(self) -> float: """All-in per-ticket price in dollars.""" return self.price_total / 100.0All-in per-ticket price in dollars.
var row : str | Nonevar seats : List[str]var section : strvar section_group : str | Noneprop section_is_numeric : bool-
Expand source code
@property def section_is_numeric(self) -> bool: return self.section.isdigit() prop section_number : Optional[int]-
Expand source code
@property def section_number(self) -> Optional[int]: return int(self.section) if self.section.isdigit() else None var url : str | None
Methods
def can_buy(self, quantity: int, allow_larger: bool = False) ‑> bool-
Expand source code
def can_buy(self, quantity: int, allow_larger: bool = False) -> bool: """Whether ``quantity`` seats can be purchased from this listing. By default Gametime only lets you buy one of the exact offered lot sizes, so this checks membership in ``available_lots``. With ``allow_larger`` it also accepts any offered lot larger than the requested quantity. """ if quantity in self.available_lots: return True if allow_larger and any(lot >= quantity for lot in self.available_lots): return True return FalseWhether
quantityseats can be purchased from this listing.By default Gametime only lets you buy one of the exact offered lot sizes, so this checks membership in
available_lots. Withallow_largerit also accepts any offered lot larger than the requested quantity. def to_dict(self) ‑> dict-
Expand source code
def to_dict(self) -> dict: return { "id": self.id, "section": self.section, "section_group": self.section_group, "row": self.row, "seats": self.seats, "available_lots": self.available_lots, "price_total_cents": self.price_total, "price_total_dollars": round(self.price_total_dollars, 2), "face_value_cents": self.face_value, "event_id": self.event_id, "url": self.url, }
class SectionMatcher (matchers: Optional[Sequence] = None)-
Expand source code
class SectionMatcher: """Matches listings against a flexible section specification. A spec is one or more tokens (comma-separated string, or a sequence). Each token is one of: * a numeric range, e.g. ``"200-299"`` (inclusive) * an exact numeric section, e.g. ``"119"`` * a section/group name, e.g. ``"Solon Club"`` (case-insensitive; matches the section label exactly or the section group as a substring) A listing matches if it satisfies *any* token. An empty spec matches all. """ def __init__(self, matchers: Optional[Sequence] = None): self._matchers = list(matchers or []) @classmethod def parse(cls, spec: Union[str, Sequence[str], None]) -> "SectionMatcher": if spec is None: return cls([]) tokens: List[str] if isinstance(spec, str): tokens = [t for t in (s.strip() for s in spec.split(",")) if t] else: tokens = [t.strip() for t in spec if t and t.strip()] matchers = [] for token in tokens: rng = _RANGE_RE.match(token) if rng: low, high = int(rng.group(1)), int(rng.group(2)) if low > high: low, high = high, low matchers.append(_Range(low, high)) continue alpha_rng = _ALPHA_RANGE_RE.match(token) if alpha_rng: low, high = alpha_rng.group(1).upper(), alpha_rng.group(2).upper() if low > high: low, high = high, low matchers.append(_AlphaRange(low, high)) elif token.isdigit(): matchers.append(_Exact(int(token))) else: matchers.append(_Name(token)) return cls(matchers) @property def is_empty(self) -> bool: return not self._matchers def matches(self, listing: Listing) -> bool: if not self._matchers: return True return any(m.matches(listing) for m in self._matchers)Matches listings against a flexible section specification.
A spec is one or more tokens (comma-separated string, or a sequence). Each token is one of:
- a numeric range, e.g.
"200-299"(inclusive) - an exact numeric section, e.g.
"119" - a section/group name, e.g.
"Solon Club"(case-insensitive; matches the section label exactly or the section group as a substring)
A listing matches if it satisfies any token. An empty spec matches all.
Static methods
def parse(spec: Union[str, Sequence[str], None]) ‑> SectionMatcher
Instance variables
prop is_empty : bool-
Expand source code
@property def is_empty(self) -> bool: return not self._matchers
Methods
def matches(self,
listing: Listing) ‑> bool-
Expand source code
def matches(self, listing: Listing) -> bool: if not self._matchers: return True return any(m.matches(listing) for m in self._matchers)
- a numeric range, e.g.