From 0d8ab05f6669c50750234b7c402b3947d6caf626 Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Wed, 29 Apr 2026 19:06:21 +0300 Subject: [PATCH] feat: add vault --- .dockerignore | 16 ++ .gitignore | 16 ++ Dockerfile | 26 +++ docker-compose.yml | 6 + pyproject.toml | 10 ++ src/main.py | 392 +++++++++++++++++++++++++++++++++++++++++++++ uv.lock | 132 +++++++++++++++ 7 files changed, 598 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 src/main.py create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..889aa27 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +README.md +LICENSE +tests/ +__pycache__/ +.mypy_cache +.pytest_cache +.gitignore +.editorconfig +.pre-commit-config.yaml +ruff.toml +pytest.ini +.idea/ +.git/ +mypy.ini +bandit.yaml +ruf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b8b3ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.idea/ +venv/ +__pycache__/ +.ruff_cache/ +.mypy_cache +.pytest_cache +.vscode/ +.pycharm/ +/venv/ +/.idea/ +*.log +*.pyc +*.swp +.DS_Store +.editorconfig +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2c0cd54 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl \ + fonts-liberation \ + libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \ + libdrm2 libgbm1 libnspr4 libnss3 libx11-6 libx11-xcb1 libxcb1 \ + libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 \ + libxrandr2 libxrender1 libxshmfence1 libxss1 libxtst6 \ + libpango-1.0-0 libpangocairo-1.0-0 \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir uv + +COPY pyproject.toml /app/pyproject.toml + +RUN uv pip compile pyproject.toml -o /app/requirements.txt \ + && uv pip install --system -r /app/requirements.txt + +RUN python -m patchright install chromium + +COPY src /app/src + +CMD ["uv", "run", "python", "-m", "src.main"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bc98910 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + crypto_parser: + build: . + restart: unless-stopped + env_file: + - .env diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..063414d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "crypto-parser" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.12" +dependencies = [ + "dotenv>=0.9.9", + "patchright>=1.57.2", + "redis>=7.1.0", +] diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..9fc57b5 --- /dev/null +++ b/src/main.py @@ -0,0 +1,392 @@ +import asyncio +import json +import logging +import os +import ssl +import gzip +import math +from datetime import datetime, timezone +from urllib.request import Request, urlopen +from dotenv import load_dotenv +import redis.asyncio as redis +from patchright.async_api import async_playwright + + +load_dotenv() + + +INTERVAL_SEC = int(os.getenv('INTERVAL_SEC', '20')) +DIGITS_AFTER_DECIMAL = int(os.getenv('DIGITS_AFTER_DECIMAL', '5')) +USDT_URL = os.getenv('USDT_URL', 'https://tradex.by/trading/ru/usdt/rub') +USDC_URL = os.getenv('USDC_URL', 'https://tradex.by/trading/ru/usdc/rub') +PRICE_SELECTOR = os.getenv('PRICE_SELECTOR', 'div.ng-star-inserted:has(span) > span') +PRICE_INDEX = int(os.getenv('PRICE_INDEX', '2')) +USDT_ADD_RUB = float(os.getenv('USDT_ADD_RUB', '2.5')) +USDC_ADD_RUB = float(os.getenv('USDC_ADD_RUB', '2.0')) +FREEZEX_TICKER_URL = os.getenv('FREEZEX_TICKER_URL', 'https://cryptottlivewebapi.free2ex.net:8443/api/v2/public/ticker') +FREEZEX_SYMBOL = os.getenv('FREEZEX_SYMBOL', 'USDTRUB') +FREEZEX_ADD_RUB = float(os.getenv('FREEZEX_ADD_RUB', '2.0')) +REDIS_KEY_LAST = os.getenv('REDIS_KEY_LAST', 'tradex:rub:last') +LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() +USE_CHROME_CHANNEL = os.getenv('USE_CHROME_CHANNEL', '0') == '1' +HTTP_USER_AGENT = os.getenv('HTTP_USER_AGENT', 'curl/8.0.0') +HTTP_TIMEOUT_SEC = float(os.getenv('HTTP_TIMEOUT_SEC', '30')) + +VAULT_ADDR = os.getenv('VAULT_ADDR', '').rstrip('/') +VAULT_MOUNT_POINT = os.getenv('VAULT_MOUNT_POINT', '') +VAULT_ROLE_ID = os.getenv('VAULT_ROLE_ID', '') +VAULT_SECRET_ID = os.getenv('VAULT_SECRET_ID', '') +VAULT_SECRET_PATH = os.getenv('VAULT_SECRET_PATH', 'keydb') + +KEYDB_KEY_RATE = os.getenv('KEYDB_KEY_RATE', 'tradex:rub:rate') + +logger = logging.getLogger('tradexparser') + + +def setup_logging() -> None: + logging.basicConfig( + level=LOG_LEVEL, + format='%(asctime)s | %(levelname)s | %(name)s | %(message)s', + ) + logging.getLogger('websockets').setLevel(logging.WARNING) + logging.getLogger('asyncio').setLevel(logging.WARNING) + + +def ceil_to_2(value: float) -> float: + return math.ceil(value * 100.0) / 100.0 + + +def to_float_str(text: str | None) -> str | None: + if not text: + return None + t = text.split('\n')[0].strip().replace(',', '.') + try: + return f'{float(t):.{DIGITS_AFTER_DECIMAL}f}' + except ValueError: + return None + + +def fmt_rate(value: float) -> str: + return f'{value:.{DIGITS_AFTER_DECIMAL}f}' + + +def fmt_ceil_2(rate_str: str | None) -> str | None: + if rate_str is None: + return None + try: + v = float(rate_str) + except ValueError: + return None + return f'{ceil_to_2(v):.2f}' + + +def add_to_rate(rate_str: str | None, add_rub: float) -> str | None: + if rate_str is None: + return None + try: + return fmt_rate(float(rate_str) + add_rub) + except ValueError: + return rate_str + + +def avg_rates(a: str | None, b: str | None) -> str | None: + if a is None and b is None: + return None + if a is None: + return b + if b is None: + return a + try: + return fmt_rate((float(a) + float(b)) / 2.0) + except ValueError: + return a + + +def _http_get_json(url: str) -> object: + req = Request( + url, + headers={ + 'accept': 'application/json', + 'user-agent': HTTP_USER_AGENT, + 'accept-encoding': 'gzip', + }, + ) + ctx = ssl.create_default_context() + with urlopen(req, timeout=HTTP_TIMEOUT_SEC, context=ctx) as resp: + raw = resp.read() + if str(resp.headers.get('Content-Encoding', '')).lower() == 'gzip': + raw = gzip.decompress(raw) + return json.loads(raw.decode('utf-8')) + + +def _pick_mid_price(row: dict) -> float | None: + def f(key: str) -> float | None: + v = row.get(key) + try: + fv = float(v) + except (TypeError, ValueError): + return None + return fv if fv != 0.0 else None + + bid = f('BestBid') + ask = f('BestAsk') + if bid is not None and ask is not None: + return (bid + ask) / 2.0 + + lb = f('LastBuyPrice') + ls = f('LastSellPrice') + if lb is not None and ls is not None: + return (lb + ls) / 2.0 + + lp = f('LastPrice') + if lp is not None: + return lp + + return ask if ask is not None else bid + + +def _http_post_json(url: str, payload: dict) -> object: + body = json.dumps(payload).encode('utf-8') + req = Request( + url, + data=body, + headers={ + 'content-type': 'application/json', + 'accept': 'application/json', + 'user-agent': HTTP_USER_AGENT, + }, + method='POST', + ) + ctx = ssl.create_default_context() + with urlopen(req, timeout=HTTP_TIMEOUT_SEC, context=ctx) as resp: + raw = resp.read() + if str(resp.headers.get('Content-Encoding', '')).lower() == 'gzip': + raw = gzip.decompress(raw) + return json.loads(raw.decode('utf-8')) + + +def _vault_get_json(url: str, token: str) -> object: + req = Request( + url, + headers={ + 'accept': 'application/json', + 'user-agent': HTTP_USER_AGENT, + 'x-vault-token': token, + }, + ) + ctx = ssl.create_default_context() + with urlopen(req, timeout=HTTP_TIMEOUT_SEC, context=ctx) as resp: + raw = resp.read() + return json.loads(raw.decode('utf-8')) + + +async def load_keydb_url_from_vault() -> str: + if not VAULT_ADDR or not VAULT_MOUNT_POINT or not VAULT_ROLE_ID or not VAULT_SECRET_ID: + raise RuntimeError('Vault env is not fully configured (VAULT_ADDR, VAULT_MOUNT_POINT, VAULT_ROLE_ID, VAULT_SECRET_ID)') + + login_url = f'{VAULT_ADDR}/v1/auth/approle/login' + login_payload = {'role_id': VAULT_ROLE_ID, 'secret_id': VAULT_SECRET_ID} + login_resp = await asyncio.to_thread(_http_post_json, login_url, login_payload) + if not isinstance(login_resp, dict): + raise RuntimeError('Vault login failed: unexpected response') + auth = login_resp.get('auth') + if not isinstance(auth, dict): + raise RuntimeError('Vault login failed: missing auth field') + token = auth.get('client_token') + if not isinstance(token, str) or not token: + raise RuntimeError('Vault login failed: missing client_token') + + secret_url = f'{VAULT_ADDR}/v1/{VAULT_MOUNT_POINT}/data/{VAULT_SECRET_PATH}' + secret_resp = await asyncio.to_thread(_vault_get_json, secret_url, token) + if not isinstance(secret_resp, dict): + raise RuntimeError('Vault read failed: unexpected response') + data = secret_resp.get('data') + if not isinstance(data, dict): + raise RuntimeError('Vault read failed: missing data field') + data2 = data.get('data') + if not isinstance(data2, dict): + raise RuntimeError('Vault read failed: missing data.data field') + + host = str(data2.get('host', '')).strip() + port = str(data2.get('port', '')).strip() + password = str(data2.get('password', '')).strip() + database = str(data2.get('database', '')).strip() + + if not host or not port or not password or database == '': + raise RuntimeError('Vault secret is missing required keydb fields: host,port,password,database') + + return f'redis://:{password}@{host}:{port}/{database}' + + +async def read_freezex_usdt_rub() -> str | None: + try: + data = await asyncio.to_thread(_http_get_json, FREEZEX_TICKER_URL) + if not isinstance(data, list): + logger.warning('Freezex ticker unexpected payload type=%s', type(data).__name__) + return None + symbol_u = FREEZEX_SYMBOL.upper() + for row in data: + if not isinstance(row, dict): + continue + if str(row.get('Symbol', '')).upper() != symbol_u: + continue + price = _pick_mid_price(row) + return fmt_rate(price) if price is not None else None + logger.warning('Freezex ticker: symbol not found: %s', FREEZEX_SYMBOL) + return None + except Exception as e: + logger.warning('Freezex ticker fetch failed: %s', e) + return None + + +async def read_price(page) -> str | None: + try: + locator = page.locator(PRICE_SELECTOR).nth(PRICE_INDEX) + raw = await locator.inner_text(timeout=5000) + return to_float_str(raw) + except Exception as e: + logger.warning('Failed to read price: %s', e) + return None + + +async def write_to_redis( + r: redis.Redis, + ts_iso: str, + usdt: str | None, + usdc: str | None, + extra: dict[str, str] | None = None, +) -> None: + data = {'ts': ts_iso} + if usdt is not None: + data['usdt_rub'] = usdt + if usdc is not None: + data['usdc_rub'] = usdc + if extra: + data.update(extra) + await r.hset(REDIS_KEY_LAST, mapping=data) + + +async def write_rate_value(r: redis.Redis, ts_iso: str, rate_str: str | None) -> None: + if rate_str is None: + return + value2 = fmt_ceil_2(rate_str) + if value2 is None: + return + await r.hset(KEYDB_KEY_RATE, mapping={'ts': ts_iso, 'value': value2}) + + +async def run_loop() -> None: + setup_logging() + logger.info( + 'Starting worker interval=%ss key=%s selector=%s index=%s freezex=%s symbol=%s vault=%s mount=%s secret=%s', + INTERVAL_SEC, REDIS_KEY_LAST, PRICE_SELECTOR, PRICE_INDEX, FREEZEX_TICKER_URL, FREEZEX_SYMBOL, VAULT_ADDR, VAULT_MOUNT_POINT, VAULT_SECRET_PATH + ) + + try: + keydb_url = await load_keydb_url_from_vault() + logger.info('KeyDB url loaded from vault') + except Exception: + logger.exception('Failed to load KeyDB config from vault') + return + + r = redis.from_url(keydb_url, decode_responses=True) + try: + await r.ping() + logger.info('KeyDB connected') + except Exception: + logger.exception('KeyDB connection failed') + return + + async with async_playwright() as p: + launch_kwargs = {'headless': True} + if USE_CHROME_CHANNEL: + launch_kwargs['channel'] = 'chrome' + + browser = await p.chromium.launch(**launch_kwargs) + context = await browser.new_context() + + page_usdt = await context.new_page() + page_usdc = await context.new_page() + + logger.info('Opening pages...') + await page_usdt.goto(USDT_URL, wait_until='domcontentloaded') + await page_usdc.goto(USDC_URL, wait_until='domcontentloaded') + await asyncio.sleep(2) + logger.info('Pages opened') + + try: + while True: + ts_iso = datetime.now(timezone.utc).isoformat() + + usdt_tradex_raw = await read_price(page_usdt) + usdt_tradex = add_to_rate(usdt_tradex_raw, USDT_ADD_RUB) + + usdt_freezex_raw = await read_freezex_usdt_rub() + usdt_freezex = add_to_rate(usdt_freezex_raw, FREEZEX_ADD_RUB) + + usdt_avg = avg_rates(usdt_tradex, usdt_freezex) + + usdc_tradex_raw = await read_price(page_usdc) + usdc_tradex = add_to_rate(usdc_tradex_raw, USDC_ADD_RUB) + + status = ( + 'ok' + if usdt_avg and usdc_tradex + else 'partial' + if usdt_avg or usdc_tradex + else 'failed' + ) + logger.info( + 'tick status=%s USDT(avg)=%s USDT(tradex raw/adj)=%s/%s USDT(freezex raw/adj)=%s/%s USDC(raw/adj)=%s/%s', + status, + usdt_avg, + usdt_tradex_raw, + usdt_tradex, + usdt_freezex_raw, + usdt_freezex, + usdc_tradex_raw, + usdc_tradex, + ) + + try: + extra: dict[str, str] = {} + if usdt_tradex_raw is not None: + extra['usdt_rub_tradex_raw'] = usdt_tradex_raw + if usdt_tradex is not None: + extra['usdt_rub_tradex'] = usdt_tradex + if usdt_freezex_raw is not None: + extra['usdt_rub_freezex_raw'] = usdt_freezex_raw + if usdt_freezex is not None: + extra['usdt_rub_freezex'] = usdt_freezex + if usdt_avg is not None: + extra['usdt_rub_avg'] = usdt_avg + + if usdc_tradex_raw is not None: + extra['usdc_rub_tradex_raw'] = usdc_tradex_raw + if usdc_tradex is not None: + extra['usdc_rub_tradex'] = usdc_tradex + + usdt_avg_ceil2 = fmt_ceil_2(usdt_avg) + usdc_tradex_ceil2 = fmt_ceil_2(usdc_tradex) + await write_to_redis(r, ts_iso, usdt_avg_ceil2, usdc_tradex_ceil2, extra=extra) + await write_rate_value(r, ts_iso, usdt_avg) + logger.info('Written to keydb') + except Exception: + logger.exception('KeyDB write failed') + + await asyncio.sleep(INTERVAL_SEC) + + except KeyboardInterrupt: + logger.info('Stopped by user') + finally: + await browser.close() + await r.close() + logger.info('Shutdown complete') + + +def main() -> None: + asyncio.run(run_loop()) + + +if __name__ == '__main__': + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bf1577e --- /dev/null +++ b/uv.lock @@ -0,0 +1,132 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "crypto-parser" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "dotenv" }, + { name = "patchright" }, + { name = "redis" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "patchright", specifier = ">=1.57.2" }, + { name = "redis", specifier = ">=7.1.0" }, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, + { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, + { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, + { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, + { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, + { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, + { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +] + +[[package]] +name = "patchright" +version = "1.57.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/9d/2e8ab9f09bdbfd4b8b17229f92f3b2af32fd8474a19c92d94f08c7a78dd1/patchright-1.57.2-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:eecd88992104156da183bf0e0144a43c278227a0eb9e538ae62c3f01448b86b8", size = 41963318, upload-time = "2025-12-30T15:33:40.533Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/80ee346f4d22cca4d6e6112e162dad51d9702350438b4b644acfe2874ce2/patchright-1.57.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b16d07fc454fbd7392c29663784abf9a577b2482df3479ae487ecd8bc3948b68", size = 40753852, upload-time = "2025-12-30T15:33:43.558Z" }, + { url = "https://files.pythonhosted.org/packages/49/84/d3f53f39855288bbe26a4ee8b5af00e1aab940af7dc971be53b3209671a9/patchright-1.57.2-py3-none-macosx_11_0_universal2.whl", hash = "sha256:af8e74a16af04dda592a8f8e89e4dd041e1ffcfd90566d17577176958fe80393", size = 41963318, upload-time = "2025-12-30T15:33:46.451Z" }, + { url = "https://files.pythonhosted.org/packages/fa/6b/214164f57f339742c3795e55cd472c7cdb82ac769d89548c6526477794f8/patchright-1.57.2-py3-none-manylinux1_x86_64.whl", hash = "sha256:5119daf606c0d21185ef4d2a6f4008db915173ed11601ebedd67abadcab0d594", size = 45953528, upload-time = "2025-12-30T15:33:49.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/5c/fb81d91376ac15d275812ab6fb8048506ee444413a97319ef640817b0984/patchright-1.57.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b58d6b2122c47dc72204847eff0ec0ea67afcb1f6573fa3f357b149b389af4ff", size = 45685195, upload-time = "2025-12-30T15:33:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/c2/87/7a2e2fab5d25aa4fce59dfe9d69d3b18a437b19243fdf498697dc1fe0f98/patchright-1.57.2-py3-none-win32.whl", hash = "sha256:54f5f090ed5c9bb331c79c5546b9a43854ee87eb31072c308f45c76561e409b5", size = 36532138, upload-time = "2025-12-30T15:33:55.802Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0f/ed5262996ea573632c4c18ab72c8eafc1eb1ee2c8b3d8470cb52f7075ca1/patchright-1.57.2-py3-none-win_amd64.whl", hash = "sha256:8865dca554dbe98b585e277f3d151e0b5fccdf3765408ec0c8631093f7828eda", size = 36532143, upload-time = "2025-12-30T15:33:58.507Z" }, + { url = "https://files.pythonhosted.org/packages/5c/31/5371fb36ec6ec3b4b1ae1784dd5254e2c7b85d0e0d34dd371933ffde2c53/patchright-1.57.2-py3-none-win_arm64.whl", hash = "sha256:830173d31c0a9eed6c03192e191dc007f30f05bc580764860cbd0001431f6f01", size = 32816218, upload-time = "2025-12-30T15:34:01.582Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]