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 result

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.
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 results

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.

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 camelCase datetimeLocal; we anchor on those to avoid picking up unrelated name fields (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 listings

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.

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 results

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.

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 | None
var extra : dict
var id : str
var name : str | None
var 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_total is 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 | None
var face_value : int | None
prop 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 : str
var price_total : int
prop 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.0

All-in per-ticket price in dollars.

var row : str | None
var seats : List[str]
var section : str
var section_group : str | None
prop 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 False

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.

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)