Source code for vlrdevapi.events.list_events

"""Event listing functionality."""

from __future__ import annotations

import datetime
from urllib import parse
from dateutil import parser as dateutil_parser
from bs4 import BeautifulSoup

from .models import EventTier, EventStatus, TierName, StatusFilter, ListEvent, _TIER_TO_ID
from .info import info as get_event_info
from ..config import get_config
from ..countries import map_country_code
from ..fetcher import fetch_html
from ..exceptions import NetworkError
from ..utils import (
    extract_text,
    extract_id_from_url,
    extract_country_code,
    split_date_range,
    parse_date,
)

_config = get_config()


def _get_event_dates_from_matches(event_id: int, timeout: float) -> tuple[datetime.date | None, datetime.date | None]:
    """
    Fetch first and last match dates from event matches page as fallback.
    
    Args:
        event_id: Event ID
        timeout: Request timeout
    
    Returns:
        Tuple of (first_match_date, last_match_date)
    """
    url = f"{_config.vlr_base}/event/matches/{event_id}"
    try:
        html = fetch_html(url, timeout)
    except NetworkError:
        return None, None
    
    soup = BeautifulSoup(html, "lxml")
    
    # Collect all match dates from the page
    match_dates: list[datetime.date] = []
    
    for card in soup.select("a.match-item"):
        # Parse date from the label above the match
        label = card.find_previous("div", class_="wf-label mod-large")
        if label:
            texts = [frag.strip() for frag in label.find_all(string=True, recursive=False)]
            text = " ".join(t for t in texts if t).strip()
            
            # Skip relative dates like "Today", "Yesterday", "Tomorrow"
            if text.lower() in ["today", "yesterday", "tomorrow"]:
                # Convert relative dates to actual dates
                today = datetime.date.today()
                if text.lower() == "today":
                    match_dates.append(today)
                elif text.lower() == "yesterday":
                    match_dates.append(today - datetime.timedelta(days=1))
                elif text.lower() == "tomorrow":
                    match_dates.append(today + datetime.timedelta(days=1))
                continue
            
            # Try parsing with common formats
            match_date = parse_date(text, [
                "%a, %B %d, %Y",  # Thu, September 7, 2023
                "%A, %B %d, %Y",  # Thursday, September 7, 2023
                "%B %d, %Y",      # September 7, 2023
                "%b %d, %Y",      # Sep 7, 2023
                "%d %B, %Y",      # 7 September, 2023
                "%d %b, %Y",      # 7 Sep, 2023
            ])
            
            if match_date:
                match_dates.append(match_date)
    
    if not match_dates:
        return None, None
    
    # Return earliest and latest dates
    return min(match_dates), max(match_dates)


[docs] def list_events( tier: EventTier | TierName = EventTier.ALL, region: str | None = None, status: EventStatus | StatusFilter = EventStatus.ALL, page: int = 1, limit: int | None = None, timeout: float | None = None, ) -> list[ListEvent]: """ List events with filters. Args: tier: Event tier (use EventTier enum or string) region: Region filter (optional) status: Event status (use EventStatus enum or string) page: Page number (1-indexed) limit: Maximum number of events to return (optional) timeout: Request timeout in seconds Returns: List of events Example: >>> import vlrdevapi as vlr >>> from vlrdevapi.events import EventTier, EventStatus >>> events = vlr.events.list_events(tier=EventTier.VCT, status=EventStatus.ONGOING, limit=10) >>> for event in events: ... print(f"{event.name} - {event.status}") """ base_params: dict[str, str] = {} tier_str = tier.value if isinstance(tier, EventTier) else tier status_str = status.value if isinstance(status, EventStatus) else status tier_id = _TIER_TO_ID.get(tier_str, "60") base_params["tier"] = tier_id if page > 1: base_params["page"] = str(page) url = f"{_config.vlr_base}/events" if base_params: url = f"{url}?{parse.urlencode(base_params)}" effective_timeout = timeout if timeout is not None else _config.default_timeout try: html = fetch_html(url, effective_timeout) except NetworkError: return [] soup = BeautifulSoup(html, "lxml") results: list[ListEvent] = [] for card in soup.select(".events-container a.event-item[href*='/event/']"): if limit is not None and len(results) >= limit: break href = card.get("href") if not href or not isinstance(href, str): continue name = extract_text(card.select_one(".event-item-title, .text-of")) or extract_text(card) if not name: continue ev_id = extract_id_from_url(href, "event") if not ev_id: continue # Parse meta date_text = None prize = None dates_el = card.select_one(".event-item-desc-item.mod-dates") if dates_el: # Extract full text from the value element to avoid cutting off year date_value_el = dates_el.select_one(".event-item-desc-item-value, .event-desc-item-value") if date_value_el: date_text = extract_text(date_value_el).strip() or None else: # Fallback to extracting from the whole element and removing label date_text = extract_text(dates_el).replace("Dates", "").strip() or None prize_el = card.select_one(".event-item-desc-item.mod-prize, .event-item-prize, .prize") if prize_el: prize = extract_text(prize_el).replace("Prize Pool", "").strip() # Parse status card_status = "upcoming" status_el = card.select_one(".event-item-desc-item-status") if status_el: classes_raw = status_el.get("class") classes: list[str] = [] if isinstance(classes_raw, list): classes = classes_raw elif isinstance(classes_raw, str): classes = [classes_raw] classes_list = classes if any("mod-completed" in str(c) for c in classes_list): card_status = "completed" elif any("mod-ongoing" in str(c) for c in classes_list): card_status = "ongoing" if status_str != "all" and card_status != status_str: continue # Parse region region_name: str | None = None flag = card.select_one(".event-item-desc-item.mod-location .flag") if flag: code = extract_country_code(card.select_one(".event-item-desc-item.mod-location")) region_name = map_country_code(code) if code else None # Parse dates - use a three-tier fallback system for accuracy start_text, end_text = split_date_range(date_text) if date_text else (None, None) start_date: datetime.date | None = None end_date: datetime.date | None = None # PRIMARY: Get accurate dates from event info page (includes proper year) # This is the most reliable source as the listing page may omit years event_info = get_event_info(ev_id, effective_timeout) if event_info: if event_info.start_date: start_date = event_info.start_date if event_info.end_date: end_date = event_info.end_date # SECONDARY: Parse dates from listing page if info() didn't return dates # This handles cases where info() fails or returns None for dates if start_date is None or end_date is None: # Try to parse start date if start_date is None and start_text: try: parsed = dateutil_parser.parse(start_text, fuzzy=False) start_date = parsed.date() except (ValueError, TypeError, dateutil_parser.ParserError): # If fuzzy=False fails, try with common formats start_date = parse_date(start_text, [ "%b %d, %Y", "%B %d, %Y", # Aug 28, 2025 or August 28, 2025 "%d %b, %Y", "%d %B, %Y", # 28 Aug, 2025 or 28 August, 2025 "%m/%d/%Y", "%d/%m/%Y", # 08/28/2025 or 28/08/2025 "%d/%b/%Y", "%d/%B/%Y", # 28/Aug/2025 or 28/August/2025 "%b %d", "%B %d", # Aug 28 or August 28 (no year) "%d %b", "%d %B", # 28 Aug or 28 August (no year) ]) # Try to parse end date if end_date is None and end_text: try: parsed = dateutil_parser.parse(end_text, fuzzy=False) end_date = parsed.date() except (ValueError, TypeError, dateutil_parser.ParserError): # If fuzzy=False fails, try with common formats end_date = parse_date(end_text, [ "%b %d, %Y", "%B %d, %Y", # Aug 28, 2025 or August 28, 2025 "%d %b, %Y", "%d %B, %Y", # 28 Aug, 2025 or 28 August, 2025 "%m/%d/%Y", "%d/%m/%Y", # 08/28/2025 or 28/08/2025 "%d/%b/%Y", "%d/%B/%Y", # 28/Aug/2025 or 28/August/2025 "%b %d", "%B %d", # Aug 28 or August 28 (no year) "%d %b", "%d %B", # 28 Aug or 28 August (no year) ]) # TERTIARY: If dates are still TBD or couldn't be parsed, fetch from matches page if (start_date is None or end_date is None) and ( date_text is None or date_text.lower() in ["tbd", "to be determined", "to be announced", "tba"] or (start_text and start_text.lower() in ["tbd", "tba"]) or (end_text and end_text.lower() in ["tbd", "tba"]) ): match_start, match_end = _get_event_dates_from_matches(ev_id, effective_timeout) if start_date is None and match_start: start_date = match_start if end_date is None and match_end: end_date = match_end results.append(ListEvent( id=ev_id, name=name, region=region_name or region, start_date=start_date, end_date=end_date, start_text=start_text, end_text=end_text, prize=prize, status=card_status, url=parse.urljoin(f"{_config.vlr_base}/", href.lstrip("/")), )) return results