diff --git a/M3U8/fetch.py b/M3U8/fetch.py index dad3746..53fbf46 100644 --- a/M3U8/fetch.py +++ b/M3U8/fetch.py @@ -3,7 +3,7 @@ import asyncio from pathlib import Path from scrapers import fstv, livetvsx, ppv, streambtw, streameast, streamed, tvpass -from scrapers.utils import CLIENT, UA, get_logger +from scrapers.utils import get_logger, network log = get_logger(__name__) @@ -12,7 +12,7 @@ BASE_FILE = Path(__file__).parent / "base.m3u8" M3U8_FILE = Path(__file__).parent / "TV.m3u8" -def vanilla_fetch() -> tuple[list[str], int]: +def load_base() -> tuple[list[str], int]: log.info("Fetching base M3U8") data = BASE_FILE.read_text(encoding="utf-8") @@ -23,19 +23,19 @@ def vanilla_fetch() -> tuple[list[str], int]: async def main() -> None: - base_m3u8, tvg_chno = vanilla_fetch() + base_m3u8, tvg_chno = load_base() tasks = [ - asyncio.create_task(fstv.scrape(CLIENT)), - asyncio.create_task(livetvsx.scrape(CLIENT)), - asyncio.create_task(ppv.scrape(CLIENT)), - asyncio.create_task(streambtw.scrape(CLIENT)), - asyncio.create_task(streameast.scrape(CLIENT)), - asyncio.create_task(streamed.scrape(CLIENT)), - asyncio.create_task(tvpass.scrape(CLIENT)), + asyncio.create_task(fstv.scrape(network.client)), + asyncio.create_task(livetvsx.scrape(network.client)), + asyncio.create_task(ppv.scrape(network.client)), + asyncio.create_task(streambtw.scrape(network.client)), + asyncio.create_task(streameast.scrape(network.client)), + asyncio.create_task(streamed.scrape(network.client)), + asyncio.create_task(tvpass.scrape(network.client)), ] - await asyncio.gather(*tasks) + await asyncio.gather(*tasks, return_exceptions=True) additions = ( fstv.urls @@ -55,10 +55,12 @@ async def main() -> None: ): live_events.extend( ( - f'\n#EXTINF:-1 tvg-chno="{chnl_num}" tvg-id="{info["id"]}" tvg-name="{event}" tvg-logo="{info["logo"]}" group-title="Live Events",{event}', + f'\n#EXTINF:-1 tvg-chno="{chnl_num}" tvg-id="{info["id"]}" ', + f'tvg-name="{event}" tvg-logo="{info["logo"]}" ', + f'group-title="Live Events",{event}', f'#EXTVLCOPT:http-referrer={info["base"]}', f'#EXTVLCOPT:http-origin={info["base"]}', - f"#EXTVLCOPT:http-user-agent={UA}", + f"#EXTVLCOPT:http-user-agent={network.UA}", info["url"], ) ) diff --git a/M3U8/scrapers/fstv.py b/M3U8/scrapers/fstv.py index ff84943..e27564f 100644 --- a/M3U8/scrapers/fstv.py +++ b/M3U8/scrapers/fstv.py @@ -4,15 +4,7 @@ from urllib.parse import unquote, urljoin import httpx from selectolax.parser import HTMLParser -from .utils import ( - get_base, - get_logger, - leagues, - load_cache, - now, - safe_process_event, - write_cache, -) +from .utils import Cache, Time, get_logger, leagues, network log = get_logger(__name__) @@ -25,7 +17,7 @@ MIRRORS = [ "https://fstv.us", ] -CACHE_FILE = Path(__file__).parent / "caches" / "fstv.json" +CACHE_FILE = Cache(Path(__file__).parent / "caches" / "fstv.json", exp=10_800) async def get_events( @@ -39,7 +31,7 @@ async def get_events( r = await client.get(base_url) r.raise_for_status() except Exception as e: - log.error(f'Failed to fetch "{base_url}"\n{e}') + log.error(f'Failed to fetch "{base_url}": {e}') return [] @@ -115,20 +107,20 @@ async def process_event( if src := ifr.attributes.get("src", ""): log.info(f"URL {url_num}) Captured M3U8") - return match_name, unquote(src).split("link=")[-1] + return match_name or "", unquote(src).split("link=")[-1] async def scrape(client: httpx.AsyncClient) -> None: - cached_urls = load_cache(CACHE_FILE, exp=10_800) + cached_urls = CACHE_FILE.load() cached_hrefs = {entry["href"] for entry in cached_urls.values()} cached_count = len(cached_urls) urls.update(cached_urls) - log.info(f"Collected {cached_count} event(s) from cache") + log.info(f"Loaded {cached_count} event(s) from cache") - if not (base_url := await get_base(client, MIRRORS)): + if not (base_url := await network.get_base(MIRRORS)): log.warning("No working FSTV mirrors") - write_cache(CACHE_FILE, cached_urls) + CACHE_FILE.write(cached_urls) return events = await get_events( @@ -140,7 +132,7 @@ async def scrape(client: httpx.AsyncClient) -> None: log.info(f"Processing {len(events)} new URL(s)") for i, ev in enumerate(events, start=1): - match_name, url = await safe_process_event( + match_name, url = await network.safe_process( lambda: process_event( client, ev["link"], @@ -163,8 +155,8 @@ async def scrape(client: httpx.AsyncClient) -> None: "url": url, "logo": logo, "base": base_url, - "timestamp": now.timestamp(), - "id": tvg_id, + "timestamp": Time.now().timestamp(), + "id": tvg_id or "Live.Event.us", "href": ev["href"], } @@ -175,4 +167,4 @@ async def scrape(client: httpx.AsyncClient) -> None: else: log.info("No new events found") - write_cache(CACHE_FILE, cached_urls) + CACHE_FILE.write(cached_urls) diff --git a/M3U8/scrapers/livetvsx.py b/M3U8/scrapers/livetvsx.py index f9cf5fa..c128186 100644 --- a/M3U8/scrapers/livetvsx.py +++ b/M3U8/scrapers/livetvsx.py @@ -2,24 +2,14 @@ import asyncio import io import ssl import xml.etree.ElementTree as ET -from datetime import datetime, timedelta +from datetime import timedelta from functools import partial from pathlib import Path import httpx from playwright.async_api import async_playwright -from .utils import ( - TZ, - capture_req, - get_logger, - leagues, - load_cache, - new_browser, - now, - safe_process_event, - write_cache, -) +from .utils import Cache, Time, get_logger, leagues, network log = get_logger(__name__) @@ -36,7 +26,7 @@ CERT_BUNDLE_URLS = [ CERT_FILE = Path(__file__).parent / "caches" / "cached-cert.pem" -CACHE_FILE = Path(__file__).parent / "caches" / "livetvsx.json" +CACHE_FILE = Cache(Path(__file__).parent / "caches" / "livetvsx.json", exp=10_800) async def write_to_cert( @@ -64,9 +54,9 @@ async def refresh_cert_cache(client: httpx.AsyncClient) -> ssl.SSLContext: async def get_cert(client: httpx.AsyncClient) -> ssl.SSLContext: if CERT_FILE.is_file(): - mtime = datetime.fromtimestamp(CERT_FILE.stat().st_mtime, TZ) + mtime = Time.from_ts(CERT_FILE.stat().st_mtime) - if now - mtime < timedelta(days=30): + if Time.now() - mtime < timedelta(days=30): return ssl.create_default_context(cafile=CERT_FILE) log.info("Refreshing cached certificate") @@ -101,7 +91,7 @@ async def fetch_xml_stream(url: str, ssl_ctx: ssl.SSLContext) -> io.BytesIO | No async def process_event(url: str, url_num: int) -> str | None: async with async_playwright() as p: - browser, context = await new_browser(p, ignore_https_errors=True) + browser, context = await network.browser(p, ignore_https_errors=True) page = await context.new_page() @@ -109,7 +99,7 @@ async def process_event(url: str, url_num: int) -> str | None: got_one = asyncio.Event() - handler = partial(capture_req, captured=captured, got_one=got_one) + handler = partial(network.capture_req, captured=captured, got_one=got_one) popup = None @@ -149,9 +139,7 @@ async def process_event(url: str, url_num: int) -> str | None: try: await link_img.click() except Exception as e: - log.debug( - f"URL {url_num}) Click failed (popup might have already been opened): {e}" - ) + log.debug(f"URL {url_num}) Click failed: {e}") popup = await popup_info.value @@ -194,8 +182,9 @@ async def process_event(url: str, url_num: int) -> str | None: return captured[-1] - log.warning(f"URL {url_num}) No M3U8 captured in popup or inline playback.") + log.warning(f"URL {url_num}) No M3U8 captured") return + except Exception: try: page.remove_listener("request", handler) @@ -220,73 +209,84 @@ async def get_events( events: list[dict[str, str]] = [] - start_dt = now - timedelta(minutes=30) - end_dt = now + timedelta(minutes=30) + now = Time.now() - if buffer := await fetch_xml_stream(url, ssl_ctx): - pub_date_format = "%a, %d %b %Y %H:%M:%S %z" + start_dt = now.delta(minutes=-30) + end_dt = now.delta(minutes=30) - for _, elem in ET.iterparse(buffer, events=("end",)): - if elem.tag == "item": - title = elem.findtext("title") - desc = elem.findtext("description") - pub_date = elem.findtext("pubDate") - link = elem.findtext("link") + if not (buffer := await fetch_xml_stream(url, ssl_ctx)): + return events - try: - dt = datetime.strptime(pub_date, pub_date_format) - dt = dt.astimezone(TZ) - except Exception: - elem.clear() - continue + pub_date_format = "%a, %d %b %Y %H:%M:%S %z" - if not start_dt <= dt <= end_dt: - elem.clear() - continue - - sport, event = ( - ( - desc.split(".")[0].strip(), - " ".join(p.strip() for p in desc.split(".")[1:]), - ) - if desc - else ("", "") - ) - - key = f"[{sport}: {event}] {title} (LTVSX)" - - if cached_keys & {key}: - elem.clear() - continue - - events.append( - { - "sport": sport, - "event": event, - "title": title, - "link": link, - } - ) + for _, elem in ET.iterparse(buffer, events=("end",)): + if elem.tag == "item": + title = elem.findtext("title") or "" + desc = elem.findtext("description") or "" + pub_date = elem.findtext("pubDate") or "" + link = elem.findtext("link") or "" + if not all([title, pub_date, link]): elem.clear() + continue + + try: + event_dt = Time.from_str(pub_date, pub_date_format) + except Exception: + elem.clear() + continue + + if not start_dt <= event_dt <= end_dt: + elem.clear() + continue + + if desc: + parts = desc.split(".") + sport = parts[0].strip() if parts else "" + event = parts[1].strip() if parts else "" + else: + sport, event = "", "" + + key = f"[{sport}: {event}] {title} (LTVSX)" + + if cached_keys & {key}: + elem.clear() + continue + + events.append( + { + "sport": sport, + "event": event, + "title": title, + "link": link, + "timestamp": event_dt.timestamp(), + } + ) + + elem.clear() return events async def scrape(client: httpx.AsyncClient) -> None: - cached_urls = load_cache(CACHE_FILE, exp=10_800) + cached_urls = CACHE_FILE.load() cached_count = len(cached_urls) urls.update(cached_urls) - log.info(f"Collected {cached_count} event(s) from cache") + log.info(f"Loaded {cached_count} event(s) from cache") log.info(f'Scraping from "{BASE_URL}"') - cert = await get_cert(client) + ssl_ctx = await get_cert(client) + + if not ssl_ctx: + log.error("Failed to create SSL context, aborting") + CACHE_FILE.write(cached_urls) + return events = await get_events( BASE_URL, - cert, + ssl_ctx, set(cached_urls.keys()), ) @@ -295,16 +295,19 @@ async def scrape(client: httpx.AsyncClient) -> None: for i, ev in enumerate(events, start=1): link = ev["link"] - url = await safe_process_event( + url = await network.safe_process( lambda: process_event(link, url_num=i), url_num=i, log=log, ) if url: - sport = ev["sport"] - event = ev["event"] - title = ev["title"] + sport, event, title, ts = ( + ev["sport"], + ev["event"], + ev["title"], + ev["timestamp"], + ) key = f"[{sport}: {event}] {title} (LTVSX)" @@ -316,9 +319,9 @@ async def scrape(client: httpx.AsyncClient) -> None: entry = { "url": url, "logo": logo, - "id": tvg_id, + "id": tvg_id or "Live.Event.us", "base": "https://livetv.sx/enx/", - "timestamp": now.timestamp(), + "timestamp": ts, } urls[key] = cached_urls[key] = entry @@ -328,4 +331,4 @@ async def scrape(client: httpx.AsyncClient) -> None: else: log.info("No new events found") - write_cache(CACHE_FILE, cached_urls) + CACHE_FILE.write(cached_urls) diff --git a/M3U8/scrapers/ppv.py b/M3U8/scrapers/ppv.py index 489ed0f..a0369e0 100644 --- a/M3U8/scrapers/ppv.py +++ b/M3U8/scrapers/ppv.py @@ -1,5 +1,4 @@ import asyncio -from datetime import datetime, timedelta from functools import partial from pathlib import Path from urllib.parse import urljoin @@ -7,26 +6,15 @@ from urllib.parse import urljoin import httpx from playwright.async_api import async_playwright -from .utils import ( - TZ, - capture_req, - get_base, - get_logger, - leagues, - load_cache, - new_browser, - now, - safe_process_event, - write_cache, -) +from .utils import Cache, Time, get_logger, leagues, network log = get_logger(__name__) urls: dict[str, dict[str, str | float]] = {} -API_FILE = Path(__file__).parent / "caches" / "ppv_api.json" +API_FILE = Cache(Path(__file__).parent / "caches" / "ppv_api.json", exp=86_400) -CACHE_FILE = Path(__file__).parent / "caches" / "ppv.json" +CACHE_FILE = Cache(Path(__file__).parent / "caches" / "ppv.json", exp=10_800) MIRRORS = [ "https://ppvs.su", @@ -37,7 +25,7 @@ MIRRORS = [ ] -def get_tvg(sport: str, event: str) -> str | None: +def get_tvg(sport: str, event: str) -> str: match sport: case "American Football": if leagues.is_valid(event, "NFL"): @@ -79,7 +67,7 @@ async def refresh_api_cache( async def process_event(url: str, url_num: int) -> str | None: async with async_playwright() as p: - browser, context = await new_browser(p) + browser, context = await network.browser(p) page = await context.new_page() @@ -87,12 +75,16 @@ async def process_event(url: str, url_num: int) -> str | None: got_one = asyncio.Event() - handler = partial(capture_req, captured=captured, got_one=got_one) + handler = partial(network.capture_req, captured=captured, got_one=got_one) page.on("request", handler) try: - await page.goto(url, wait_until="domcontentloaded", timeout=15_000) + await page.goto( + url, + wait_until="domcontentloaded", + timeout=15_000, + ) wait_task = asyncio.create_task(got_one.wait()) @@ -137,25 +129,18 @@ async def get_events( events: list[dict[str, str]] = [] - if not ( - api_data := load_cache( - API_FILE, - exp=86_400, - nearest_hr=True, - per_entry=False, - ) - ): + if not (api_data := API_FILE.load(nearest_hr=True, per_entry=False)): api_data = await refresh_api_cache(client, urljoin(base_url, "api/streams")) - write_cache(API_FILE, api_data) + API_FILE.write(api_data) - for stream_group in api_data["streams"]: - sport = stream_group["category"] + for stream_group in api_data.get("streams", []): + sport = stream_group("category", []) if sport == "24/7 Streams": continue - for event in stream_group["streams"]: + for event in stream_group.get("streams", []): name, start_ts, end_ts, logo, uri_name = ( event["name"], event["starts_at"], @@ -169,11 +154,11 @@ async def get_events( if cached_keys & {key}: continue - start_dt = datetime.fromtimestamp(start_ts, tz=TZ) - timedelta(minutes=30) + start_dt = Time.from_ts(start_ts).delta(minutes=-30) - end_dt = datetime.fromtimestamp(end_ts, tz=TZ) + timedelta(minutes=30) + end_dt = Time.from_ts(end_ts).delta(minutes=30) - if not start_dt <= now < end_dt: + if not start_dt <= Time.now() < end_dt: continue events.append( @@ -182,6 +167,7 @@ async def get_events( "event": name, "link": urljoin(base_url, f"live/{uri_name}"), "logo": logo, + "timestamp": start_dt.timestamp(), } ) @@ -189,15 +175,15 @@ async def get_events( async def scrape(client: httpx.AsyncClient) -> None: - cached_urls = load_cache(CACHE_FILE, exp=10_800) + cached_urls = CACHE_FILE.load() cached_count = len(cached_urls) urls.update(cached_urls) - log.info(f"Collected {cached_count} event(s) from cache") + log.info(f"Loaded {cached_count} event(s) from cache") - if not (base_url := await get_base(client, MIRRORS)): + if not (base_url := await network.get_base(MIRRORS)): log.warning("No working PPV mirrors") - write_cache(CACHE_FILE, cached_urls) + CACHE_FILE.write(cached_urls) return log.info(f'Scraping from "{base_url}"') @@ -211,14 +197,19 @@ async def scrape(client: httpx.AsyncClient) -> None: log.info(f"Processing {len(events)} new URL(s)") for i, ev in enumerate(events, start=1): - url = await safe_process_event( + url = await network.safe_process( lambda: process_event(ev["link"], url_num=i), url_num=i, log=log, ) if url: - sport, event, logo = ev["sport"], ev["event"], ev["logo"] + sport, event, logo, ts = ( + ev["sport"], + ev["event"], + ev["logo"], + ev["timestamp"], + ) key = f"[{sport}] {event} (PPV)" @@ -226,7 +217,7 @@ async def scrape(client: httpx.AsyncClient) -> None: "url": url, "logo": logo, "base": base_url, - "timestamp": now.timestamp(), + "timestamp": ts, "id": get_tvg(sport, event) or "Live.Event.us", } @@ -237,4 +228,4 @@ async def scrape(client: httpx.AsyncClient) -> None: else: log.info("No new events found") - write_cache(CACHE_FILE, cached_urls) + CACHE_FILE.write(cached_urls) diff --git a/M3U8/scrapers/streambtw.py b/M3U8/scrapers/streambtw.py index aeb3df1..4b898c2 100644 --- a/M3U8/scrapers/streambtw.py +++ b/M3U8/scrapers/streambtw.py @@ -5,7 +5,7 @@ from urllib.parse import urljoin import httpx from selectolax.parser import HTMLParser -from .utils import get_logger, leagues, load_cache, now, safe_process_event, write_cache +from .utils import Cache, Time, get_logger, leagues, network log = get_logger(__name__) @@ -13,7 +13,7 @@ urls: dict[str, dict[str, str]] = {} BASE_URL = "https://streambtw.com/" -CACHE_FILE = Path(__file__).parent / "caches" / "streambtw.json" +CACHE_FILE = Cache(Path(__file__).parent / "caches" / "streambtw.json", exp=86_400) async def process_event( @@ -45,7 +45,7 @@ async def get_events(client: httpx.AsyncClient) -> list[dict[str, str]]: r = await client.get(BASE_URL) r.raise_for_status() except Exception as e: - log.error(f'Failed to fetch "{BASE_URL}"\n{e}') + log.error(f'Failed to fetch "{BASE_URL}": {e}') return [] @@ -60,22 +60,24 @@ async def get_events(client: httpx.AsyncClient) -> list[dict[str, str]]: link = card.css_first("a.btn.btn-primary") - if href := link.attrs.get("href"): - events.append( - { - "sport": sport, - "event": name, - "link": urljoin(BASE_URL, href), - } - ) + if not (href := link.attrs.get("href")): + continue + + events.append( + { + "sport": sport, + "event": name, + "link": urljoin(BASE_URL, href), + } + ) return events async def scrape(client: httpx.AsyncClient) -> None: - if cached := load_cache(CACHE_FILE, exp=86_400, nearest_hr=True): + if cached := CACHE_FILE.load(): urls.update(cached) - log.info(f"Collected {len(urls)} event(s) from cache") + log.info(f"Loaded {len(urls)} event(s) from cache") return log.info(f'Scraping from "{BASE_URL}"') @@ -85,10 +87,11 @@ async def scrape(client: httpx.AsyncClient) -> None: log.info(f"Processing {len(events)} new URL(s)") for i, ev in enumerate(events, start=1): - url = await safe_process_event( + url = await network.safe_process( lambda: process_event(client, url=ev["link"], url_num=i), url_num=i, log=log, + timeout=10, ) if url: @@ -102,12 +105,12 @@ async def scrape(client: httpx.AsyncClient) -> None: "url": url, "logo": logo, "base": BASE_URL, - "timestamp": now.timestamp(), - "id": tvg_id, + "timestamp": Time.now().timestamp(), + "id": tvg_id or "Live.Event.us", } urls[key] = entry log.info(f"Collected {len(urls)} event(s)") - write_cache(CACHE_FILE, urls) + CACHE_FILE.write(urls) diff --git a/M3U8/scrapers/streameast.py b/M3U8/scrapers/streameast.py index c5a1218..09dfab2 100644 --- a/M3U8/scrapers/streameast.py +++ b/M3U8/scrapers/streameast.py @@ -1,5 +1,4 @@ import asyncio -from datetime import datetime, timedelta from functools import partial from pathlib import Path from urllib.parse import urljoin @@ -8,24 +7,13 @@ import httpx from playwright.async_api import async_playwright from selectolax.parser import HTMLParser -from .utils import ( - TZ, - capture_req, - get_base, - get_logger, - leagues, - load_cache, - new_browser, - now, - safe_process_event, - write_cache, -) +from .utils import Cache, Time, get_logger, leagues, network log = get_logger(__name__) urls: dict[str, dict[str, str | float]] = {} -CACHE_FILE = Path(__file__).parent / "caches" / "streameast.json" +CACHE_FILE = Cache(Path(__file__).parent / "caches" / "streameast.json", exp=10_800) MIRRORS = [ "https://streameast.ga", @@ -48,7 +36,7 @@ MIRRORS = [ async def process_event(url: str, url_num: int) -> str | None: async with async_playwright() as p: - browser, context = await new_browser(p, browser="brave") + browser, context = await network.browser(p, browser="brave") page = await context.new_page() @@ -56,7 +44,7 @@ async def process_event(url: str, url_num: int) -> str | None: got_one = asyncio.Event() - handler = partial(capture_req, captured=captured, got_one=got_one) + handler = partial(network.capture_req, captured=captured, got_one=got_one) page.on("request", handler) @@ -114,8 +102,10 @@ async def get_events( soup = HTMLParser(r.text) events = [] - start_dt = now - timedelta(minutes=30) - end_dt = now + timedelta(minutes=30) + now = Time.now() + + start_dt = now.delta(minutes=-30) + end_dt = now.delta(minutes=30) for section in soup.css("div.se-sport-section"): if not (sport := section.attributes.get("data-sport-name", "").strip()): @@ -147,7 +137,7 @@ async def get_events( if cached_keys & {key}: continue - event_dt = datetime.fromtimestamp(timestamp, TZ) + event_dt = Time.from_ts(timestamp) if time_text == "LIVE" or (start_dt <= event_dt < end_dt): events.append( @@ -155,6 +145,7 @@ async def get_events( "sport": sport, "event": name, "link": href, + "timestamp": timestamp, } ) @@ -162,15 +153,15 @@ async def get_events( async def scrape(client: httpx.AsyncClient) -> None: - cached_urls = load_cache(CACHE_FILE, exp=10_800) + cached_urls = CACHE_FILE.load() cached_count = len(cached_urls) urls.update(cached_urls) - log.info(f"Collected {cached_count} event(s) from cache") + log.info(f"Loaded {cached_count} event(s) from cache") - if not (base_url := await get_base(client, MIRRORS)): + if not (base_url := await network.get_base(MIRRORS)): log.warning("No working StreamEast mirrors") - write_cache(CACHE_FILE, cached_urls) + CACHE_FILE.write(cached_urls) return log.info(f'Scraping from "{base_url}"') @@ -184,14 +175,14 @@ async def scrape(client: httpx.AsyncClient) -> None: log.info(f"Processing {len(events)} new URL(s)") for i, ev in enumerate(events, start=1): - url = await safe_process_event( + url = await network.safe_process( lambda: process_event(ev["link"], url_num=i), url_num=i, log=log, ) if url: - sport, event = ev["sport"], ev["event"] + sport, event, ts = ev["sport"], ev["event"], ev["timestamp"] tvg_id, logo = leagues.info(sport) @@ -205,8 +196,8 @@ async def scrape(client: httpx.AsyncClient) -> None: "url": url, "logo": logo, "base": base_url, - "timestamp": now.timestamp(), - "id": tvg_id, + "timestamp": ts, + "id": tvg_id or "Live.Event.us", } urls[key] = cached_urls[key] = entry @@ -216,4 +207,4 @@ async def scrape(client: httpx.AsyncClient) -> None: else: log.info("No new events found") - write_cache(CACHE_FILE, cached_urls) + CACHE_FILE.write(cached_urls) diff --git a/M3U8/scrapers/streamed.py b/M3U8/scrapers/streamed.py index 25f0174..a97e1fc 100644 --- a/M3U8/scrapers/streamed.py +++ b/M3U8/scrapers/streamed.py @@ -1,6 +1,5 @@ import asyncio import re -from datetime import datetime, timedelta from functools import partial from pathlib import Path @@ -8,40 +7,22 @@ import httpx from playwright.async_api import async_playwright from selectolax.parser import HTMLParser -from .utils import ( - TZ, - capture_req, - get_logger, - leagues, - load_cache, - new_browser, - now, - safe_process_event, - write_cache, -) +from .utils import Cache, Time, get_logger, leagues, network log = get_logger(__name__) urls: dict[str, dict[str, str | float]] = {} -CACHE_FILE = Path(__file__).parent / "caches" / "streamed.json" +CACHE_FILE = Cache(Path(__file__).parent / "caches" / "streamed.json", exp=10_800) -HTML_CACHE = Path(__file__).parent / "caches" / "streamed_php.json" +HTML_CACHE = Cache(Path(__file__).parent / "caches" / "streamed_php.json", exp=86_400) -base_url = "https://streamed.site/webmaster.php" - - -def get_date(s: str) -> datetime: - try: - return datetime.strptime(s, "%Y-%m-%d %H:%M %Z").astimezone(TZ) - except ValueError: - s = s.replace("ET", "").strip() - return datetime.strptime(s, "%Y-%m-%d %H:%M").astimezone(TZ) +BASE_URL = "https://streamed.site/webmaster.php" async def process_event(url: str, url_num: int) -> str | None: async with async_playwright() as p: - browser, context = await new_browser(p, browser="brave") + browser, context = await network.browser(p, browser="brave") page = await context.new_page() @@ -49,7 +30,7 @@ async def process_event(url: str, url_num: int) -> str | None: got_one = asyncio.Event() - handler = partial(capture_req, captured=captured, got_one=got_one) + handler = partial(network.capture_req, captured=captured, got_one=got_one) page.on("request", handler) @@ -102,12 +83,13 @@ async def refresh_html_cache(client: httpx.AsyncClient, url: str) -> dict[str, s soup = HTMLParser(r.text) events = {} + now = Time.now().to_tz("EST") for row in soup.css("div.wrap div.row"): if not (date := row.css_first("div.date")): continue - event_dt = get_date(date.text(strip=True)) + event_dt = Time.from_str(date.text(strip=True)).to_tz("EST") if event_dt.date() != now.date(): continue @@ -134,8 +116,8 @@ async def refresh_html_cache(client: httpx.AsyncClient, url: str) -> dict[str, s "sport": sport, "event": event, "link": f"https://streamed.site/set.php?{m[1]}", - "event_ts": event_dt.timestamp(), "timestamp": now.timestamp(), + "event_ts": event_dt.timestamp(), } return events @@ -147,13 +129,15 @@ async def get_events( cached_keys: set[str], ) -> list[dict[str, str]]: - if not (events := load_cache(HTML_CACHE, exp=86_400, nearest_hr=True)): + if not (events := HTML_CACHE.load(nearest_hr=True)): events = await refresh_html_cache(client, url) - write_cache(HTML_CACHE, events) + HTML_CACHE.write(events) live = [] - start_ts = (now - timedelta(minutes=30)).timestamp() - end_ts = (now + timedelta(minutes=30)).timestamp() + now = Time.now().to_tz("EST") + + start_ts = now.delta(minutes=-30).to_tz("EST").timestamp() + end_ts = now.delta(minutes=30).to_tz("EST").timestamp() for k, v in events.items(): if cached_keys & {k}: @@ -168,31 +152,31 @@ async def get_events( async def scrape(client: httpx.AsyncClient) -> None: - cached_urls = load_cache(CACHE_FILE, exp=10_800) + cached_urls = CACHE_FILE.load() cached_count = len(cached_urls) urls.update(cached_urls) - log.info(f"Collected {cached_count} event(s) from cache") + log.info(f"Loaded {cached_count} event(s) from cache") - log.info(f'Scraping from "{base_url}"') + log.info(f'Scraping from "{BASE_URL}"') events = await get_events( client, - base_url, + BASE_URL, set(cached_urls.keys()), ) log.info(f"Processing {len(events)} new URL(s)") for i, ev in enumerate(events, start=1): - url = await safe_process_event( + url = await network.safe_process( lambda: process_event(ev["link"], url_num=i), url_num=i, log=log, ) if url: - sport, event = ev["sport"], ev["event"] + sport, event, ts = ev["sport"], ev["event"], ev["timestamp"] tvg_id, logo = leagues.info(sport) @@ -202,8 +186,8 @@ async def scrape(client: httpx.AsyncClient) -> None: "url": url, "logo": logo, "base": "https://streamed.site/", - "timestamp": now.timestamp(), - "id": tvg_id, + "timestamp": ts, + "id": tvg_id or "Live.Event.us", } urls[key] = cached_urls[key] = entry @@ -213,4 +197,4 @@ async def scrape(client: httpx.AsyncClient) -> None: else: log.info("No new events found") - write_cache(CACHE_FILE, cached_urls) + CACHE_FILE.write(cached_urls) diff --git a/M3U8/scrapers/tvpass.py b/M3U8/scrapers/tvpass.py index 703b487..d3e3814 100644 --- a/M3U8/scrapers/tvpass.py +++ b/M3U8/scrapers/tvpass.py @@ -3,15 +3,15 @@ from pathlib import Path import httpx -from .utils import get_logger, leagues, load_cache, now, write_cache +from .utils import Cache, Time, get_logger, leagues -log = get_logger(__name__) +log = get_logger() urls: dict[str, dict[str, str]] = {} BASE_URL = "https://tvpass.org/playlist/m3u" -CACHE_FILE = Path(__file__).parent / "caches" / "tvpass.json" +CACHE_FILE = Cache(Path(__file__).parent / "caches" / "tvpass.json", exp=86_400) async def fetch_m3u8(client: httpx.AsyncClient) -> list[str]: @@ -19,21 +19,25 @@ async def fetch_m3u8(client: httpx.AsyncClient) -> list[str]: r = await client.get(BASE_URL) r.raise_for_status() except Exception as e: - log.error(f'Failed to fetch "{BASE_URL}"\n{e}') + log.error(f'Failed to fetch "{BASE_URL}": {e}') return [] return r.text.splitlines() async def scrape(client: httpx.AsyncClient) -> None: - if cached := load_cache(CACHE_FILE, exp=86_400, nearest_hr=True): + if cached := CACHE_FILE.load(nearest_hr=True): urls.update(cached) - log.info(f"Collected {len(urls)} event(s) from cache") + log.info(f"Loaded {len(urls)} event(s) from cache") return log.info(f'Scraping from "{BASE_URL}"') - for i, line in enumerate(data := await fetch_m3u8(client)): + if not (data := await fetch_m3u8(client)): + log.warning("No M3U8 data received") + return + + for i, line in enumerate(data): if line.startswith("#EXTINF"): tvg_id_match = re.search(r'tvg-id="([^"]*)"', line) tvg_name_match = re.search(r'tvg-name="([^"]*)"', line) @@ -56,13 +60,13 @@ async def scrape(client: httpx.AsyncClient) -> None: entry = { "url": f"http://origin.thetvapp.to/hls/{channel}/mono.m3u8", "logo": logo, - "id": tvg_id, + "id": tvg_id or "Live.Event.us", "base": "https://tvpass.org", - "timestamp": now.timestamp(), + "timestamp": Time.now().timestamp(), } urls[key] = entry - write_cache(CACHE_FILE, urls) + CACHE_FILE.write(urls) log.info(f"Cached {len(urls)} event(s)") diff --git a/M3U8/scrapers/utils/__init__.py b/M3U8/scrapers/utils/__init__.py index e15904a..8a279b5 100644 --- a/M3U8/scrapers/utils/__init__.py +++ b/M3U8/scrapers/utils/__init__.py @@ -1,19 +1,12 @@ -from .cache import load_cache, write_cache -from .config import TZ, leagues, now +from .caching import Cache +from .config import Time, leagues from .logger import get_logger -from .network import CLIENT, UA, capture_req, get_base, new_browser, safe_process_event +from .webwork import network __all__ = [ - "CLIENT", - "TZ", - "UA", - "capture_req", - "get_base", + "Cache", + "Time", "get_logger", "leagues", - "load_cache", - "new_browser", - "now", - "safe_process_event", - "write_cache", + "network", ] diff --git a/M3U8/scrapers/utils/cache.py b/M3U8/scrapers/utils/cache.py deleted file mode 100644 index 82f811c..0000000 --- a/M3U8/scrapers/utils/cache.py +++ /dev/null @@ -1,50 +0,0 @@ -import json -from datetime import datetime -from pathlib import Path - -from .config import now - - -def near_hr(dt: datetime) -> float: - return dt.replace(minute=0, second=0, microsecond=0).timestamp() - - -def is_fresh( - entry: dict, - nearest_hr: bool, - exp: int, -) -> bool: - ts: float | int = entry.get("timestamp", 31496400) - - if nearest_hr: - ts = near_hr(datetime.fromtimestamp(ts)) - - return now.timestamp() - ts < exp - - -def load_cache( - file: Path, - exp: int | float, - nearest_hr: bool = False, - per_entry: bool = True, -) -> dict[str, dict[str, str | float]]: - try: - data: dict = json.loads(file.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError): - return {} - - if per_entry: - return {k: v for k, v in data.items() if is_fresh(v, nearest_hr, exp)} - - ts: float | int = data.get("timestamp", 31496400) - - if nearest_hr: - ts = near_hr(datetime.fromtimestamp(ts)) - - return data if now.timestamp() - ts < exp else {} - - -def write_cache(file: Path, data: dict) -> None: - file.parent.mkdir(parents=True, exist_ok=True) - - file.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/M3U8/scrapers/utils/caching.py b/M3U8/scrapers/utils/caching.py new file mode 100644 index 0000000..f6fee98 --- /dev/null +++ b/M3U8/scrapers/utils/caching.py @@ -0,0 +1,65 @@ +import json +from datetime import datetime +from pathlib import Path + +from .config import Time + + +class Cache: + def __init__(self, file: Path, exp: int | float) -> None: + self.file = file + self.exp = exp + + @staticmethod + def near_hr(dt: datetime) -> float: + return dt.replace(minute=0, second=0, microsecond=0).timestamp() + + def is_fresh( + self, + entry: dict, + nearest_hr: bool, + ) -> bool: + ts: float | int = entry.get("timestamp", 31496400) + + if nearest_hr: + ts = self.near_hr(Time.from_ts(ts)) + + return Time.now().timestamp() - ts < self.exp + + def load( + self, + nearest_hr: bool = False, + per_entry: bool = True, + ) -> dict[str, dict[str, str | float]]: + try: + data: dict = json.loads(self.file.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + if not data: + return {} + + if per_entry: + return {k: v for k, v in data.items() if self.is_fresh(v, nearest_hr)} + + ts: float | int = data.get("timestamp", 31496400) + + if nearest_hr: + ts = self.near_hr(Time.from_ts(ts)) + + return data if self.is_fresh({"timestamp": ts}, False) else {} + + def write(self, data: dict) -> None: + self.file.parent.mkdir(parents=True, exist_ok=True) + + self.file.write_text( + json.dumps( + data, + indent=2, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + +__all__ = ["Cache"] diff --git a/M3U8/scrapers/utils/config.py b/M3U8/scrapers/utils/config.py index 8d01b99..871930c 100644 --- a/M3U8/scrapers/utils/config.py +++ b/M3U8/scrapers/utils/config.py @@ -1,27 +1,79 @@ import json import re -from datetime import datetime +from datetime import datetime, timedelta, timezone from pathlib import Path import pytz -TZ = pytz.timezone("America/New_York") +ZONES = {"ET": pytz.timezone("America/New_York"), "UTC": timezone.utc} -now = datetime.now(TZ) +ZONES["EDT"] = ZONES["EST"] = ZONES["ET"] -live_img = "https://i.gyazo.com/978f2eb4a199ca5b56b447aded0cb9e3.png" -leagues_file = Path(__file__).parent / "leagues.json" +class Time(datetime): + TZ = timezone.utc + + @classmethod + def now(cls) -> "Time": + dt = datetime.now(cls.TZ) + return cls.fromtimestamp(dt.timestamp(), tz=cls.TZ) + + @classmethod + def from_ts(cls, ts: int | float) -> "Time": + return cls.fromtimestamp(ts, tz=cls.TZ) + + def delta(self, **kwargs) -> "Time": + new_dt = super().__add__(timedelta(**kwargs)) + return self.__class__.fromtimestamp(new_dt.timestamp(), tz=new_dt.tzinfo) + + @classmethod + def from_str(cls, s: str, fmt: str | None = None) -> "Time": + pattern = r"\b(ET|UTC|EST|EDT)\b" + + match = re.search(pattern, s) + + tz = ZONES.get(match[1]) if match else cls.TZ + + cleaned_str = re.sub(pattern, "", s).strip() + + if fmt: + dt = datetime.strptime(cleaned_str, fmt) + else: + formats = [ + "%Y-%m-%d %H:%M", + "%Y-%m-%d %H:%M:%S", + ] + + for frmt in formats: + try: + dt = datetime.strptime(cleaned_str, frmt) + break + except ValueError: + continue + else: + return cls.from_ts(31496400) + + dt = tz.localize(dt) if hasattr(tz, "localize") else dt.replace(tzinfo=tz) + + return cls.fromtimestamp(dt.astimezone(cls.TZ).timestamp(), tz=cls.TZ) + + def to_tz(self, tzone: str) -> "Time": + dt = self.astimezone(ZONES[tzone]) + return self.__class__.fromtimestamp(dt.timestamp(), tz=ZONES[tzone]) class Leagues: def __init__(self) -> None: - self.data = json.loads(leagues_file.read_text(encoding="utf-8")) + self.data = json.loads( + (Path(__file__).parent / "leagues.json").read_text(encoding="utf-8") + ) + + self.live_img = "https://i.gyazo.com/978f2eb4a199ca5b56b447aded0cb9e3.png" def teams(self, league: str) -> list[str]: return self.data["teams"].get(league, []) - def info(self, name: str) -> tuple[str | str]: + def info(self, name: str) -> tuple[str | None, str]: name = name.upper() if match := next( @@ -36,9 +88,9 @@ class Leagues: ): tvg_id, logo = match - return (tvg_id, logo or live_img) + return (tvg_id, logo or self.live_img) - return ("Live.Event.us", live_img) + return (None, self.live_img) def is_valid(self, event: str, league: str) -> bool: if match := re.search(r"(\-|vs.?)", event): @@ -48,5 +100,11 @@ class Leagues: return event.lower() == "nfl redzone" if league == "NFL" else False + @property + def league_names(self) -> list[str]: + return self.data["teams"].keys() + leagues = Leagues() + +__all__ = ["leagues", "Time"] diff --git a/M3U8/scrapers/utils/logger.py b/M3U8/scrapers/utils/logger.py index 872498e..e0b2455 100644 --- a/M3U8/scrapers/utils/logger.py +++ b/M3U8/scrapers/utils/logger.py @@ -10,22 +10,23 @@ LOG_FMT = ( ) COLORS = { - "DEBUG": "\033[37m", + "DEBUG": "\033[36m", "INFO": "\033[32m", "WARNING": "\033[33m", "ERROR": "\033[31m", - "CRITICAL": "\033[41m", + "CRITICAL": "\033[1;41m", "reset": "\033[0m", } class ColorFormatter(logging.Formatter): def format(self, record) -> str: - color = COLORS.get(record.levelname, "") + color = COLORS.get(record.levelname, COLORS["reset"]) levelname = record.levelname - record.levelname = f"{color}{levelname}{COLORS['reset']}" + record.levelname = f"{color}{levelname:<8}{COLORS['reset']}" formatted = super().format(record) record.levelname = levelname + return formatted @@ -41,5 +42,9 @@ def get_logger(name: str | None = None) -> logging.Logger: handler.setFormatter(formatter) logger.addHandler(handler) logger.setLevel(logging.INFO) + logger.propagate = False return logger + + +__all__ = ["get_logger", "ColorFormatter"] diff --git a/M3U8/scrapers/utils/network.py b/M3U8/scrapers/utils/network.py deleted file mode 100644 index 5121513..0000000 --- a/M3U8/scrapers/utils/network.py +++ /dev/null @@ -1,154 +0,0 @@ -import asyncio -import logging -import re -from collections.abc import Callable -from typing import Any - -import httpx -from playwright.async_api import Browser, BrowserContext, Playwright, Request - -UA = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0" -) - -CLIENT = httpx.AsyncClient( - timeout=5, - follow_redirects=True, - headers={"User-Agent": UA}, -) - - -async def check_status(client: httpx.AsyncClient, url: str) -> bool: - try: - r = await client.get(url) - r.raise_for_status() - except Exception: - return False - - return r.status_code == 200 - - -async def get_base(client: httpx.AsyncClient, mirrors: list[str]) -> str | None: - tasks = [check_status(client, link) for link in mirrors] - results = await asyncio.gather(*tasks) - - try: - return [url for url, ok in zip(mirrors, results) if ok][0] - except IndexError: - return - - -async def safe_process_event( - fn: Callable, - url_num: int, - timeout: int | float = 15, - log: logging.Logger | None = None, -) -> Any | None: - - if not log: - log = logging.getLogger(__name__) - - task = asyncio.create_task(fn()) - - try: - return await asyncio.wait_for(task, timeout=timeout) - except asyncio.TimeoutError: - log.warning(f"URL {url_num}) Timed out after {timeout}s, skipping event") - - task.cancel() - - try: - await task - except asyncio.CancelledError: - pass - except Exception as e: - log.debug(f"URL {url_num}) Ignore exception after timeout: {e}") - - -def capture_req( - req: Request, - captured: list[str], - got_one: asyncio.Event, -) -> None: - valid_m3u8 = re.compile(r"^(?!.*(amazonaws|knitcdn)).*\.m3u8") - - if valid_m3u8.search(req.url): - captured.append(req.url) - got_one.set() - - -async def new_browser( - playwright: Playwright, - browser: str = "firefox", - ignore_https_errors: bool = False, -) -> tuple[Browser, BrowserContext]: - - if browser == "brave": - brwsr = await playwright.chromium.connect_over_cdp("http://localhost:9222") - context = brwsr.contexts[0] - else: - brwsr = await playwright.firefox.launch(headless=True) - - context = await brwsr.new_context( - user_agent=UA, - ignore_https_errors=ignore_https_errors, - viewport={"width": 1366, "height": 768}, - device_scale_factor=1, - locale="en-US", - timezone_id="America/New_York", - color_scheme="dark", - permissions=["geolocation"], - extra_http_headers={ - "Accept-Language": "en-US,en;q=0.9", - "Upgrade-Insecure-Requests": "1", - }, - ) - - await context.add_init_script( - """ - Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); - - Object.defineProperty(navigator, 'languages', { - get: () => ['en-US', 'en'] - }); - - Object.defineProperty(navigator, 'plugins', { - get: () => [1, 2, 3, 4] - }); - - const elementDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); - Object.defineProperty(HTMLDivElement.prototype, 'offsetHeight', { - ...elementDescriptor, - get: function() { - if (this.id === 'modernizr') { return 24; } - return elementDescriptor.get.apply(this); - } - }); - - Object.defineProperty(window.screen, 'width', { get: () => 1366 }); - Object.defineProperty(window.screen, 'height', { get: () => 768 }); - - const getParameter = WebGLRenderingContext.prototype. getParameter; - WebGLRenderingContext.prototype.getParameter = function (param) { - if (param === 37445) return "Intel Inc."; // UNMASKED_VENDOR_WEBGL - if (param === 37446) return "Intel Iris OpenGL Engine"; // UNMASKED_RENDERER_WEBGL - return getParameter.apply(this, [param]); - }; - - const observer = new MutationObserver(mutations => { - mutations.forEach(mutation => { - mutation.addedNodes.forEach(node => { - if (node.tagName === 'IFRAME' && node.hasAttribute('sandbox')) { - node.removeAttribute('sandbox'); - } - }); - }); - }); - - observer.observe(document.documentElement, { childList: true, subtree: true }); - """ - ) - - return brwsr, context diff --git a/M3U8/scrapers/utils/webwork.py b/M3U8/scrapers/utils/webwork.py new file mode 100644 index 0000000..5d34c0d --- /dev/null +++ b/M3U8/scrapers/utils/webwork.py @@ -0,0 +1,174 @@ +import asyncio +import logging +import re +from collections.abc import Callable +from typing import Any + +import httpx +from playwright.async_api import Browser, BrowserContext, Playwright, Request + +from .logger import get_logger + + +class Network: + UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0" + ) + + def __init__(self) -> None: + self.client = httpx.AsyncClient( + timeout=5, + follow_redirects=True, + headers={"User-Agent": Network.UA}, + ) + + self._logger = get_logger("network") + + async def check_status(self, url: str) -> bool: + try: + r = await self.client.get(url) + r.raise_for_status() + return r.status_code == 200 + except (httpx.HTTPError, httpx.TimeoutException) as e: + self._logger.debug(f"Status check failed for {url}: {e}") + return False + + async def get_base(self, mirrors: list[str]) -> str | None: + tasks = [self.check_status(link) for link in mirrors] + results = await asyncio.gather(*tasks, return_exceptions=True) + + working_mirrors = [ + mirror for mirror, success in zip(mirrors, results) if success + ] + + return working_mirrors[0] if working_mirrors else None + + @staticmethod + async def safe_process( + fn: Callable, + url_num: int, + timeout: int | float = 15, + log: logging.Logger | None = None, + ) -> Any | None: + + if not log: + log = logging.getLogger(__name__) + + task = asyncio.create_task(fn()) + + try: + return await asyncio.wait_for(task, timeout=timeout) + except asyncio.TimeoutError: + log.warning(f"URL {url_num}) Timed out after {timeout}s, skipping event") + + task.cancel() + + try: + await task + except asyncio.CancelledError: + pass + except Exception as e: + log.debug(f"URL {url_num}) Ignore exception after timeout: {e}") + + return None + except Exception as e: + log.error(f"URL {url_num}) Unexpected error: {e}") + return None + + @staticmethod + def capture_req( + req: Request, + captured: list[str], + got_one: asyncio.Event, + patterns: list[str] | None = None, + ) -> None: + if not patterns: + patterns = ["amazonaws", "knitcdn"] + + pattern = re.compile(rf"^.*\.m3u8(?!.*({'|'.join(patterns)}))") + + if pattern.search(req.url): + captured.append(req.url) + got_one.set() + + @staticmethod + async def browser( + playwright: Playwright, + browser: str = "firefox", + ignore_https_errors: bool = False, + ) -> tuple[Browser, BrowserContext]: + + if browser == "brave": + brwsr = await playwright.chromium.connect_over_cdp("http://localhost:9222") + context = brwsr.contexts[0] + else: + brwsr = await playwright.firefox.launch(headless=True) + + context = await brwsr.new_context( + user_agent=Network.UA, + ignore_https_errors=ignore_https_errors, + viewport={"width": 1366, "height": 768}, + device_scale_factor=1, + locale="en-US", + timezone_id="America/New_York", + color_scheme="dark", + permissions=["geolocation"], + extra_http_headers={ + "Accept-Language": "en-US,en;q=0.9", + "Upgrade-Insecure-Requests": "1", + }, + ) + + await context.add_init_script( + """ + Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); + + Object.defineProperty(navigator, 'languages', { + get: () => ['en-US', 'en'] + }); + + Object.defineProperty(navigator, 'plugins', { + get: () => [1, 2, 3, 4] + }); + + const elementDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); + Object.defineProperty(HTMLDivElement.prototype, 'offsetHeight', { + ...elementDescriptor, + get: function() { + if (this.id === 'modernizr') { return 24; } + return elementDescriptor.get.apply(this); + } + }); + + Object.defineProperty(window.screen, 'width', { get: () => 1366 }); + Object.defineProperty(window.screen, 'height', { get: () => 768 }); + + const getParameter = WebGLRenderingContext.prototype. getParameter; + WebGLRenderingContext.prototype.getParameter = function (param) { + if (param === 37445) return "Intel Inc."; // UNMASKED_VENDOR_WEBGL + if (param === 37446) return "Intel Iris OpenGL Engine"; // UNMASKED_RENDERER_WEBGL + return getParameter.apply(this, [param]); + }; + + const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if (node.tagName === 'IFRAME' && node.hasAttribute('sandbox')) { + node.removeAttribute('sandbox'); + } + }); + }); + }); + + observer.observe(document.documentElement, { childList: true, subtree: true }); + """ + ) + + return brwsr, context + + +network = Network() + +__all__ = ["network"] diff --git a/pyproject.toml b/pyproject.toml index dfae3ff..3473361 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,5 +6,5 @@ dependencies = [ "httpx>=0.28.1", "playwright>=1.55.0", "pytz>=2025.2", - "selectolax>=0.3.33", + "selectolax>=0.4.0", ] diff --git a/uv.lock b/uv.lock index 0f6c9fc..fed7b17 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "anyio" -version = "4.10.0" +version = "4.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -12,9 +12,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] [[package]] @@ -151,7 +151,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "playwright", specifier = ">=1.55.0" }, { name = "pytz", specifier = ">=2025.2" }, - { name = "selectolax", specifier = ">=0.3.33" }, + { name = "selectolax", specifier = ">=0.4.0" }, ] [[package]] @@ -196,52 +196,64 @@ wheels = [ [[package]] name = "selectolax" -version = "0.3.34" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/8c/8bbe1b17098b4e2a63a251361870303c37ad4c3170536277096575c24ca4/selectolax-0.3.34.tar.gz", hash = "sha256:c2cdb30b60994f1e0b74574dd408f1336d2fadd68a3ebab8ea573740dcbf17e2", size = 4706599, upload-time = "2025-08-28T23:17:44.131Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/e0/c62a6bbbadaef976f1f27e08f1188bd7d1bf10380e4265d46737a47f8db9/selectolax-0.4.0.tar.gz", hash = "sha256:0387798f42b36ce24bc19d599ecd6ebe56ee559fe108d43978fac371cece15c7", size = 4753183, upload-time = "2025-09-28T17:52:51.97Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/3c/93e5569ac6ab51ea316692d66c42c13585cb712bc7efb1da2d243edc1010/selectolax-0.3.34-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c1abfa86809a191a8cef9b1e1f6b0fe055663525b6b383b0d1db5631964a044", size = 1995463, upload-time = "2025-08-28T23:16:23.465Z" }, - { url = "https://files.pythonhosted.org/packages/30/3f/aeb8540f3802ce666992eb44eb4961915ef0274435566060cc1d09ffd8bc/selectolax-0.3.34-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c4d9c343041dcfc36c54e250dc8fc3523594153afb4697ee6c295a95f63bef3", size = 1987843, upload-time = "2025-08-28T23:16:26.065Z" }, - { url = "https://files.pythonhosted.org/packages/2c/04/3bf3a322c8699442995a03dfeb0b877ad93a92490dc0c53ae897fedf3e0f/selectolax-0.3.34-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45f9fecd7d7b1f699a4e2633338c15fe1b2e57671a1e07263aa046a80edf0109", size = 2196214, upload-time = "2025-08-28T23:16:27.78Z" }, - { url = "https://files.pythonhosted.org/packages/12/2f/c0e1d572fa381c8a85bdd6d8f8b521d7ec4baca193bab784482ed4ea3895/selectolax-0.3.34-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9bdfaf8c62c55076e37ca755f06d5063fd8ba4dad1c48918218c482e0a0c5a6", size = 2234164, upload-time = "2025-08-28T23:16:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/89/28/973216412c44eb1759644be4656496f173bb61eb3301fa2908308b10e41b/selectolax-0.3.34-cp310-cp310-win32.whl", hash = "sha256:4be1d9a2fa4de9fde0bff733e67192be0cc8052526afd9f7d58ce507c15f994f", size = 1690571, upload-time = "2025-08-28T23:16:31.856Z" }, - { url = "https://files.pythonhosted.org/packages/4b/99/3cb9d5d2cfa534b83284c3331370dd569978ab0aae4ffb5a789818f69f69/selectolax-0.3.34-cp310-cp310-win_amd64.whl", hash = "sha256:5b3c8b87b2df5145b838ae51534e1becaac09123706b9ed417b21a9b702c6bb9", size = 1791091, upload-time = "2025-08-28T23:16:33.28Z" }, - { url = "https://files.pythonhosted.org/packages/a0/85/73f3598b50abe48cc49d7568a4bbf0afc4d56518d8eebd1f30016a72db6f/selectolax-0.3.34-cp310-cp310-win_arm64.whl", hash = "sha256:cedc440a25b9e96549b762a552be883e92770d1d01f632b3aa46fb6af93fcb5f", size = 1745110, upload-time = "2025-08-28T23:16:35.013Z" }, - { url = "https://files.pythonhosted.org/packages/e4/1e/146ce1f51d472677777422ce4442eff081ac54331667a6558a98f7c47e6c/selectolax-0.3.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa1abb8ca78c832808661a9ac13f7fe23fbab4b914afb5d99b7f1349cc78586a", size = 2002899, upload-time = "2025-08-28T23:16:36.774Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9f/30cb6a68d2e6d75da8fa3910d1f80a9b8b7338689894d24cfddb184adeaa/selectolax-0.3.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:88596b9f250ce238b7830e5987780031ffd645db257f73dcd816ec93523d7c04", size = 1994898, upload-time = "2025-08-28T23:16:38.153Z" }, - { url = "https://files.pythonhosted.org/packages/79/63/b207c894c54b426ae69e08c85a5dc85c22f4d10872efd17a599f8dfb94e2/selectolax-0.3.34-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7755dfe7dd7455ca1f7194c631d409508fa26be8db94874760a27ae27d98a1c3", size = 2209542, upload-time = "2025-08-28T23:16:39.975Z" }, - { url = "https://files.pythonhosted.org/packages/3b/50/76285317fafbb80f01853c05256c584f32184371f609ecb9f0bab372a785/selectolax-0.3.34-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:579fdefcb302a7cc632a094ec69e7db24865ec475b1f34f5b2f0e9d05d8ec428", size = 2242685, upload-time = "2025-08-28T23:16:41.327Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ba/56be3f947cda174f1923fc08b216b63ec5c433c41b7b02c9ac05170a2b30/selectolax-0.3.34-cp311-cp311-win32.whl", hash = "sha256:a568d2f4581d54c74ec44102d189fe255efed2d8160fda927b3d8ed41fe69178", size = 1690600, upload-time = "2025-08-28T23:16:43.071Z" }, - { url = "https://files.pythonhosted.org/packages/53/f2/d44f44fe11a28be31a3f55cff298364cd5f0baa2dca2eed20d4e4ac230f5/selectolax-0.3.34-cp311-cp311-win_amd64.whl", hash = "sha256:ff0853d10a7e8f807113a155e93cd612a41aedd009fac02992f10c388fcdd6fe", size = 1792859, upload-time = "2025-08-28T23:16:44.77Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a1/d4168930f1b377befa2e3b6db1a81a600c12a800689aef047ec4407ac933/selectolax-0.3.34-cp311-cp311-win_arm64.whl", hash = "sha256:f28ebdb0f376dae6f2e80d41731076ce4891403584f15cec13593f561cfb4db0", size = 1744909, upload-time = "2025-08-28T23:16:46.526Z" }, - { url = "https://files.pythonhosted.org/packages/d8/eb/6cf9dd52e20922ea5d5be8f8c448e9501a8503ad6c8d7f70be737f76e76b/selectolax-0.3.34-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a913371fe79d6f795fc36c0c0753aab1593e198af78dc0654a7615a6581ada14", size = 2003247, upload-time = "2025-08-28T23:16:48.206Z" }, - { url = "https://files.pythonhosted.org/packages/bc/ec/58682f69bddfe1b64e44e235e6ad2585742b4d8d805c4c0d7f2a9c0d97f5/selectolax-0.3.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:11b0e913897727563b2689b38a63696a21084c3c7fd93042dc8af259a4020809", size = 1995861, upload-time = "2025-08-28T23:16:49.641Z" }, - { url = "https://files.pythonhosted.org/packages/ec/68/38b8bc49d19feefd20d65661ee0ffad8537cb21bdbeaa23be3cf42f7445d/selectolax-0.3.34-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b49f0e0af267274c39a0dc7e807c556ecf2e189f44cf95dd5d2398f36c17ce9", size = 2202808, upload-time = "2025-08-28T23:16:51.564Z" }, - { url = "https://files.pythonhosted.org/packages/2d/42/1f5e5fc7c9ac362ac259a94d2d4040e1e2c57ab2c552db7bd830884d610a/selectolax-0.3.34-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0a5a1a8b62e204aba7030b49c5b696ee24cabb243ba757328eb54681a74340c", size = 2241858, upload-time = "2025-08-28T23:16:53.589Z" }, - { url = "https://files.pythonhosted.org/packages/90/d7/fa4d3f8fa1cfdeb36f0a4f14db362fa45b913679a15e82f3618fd62526ce/selectolax-0.3.34-cp312-cp312-win32.whl", hash = "sha256:cb49af5de5b5e99068bc7845687b40d4ded88c5e80868a7f1aa004f2380c2444", size = 1686857, upload-time = "2025-08-28T23:16:55.247Z" }, - { url = "https://files.pythonhosted.org/packages/5b/2c/f0112f0de82e7b72773f5289481fa8e31c65992d687a36048ade7e2d5703/selectolax-0.3.34-cp312-cp312-win_amd64.whl", hash = "sha256:33862576e7d9bb015b1580752316cc4b0ca2fb54347cb671fabb801c8032c67e", size = 1789407, upload-time = "2025-08-28T23:16:56.622Z" }, - { url = "https://files.pythonhosted.org/packages/fc/09/8972f0fed462739ad8a5fe1f0191f19edfc95ff7c1b09de4c940c9fe744c/selectolax-0.3.34-cp312-cp312-win_arm64.whl", hash = "sha256:8a663d762c9b6e64888489293d9b37d6727ac8f447dca221e044b61203c0f1e1", size = 1737885, upload-time = "2025-08-28T23:16:58.161Z" }, - { url = "https://files.pythonhosted.org/packages/d0/29/eeb77d1a77599023387d4d00655960dfa3d760557b42a65ef347e29b40b0/selectolax-0.3.34-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2bb74e079098d758bd3d5c77b1c66c90098de305e4084b60981e561acf52c12a", size = 2001199, upload-time = "2025-08-28T23:16:59.467Z" }, - { url = "https://files.pythonhosted.org/packages/21/80/326b9dd2901b64c3c654db9e8841ddc412b9c2af0047b7d43290bbb276be/selectolax-0.3.34-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc39822f714e6e434ceb893e1ccff873f3f88c8db8226ba2f8a5f4a7a0e2aa29", size = 1994171, upload-time = "2025-08-28T23:17:01.206Z" }, - { url = "https://files.pythonhosted.org/packages/15/af/1265e4f9429b3c3cf098ba08cb3264d7e16990ed3029d89e9890012aae76/selectolax-0.3.34-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181b67949ec23b4f11b6f2e426ba9904dd25c73d12c2cb22caf8fae21a363e99", size = 2196092, upload-time = "2025-08-28T23:17:02.574Z" }, - { url = "https://files.pythonhosted.org/packages/1c/41/e67100abd8b0b2a5e1d5d7fa864c31d31e9a2c0bbd08ce4e951235f13143/selectolax-0.3.34-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b09f9d7b22bbb633966ac2019ec059caf735a5bdb4a5784bab0f4db2198fd6a", size = 2233674, upload-time = "2025-08-28T23:17:03.928Z" }, - { url = "https://files.pythonhosted.org/packages/3a/24/7ad043805c9292b4f535071c223d10aad7703b4460d68de1dce9dcf21d3f/selectolax-0.3.34-cp313-cp313-win32.whl", hash = "sha256:6e2ae8a984f82c9373e8a5ec0450f67603fde843fed73675f5187986e9e45b59", size = 1686489, upload-time = "2025-08-28T23:17:05.341Z" }, - { url = "https://files.pythonhosted.org/packages/6b/79/62666fbfcd847c0cfc2b75b496bfa8382d765e7a3d5a2c792004760a6e61/selectolax-0.3.34-cp313-cp313-win_amd64.whl", hash = "sha256:96acd5414aaf0bb8677258ff7b0f494953b2621f71be1e3d69e01743545509ec", size = 1789924, upload-time = "2025-08-28T23:17:06.708Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b5/0bb579210a7de36d97c359016e77119513d3e810c61e99ade72089bc1b4d/selectolax-0.3.34-cp313-cp313-win_arm64.whl", hash = "sha256:1d309fd17ba72bb46a282154f75752ed7746de6f00e2c1eec4cd421dcdadf008", size = 1737480, upload-time = "2025-08-28T23:17:08.575Z" }, - { url = "https://files.pythonhosted.org/packages/b8/5c/ab87e8ecb3c6aa1053d1c6d1eba0e47e292cc72aff0f6fbb89d920d4d87c/selectolax-0.3.34-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3e9c4197563c9b62b56dd7545bfd993ce071fd40b8779736e9bc59813f014c23", size = 2000587, upload-time = "2025-08-28T23:17:10.327Z" }, - { url = "https://files.pythonhosted.org/packages/72/8e/5c08bd5628f73ab582696f8349138a569115a0fd6ab71842e4115ceec4ff/selectolax-0.3.34-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f96eaa0da764a4b9e08e792c0f17cce98749f1406ffad35e6d4835194570bdbf", size = 1994327, upload-time = "2025-08-28T23:17:11.709Z" }, - { url = "https://files.pythonhosted.org/packages/ac/29/02b22eff289b29ee3f869a85e4be4f7f3cf4b480d429bb18aab014848917/selectolax-0.3.34-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:412ce46d963444cd378e9f3197a2f30b05d858722677a361fc44ad244d2bb7db", size = 2201620, upload-time = "2025-08-28T23:17:13.538Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d3/bdd3a94bb1276be4ef4371dbfd254137b22f5c54a94d051a8d72c3956dc6/selectolax-0.3.34-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58dd7dc062b0424adb001817bf9b05476d165a4db1885a69cac66ca16b313035", size = 2233487, upload-time = "2025-08-28T23:17:14.921Z" }, - { url = "https://files.pythonhosted.org/packages/e6/6a/5d551c570f29bfca5815f45fa6e6a3310cc5bc6c9b1073a968d71f73612b/selectolax-0.3.34-cp314-cp314-win32.whl", hash = "sha256:4255558fa48e3685a13f3d9dfc84586146c7b0b86e44c899ac2ac263357c987f", size = 1779755, upload-time = "2025-08-28T23:17:16.322Z" }, - { url = "https://files.pythonhosted.org/packages/cc/dc/5def41b07cb3b917841022489e6bd6c3277363c23b44eca00a0ada93221c/selectolax-0.3.34-cp314-cp314-win_amd64.whl", hash = "sha256:6cbf2707d79afd7e15083f3f32c11c9b6e39a39026c8b362ce25959842a837b6", size = 1877332, upload-time = "2025-08-28T23:17:17.766Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/63da99be8f78bbfca0cb3f9ad71b7475ab97383f830c86a9abd29c6d3f25/selectolax-0.3.34-cp314-cp314-win_arm64.whl", hash = "sha256:3aa83e4d1f5f5534c9d9e44fc53640c82edc7d0eef6fca0829830cccc8df9568", size = 1831124, upload-time = "2025-08-28T23:17:19.744Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/07d8031c6c106de10ff42b4440ad7fa6a038650942bb2e194e4eb9ffec6d/selectolax-0.3.34-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:bb0b9002974ec7052f7eb1439b8e404e11a00a26affcbdd73fc53fc55beec809", size = 2023889, upload-time = "2025-08-28T23:17:21.222Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/fa8220c2eae44928b5ae73eccd44baedb328109f115c948d796c46d11048/selectolax-0.3.34-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:38e5fdffab6d08800a19671ac9641ff9ca6738fad42090f4dd0da76e4db29582", size = 2011882, upload-time = "2025-08-28T23:17:22.844Z" }, - { url = "https://files.pythonhosted.org/packages/f6/02/657089f68f59308bd90137102a7f6da0c3770128ae7245e1290e99f5a48d/selectolax-0.3.34-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:871d35e19dfde9ee83c1df139940c2e5cdf6a50ef3d147a0e9acf382b63b5b3e", size = 2221871, upload-time = "2025-08-28T23:17:24.259Z" }, - { url = "https://files.pythonhosted.org/packages/d2/56/1ad7877f9b2b12f616a8847eca0a3047c6b5ed14588f21fe1f6915357efb/selectolax-0.3.34-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f3f269bc53bc84ccc166704263712f4448130ec827a38a0df230cffe3dc46a9", size = 2241032, upload-time = "2025-08-28T23:17:25.76Z" }, - { url = "https://files.pythonhosted.org/packages/60/c0/30ce665b7382f663fdbb282748ddee392a61c85f51862776b128d8644d45/selectolax-0.3.34-cp314-cp314t-win32.whl", hash = "sha256:b957d105c2f3d86de872f61be1c9a92e1d84580a5ec89a413282f60ffb3f7bc1", size = 1828494, upload-time = "2025-08-28T23:17:27.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/9e/11d023ad74d0d1a48cefdddbb2d00365c4d9a97735d7c24c0f206cd1babb/selectolax-0.3.34-cp314-cp314t-win_amd64.whl", hash = "sha256:9c609d639ce09154d688063bb830dc351fb944fa52629e25717dbab45ad04327", size = 1951608, upload-time = "2025-08-28T23:17:29.327Z" }, - { url = "https://files.pythonhosted.org/packages/cc/20/a5f93b84e3e6de9756dc82465c0dff57b1c8a25b1815bca0817e4342494c/selectolax-0.3.34-cp314-cp314t-win_arm64.whl", hash = "sha256:6359e94d66fb4fce9fb7c9d18252c3d8cba28b90f7412da8ce610bd77746f750", size = 1852855, upload-time = "2025-08-28T23:17:30.746Z" }, + { url = "https://files.pythonhosted.org/packages/57/80/09fae49a5493f43efead22d1ff7c19279d22569f1e742da0e2476ac41e0a/selectolax-0.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5afd58bad4a5bbaef114f6c61278ec33f39d46b04c2b6a98032dac892f21f5f8", size = 2027872, upload-time = "2025-09-28T17:51:11.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/1a/fb195109748b2dd3104383a9f8f856f3c1715443fe920f8d0e44419cd34a/selectolax-0.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae7b3ca4bb5f19cdd624e105d606bef8aa24a9590b6de9610d83573af649abb0", size = 2020754, upload-time = "2025-09-28T17:51:14.094Z" }, + { url = "https://files.pythonhosted.org/packages/96/ac/1dc7c52cfc3b3ead65308e134baae7154a7b0cd23865384a8d460cc9ffe8/selectolax-0.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12ca3568e5a4da1f0731b0b817b54b5b1d9f8107ae1ed33ba82a18626ad67708", size = 2218933, upload-time = "2025-09-28T17:51:15.924Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4d/6d3615ea159dc6777f7ab9bd89c54d86c2a97d50ceee43b69eae643a85d1/selectolax-0.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4ee361e70c1918dd8f34b77fbdced755c532c1e5867e4b8bea169efb25f409e", size = 2252992, upload-time = "2025-09-28T17:51:17.698Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/cf95afeed588ec9deb3f7b0851dcad7d556c557053cdda985f01100e04cb/selectolax-0.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3556c983ad4afa382e3ad79d113fd3159c009e7c6697837afd0fbc01af7f42bc", size = 2233420, upload-time = "2025-09-28T17:51:19.383Z" }, + { url = "https://files.pythonhosted.org/packages/43/6b/2ab3b5f0ac78088921c12f852123c194dc8de349ccf3c361f1c5b64c5317/selectolax-0.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d6ac41d5344078274e53d5c50e9091718444a1705f8b14b33ac913b53a52b6eb", size = 2260444, upload-time = "2025-09-28T17:51:21.267Z" }, + { url = "https://files.pythonhosted.org/packages/4e/91/d9a14f5fb8c0d3be47c493b6eea69c50f45ecb11ebb400410abbb819cccb/selectolax-0.4.0-cp310-cp310-win32.whl", hash = "sha256:ab1c62395e9698a96dd0f0b5bc2da8b6d25a6e6675f35ddd182e16957f9346e6", size = 1706495, upload-time = "2025-09-28T17:51:23.164Z" }, + { url = "https://files.pythonhosted.org/packages/c5/28/27d84208fd2b0242bf11d9b33fde47309b5b7d3ac96c976e610c911e2d2b/selectolax-0.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:7b4b830b897bc2c5ad572dce657d213b7106ee364b8e0bbd42e5d723fd91a1b6", size = 1808661, upload-time = "2025-09-28T17:51:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e2/1d5c71e9586989893e5471a9589b06ce155ac9f06fedd8bff891833e7a4e/selectolax-0.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:3115a5bf21d1b8ab9b86f854d162c6dd72bcc1088d428713157fc4cae1984038", size = 1757244, upload-time = "2025-09-28T17:51:26.687Z" }, + { url = "https://files.pythonhosted.org/packages/09/d1/376e99ea710f66c157b0457fa971b2cb7dce3ff20fa98528dc9e44a4c917/selectolax-0.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:930ce6aae62b39ced18047eae916fe93970a33d77016ac4158d971caa510b2cf", size = 2034524, upload-time = "2025-09-28T17:51:28.444Z" }, + { url = "https://files.pythonhosted.org/packages/cf/25/d46756188af9ddf2ed49a1cd1eaba00930389de76ce4933d00608aa2804e/selectolax-0.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e05cf9a525973740e526009ad3c4b20bb94f21456a47b039ec3cfabfe58bebca", size = 2028783, upload-time = "2025-09-28T17:51:30.227Z" }, + { url = "https://files.pythonhosted.org/packages/80/5d/37e10b9de79f2817f3f40a83ad0b359b5bf0691ac47c65130beda6df0d11/selectolax-0.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8141421be2a048b3a63428c46f894d382c2dd0f15c5e17737afda7745e759dbf", size = 2229394, upload-time = "2025-09-28T17:51:32.152Z" }, + { url = "https://files.pythonhosted.org/packages/36/8d/acfc2e24a12c18217a732b90f9fb52ba8b0f95381b7385b66df3145dc4fd/selectolax-0.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7150d2f559d4f5a2c7da4e3825f84937dddff109b63c80abf2252490b5974a6", size = 2263336, upload-time = "2025-09-28T17:51:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/07/84/5134208f3df2df61282facbe6d8dade408be03a06ac3a31b227a7f2c443d/selectolax-0.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4c2ba376da5946c924f6cae22dfc9f3e8b229d4255c8545f7827827ce154a9", size = 2244521, upload-time = "2025-09-28T17:51:35.674Z" }, + { url = "https://files.pythonhosted.org/packages/04/b8/817bbcea6fa2d6bfecd3979236b7252fe22357cb3a89c412cc79178b96f8/selectolax-0.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83de91f71fca28fdfe9ad61989b5c93bdfa00b238cc08416d5431c8f78806bf6", size = 2269845, upload-time = "2025-09-28T17:51:37.599Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7e/dd4d4c0181955133c56b1bd8f7f39f4d08131cbf40695e337b820998a27e/selectolax-0.4.0-cp311-cp311-win32.whl", hash = "sha256:7abe15df5a53f4e9a19c016ea1df4220ec0cac8cb3a3ff591cbf7b505803ee07", size = 1706390, upload-time = "2025-09-28T17:51:39.18Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/d5fa9607c4214fe4336f44d32d41a6e65776b070c35058feb97f88a04038/selectolax-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:b3c0c61ab5a1548532263652a617b22f9cf033089bfc4556309d583e5a65667e", size = 1810119, upload-time = "2025-09-28T17:51:40.931Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5c/5c6a9758e4a2d9de42a7bed2f96154064c19768ab3c1aa6fe152127e320a/selectolax-0.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:115f6b36c75c0141ea5bd9669bfdc4c55686b3944c81d5ef399d08bf541a46f6", size = 1757002, upload-time = "2025-09-28T17:51:42.322Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b7/4927bb03c54a12eb7d81da5171e18a7bd0e9eabe909f798aba15dfa69feb/selectolax-0.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4f6e6bfe035cfc7962efdb63ca7b591e8dbf1d1a5226d9af8c0735a395983e79", size = 2037651, upload-time = "2025-09-28T17:51:43.825Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/8c2e64ee8e92d532e627efcd0fff8eb3e12018c4e3642f400545d113da07/selectolax-0.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c727a1d5dec654291a58aca34966495f97f20c6d93ad3bfb67a3a8cc5c58e3a", size = 2030040, upload-time = "2025-09-28T17:51:45.248Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/008ce4af155c5cafe0801825805ae9f92c38c801022b4a6f295439c44321/selectolax-0.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec5ddc25e94ac93c353ef361c513bac40e45648ee7a8a36d5d6911d6daa5689b", size = 2223653, upload-time = "2025-09-28T17:51:46.994Z" }, + { url = "https://files.pythonhosted.org/packages/25/2d/fce2dc92a2b05c9ba0d56c49dfa1ac66d4c6e67b09930bd413667d3d26fd/selectolax-0.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534a4cf50371406594efd551395acb47166e54cf06b97d24d373fdf5ff3d3436", size = 2263895, upload-time = "2025-09-28T17:51:48.537Z" }, + { url = "https://files.pythonhosted.org/packages/37/07/de96522669a8888db748ed550362d51dbaa36c8230a00cafe59c50351dbe/selectolax-0.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1c61fa0662bdd8525f3d9fef6d8041faef39e7e9fe12cc9ef068dc34a791000b", size = 2238855, upload-time = "2025-09-28T17:51:50.182Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cd/082ba63397894939c9671eaf5521291218e0431453fae91ce741dd57686a/selectolax-0.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb89d5ca84f2523063d06d7e23ebd8cb30d2e431920478ba14a02ae2a7f0c51d", size = 2269371, upload-time = "2025-09-28T17:51:51.563Z" }, + { url = "https://files.pythonhosted.org/packages/cb/80/765e674f548a645afcfc9c4270fce01704663732be4d3f37f49da2524b81/selectolax-0.4.0-cp312-cp312-win32.whl", hash = "sha256:9a088736aed7a3b5583188679612e6a278155328d6650a27a96ab0753d1b49d0", size = 1703051, upload-time = "2025-09-28T17:51:53.115Z" }, + { url = "https://files.pythonhosted.org/packages/cd/41/6df32a5d5c7328f384d7d439d7be2d10b416c3d9af02bea1c91bbced2c5f/selectolax-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b7cc3105bfda0d478d7220a681117c688abcf58580c1bb0bd5acd668c9192270", size = 1805133, upload-time = "2025-09-28T17:51:54.629Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ba/cc92689a5b04e15e91133a7d1ccbdeb459170e2ee2d2d6ff2f72940f3c21/selectolax-0.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:67d44890c128b5fc797dc3a55a168a41551bc619f3cd3c6819d06a742fab4ef4", size = 1750358, upload-time = "2025-09-28T17:51:56.126Z" }, + { url = "https://files.pythonhosted.org/packages/cd/01/bb46431df329ca472292425cc9244c934f1cc3d09beb8cd9db096b9284f0/selectolax-0.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec605e9a8d85d1e1118b9a07347cc4cc81714c8b7b0ae8be9c8b515f2dda52c2", size = 2037193, upload-time = "2025-09-28T17:51:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/2a/60/2c8c8b8b78db646f5eef4c7ecbc2198db778f35ef3d656fb855dd109fbe0/selectolax-0.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aef60a57920883d02574330de203d6ea984c33152bd2285ff6e88b978feeea5c", size = 2029085, upload-time = "2025-09-28T17:51:59.112Z" }, + { url = "https://files.pythonhosted.org/packages/d6/21/85b29e2dc44a8a0de0dff73f909c88506d14e96f479b9a50b39369b8d5fe/selectolax-0.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97b30b18e5b4633b889fd8bb8fc1cc31acb348e6f4cf67e2fa615d1c38697d24", size = 2218148, upload-time = "2025-09-28T17:52:00.877Z" }, + { url = "https://files.pythonhosted.org/packages/62/02/d03bedc09e60185f35c7424d3ef5046361dab50b40de19141e29289981dc/selectolax-0.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45b73eadfbc9d96a354e9b559bac40bc430f0cfa95f3c7e8ff9b8c642bd4063f", size = 2256958, upload-time = "2025-09-28T17:52:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8e/ae06590bebd5ed0243b52427d44919d26aebec8afa1c6fc9ccdbf9008f1c/selectolax-0.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:78b069c1a7d118077e905b6e560d8e9eb86ad6b7dc9122e6e16879fcd59731b9", size = 2228723, upload-time = "2025-09-28T17:52:04.332Z" }, + { url = "https://files.pythonhosted.org/packages/d3/fe/fada91b80ee58973def8fefe302a40214de0f8f5091ec1eb808fc1e60148/selectolax-0.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06bb0f67ae5c951ea9f93e1fa51f646eb61336be6041631eee3fad1b61615415", size = 2261784, upload-time = "2025-09-28T17:52:05.76Z" }, + { url = "https://files.pythonhosted.org/packages/1f/71/257efdd7d4bf28c7889bb8c9ecc2a11233a6bb9d7c06292add90db8cdf9b/selectolax-0.4.0-cp313-cp313-win32.whl", hash = "sha256:3302f5d8f921e873b8f99d93cd7f093fc44e0fbea4ac6e9ce835991de3ca47e4", size = 1702602, upload-time = "2025-09-28T17:52:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a0/f8de5940e5f286f8e1c88fbff8297c1427be0117a8f3258a6482d831f405/selectolax-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:6424ea4d27d26cb49042c729a7721cdc1210e7c404abcfe991c6f299a330bca7", size = 1806161, upload-time = "2025-09-28T17:52:08.613Z" }, + { url = "https://files.pythonhosted.org/packages/29/e0/0a8ae951856c9819551ab22ac57c1cae530702a170f81fae48ab5da2bb25/selectolax-0.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:8e4994013ce69d65224afe4ae2038316a968ac3c0dcfd34faeaff09bf6825e61", size = 1749911, upload-time = "2025-09-28T17:52:10.102Z" }, + { url = "https://files.pythonhosted.org/packages/27/dc/ac0b643411d0cd394e3b9c2660351c73cce7805efde5b1923ec943c27d6b/selectolax-0.4.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5edf5793c83b713022a325437ec1f0460b1163655fd1145ee2acf427ab1ff388", size = 2037005, upload-time = "2025-09-28T17:52:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/e4629f8dd307f5039ce9c50ac45085da5e194e8a1710cbd8bb4cf5ea02f5/selectolax-0.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a0faaad30a143ec0068cd29d346106309ca14add2813607c84548f69ff0babd9", size = 2030288, upload-time = "2025-09-28T17:52:13.319Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/a59866e3ef94349a3fa17c8db8e12c783de52e99277c8bff45bed30ce0fe/selectolax-0.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da04f1a1bf31a33e05739bc14ac585523adb4df8fbd0ce4eb3b1b6da82e76c56", size = 2223289, upload-time = "2025-09-28T17:52:14.84Z" }, + { url = "https://files.pythonhosted.org/packages/98/8f/12e2e3958c1d2a50123725fd05a8739c436093e88b5086b1c29bdea818bb/selectolax-0.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:468063c361d45ce04e3ada12f8848b0e99289aeb32b100c2b38dc86e085cea6a", size = 2255545, upload-time = "2025-09-28T17:52:16.243Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/514a4e18b08820601091ab64328f1e0f6327e219123ea24e72a2444401f1/selectolax-0.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b137e6a3470c6e374552cbe3ef69d5e10f4ac5317b0dd8d8a5d288ad02a980e7", size = 2239440, upload-time = "2025-09-28T17:52:18.365Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/a6ef1d4fac7014d008ee25296057ed785a2b0daf6bc2a8ee503a84c428fa/selectolax-0.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e98416d1096f438fa7fa243f53350c8fc3e63e7c781a513300ff2da4ae247f95", size = 2262156, upload-time = "2025-09-28T17:52:19.759Z" }, + { url = "https://files.pythonhosted.org/packages/15/9f/a01ebbed6e83ca61bc2bf40119ada6c1206f562a15bb50535fa341264f69/selectolax-0.4.0-cp314-cp314-win32.whl", hash = "sha256:a144be60b2e9b8c34602cf07b01d1edf310fe1079944c473867295cc582c30ef", size = 1795070, upload-time = "2025-09-28T17:52:21.152Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9a/fd58ca54e4544b59603a9f106ff7af412fd5e3852d1c1e3f4f56ab653dfc/selectolax-0.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:5d2bf8359fda6c5e7d2ac4183343fed5c84276adc7a28aa1f4f5c94964d15d5d", size = 1894458, upload-time = "2025-09-28T17:52:23.016Z" }, + { url = "https://files.pythonhosted.org/packages/02/5a/86a330a64519749ddeb4124a8370124737f44bceb8cfd8b0a3c36aa67d30/selectolax-0.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:9f249b72ce4a344f1ac8b14fc9c9ccb043c8afbef6fedfc535ba8d03db0c7f19", size = 1843326, upload-time = "2025-09-28T17:52:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/64/f5f5ef5ccac5a70db6d3d129c027eff0d50ac0222360fbdd86ffab0e5119/selectolax-0.4.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f2048fad6e21bbecb0a96f6d830f4c295c8638fb66b04e996afc7225db751594", size = 2052639, upload-time = "2025-09-28T17:52:25.77Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8f/46dc6990313b872ab1f33175c5c02846ef9eb7c6c488ddf0984824673d69/selectolax-0.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4b28af17499028ae8c210b3e8ef04c93f4d7a1b4e150da5781a3cf331a63f5a6", size = 2052569, upload-time = "2025-09-28T17:52:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ee/48ff15c3ddac428749a5fbbc99b55c0b0a1720ba83ca048fbaf630254338/selectolax-0.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9da70a07e6ec2409f2cc6da787a6c4018ca672c7e139983e37de0cefa0686395", size = 2239877, upload-time = "2025-09-28T17:52:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/e5/cb/5bffe2b9acd022eefb3a84c80cbdf441624a5754c062c05fcf1f6752a195/selectolax-0.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e62446575f9c58681a112273b48fcd659ee7c5f9f1b8c774223b128ce793ae38", size = 2263748, upload-time = "2025-09-28T17:52:29.964Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1b/1a1970723372c6f4cf28b7100d415b66c2530138f39e82c5aad6789680e2/selectolax-0.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a20ae8d377e74ce6480e55039c83c3ebfa96ed41281660530f02758a62ce6b80", size = 2254831, upload-time = "2025-09-28T17:52:31.309Z" }, + { url = "https://files.pythonhosted.org/packages/71/1c/359e14203c84bd6542d7631510bf5b2eec263a5ca1efa5fdeb2b3a768a42/selectolax-0.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:744b2809fcb360b01389b3d4929476951f5a260e375b12be1a6fa86ce11805dc", size = 2271220, upload-time = "2025-09-28T17:52:32.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/7b/69fb95a7f3f3ade4622f35f5e02b38ae1f73f6a73856eb017bc965174678/selectolax-0.4.0-cp314-cp314t-win32.whl", hash = "sha256:9d9a5b1b2ecb64409d143aa37d4708bf3a3aacf47c19fe095429880e8fd43e4e", size = 1846313, upload-time = "2025-09-28T17:52:34.114Z" }, + { url = "https://files.pythonhosted.org/packages/42/36/a50f7af1d35e4f299d52f2163dffb60318294fc2bc8a58dcaf07d8d95e3d/selectolax-0.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bbb5f0c0ea169a01aaa92c46a054a46ddc53c6e69aef683abf438fe39fbd60c2", size = 1967681, upload-time = "2025-09-28T17:52:35.542Z" }, + { url = "https://files.pythonhosted.org/packages/c3/96/e2f3ddd77dbfabf2b22dff676a16b0c87da71785756fa53b48b2217e1e4a/selectolax-0.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:22f2ce15e8e79acb66b576518609efe5d34e32200cb0818b48c7ebd89f59d4e6", size = 1863193, upload-time = "2025-09-28T17:52:37.052Z" }, ] [[package]]