""" Скачивание удалённых списков с ETag-кэшем и фолбэком на локальный кэш при недоступности источника - логика 1:1 с _fetch_url() из bash-версии. Отдельная страховка: PySocks (используется при socks5h:// прокси) не всегда честно соблюдает timeout, переданный в requests.get() - может зависнуть на этапе SOCKS-хендшейка или чтения ответа. Поэтому вокруг запроса дополнительно ставится жёсткий wall-clock таймаут через SIGALRM, чтобы скрипт гарантированно не висел вечно на одном списке. """ from __future__ import annotations import hashlib import logging import signal from contextlib import contextmanager from pathlib import Path import requests log = logging.getLogger("xray-lists.fetch") class HardTimeout(Exception): pass @contextmanager def _hard_timeout(seconds: int): def _on_alarm(signum, frame): raise HardTimeout(f"hard timeout after {seconds}s (possibly a hung proxy)") previous = signal.signal(signal.SIGALRM, _on_alarm) signal.alarm(seconds) try: yield finally: signal.alarm(0) signal.signal(signal.SIGALRM, previous) def _url_hash(url: str) -> str: return hashlib.md5(url.encode()).hexdigest()[:8] def build_session(proxy: str | None, proxy_user: str | None, proxy_pass: str | None) -> requests.Session: s = requests.Session() if proxy: if proxy_user: # вставляем креды в URL прокси: scheme://user:pass@host:port scheme, rest = proxy.split("://", 1) proxy = f"{scheme}://{proxy_user}:{proxy_pass}@{rest}" s.proxies.update({"http": proxy, "https": proxy}) return s def fetch_url(session: requests.Session, cache_dir: Path, entry_id: str, url: str, timeout: int) -> str | None: """ Возвращает текстовое содержимое URL, используя ETag-кэш. None означает "данных нет вообще" (сеть недоступна и кэша тоже нет). """ cache_dir.mkdir(parents=True, exist_ok=True) h = _url_hash(url) raw_cache = cache_dir / f"{entry_id}_{h}.raw" etag_file = cache_dir / f"{entry_id}_{h}.etag" headers = {} etag = None if etag_file.is_file(): etag = etag_file.read_text().strip() if etag: headers["If-None-Match"] = f'"{etag}"' try: # +5s запас сверх requests-таймаута: если requests/PySocks сами # отработают штатно, alarm просто снимется в finally и не выстрелит. with _hard_timeout(timeout + 5): resp = session.get(url, headers=headers, timeout=timeout) except (requests.RequestException, HardTimeout) as exc: log.warning("%s: download failed (%s), using local cache if any", entry_id, exc) return raw_cache.read_text() if raw_cache.is_file() else None if resp.status_code == 304: log.info("%s: 304 not modified (%s)", entry_id, url) return raw_cache.read_text() if raw_cache.is_file() else None if resp.status_code == 200: log.info("%s: 200 ok (%s)", entry_id, url) new_etag = resp.headers.get("ETag", "").strip().strip('"').lstrip("W/") if new_etag: etag_file.write_text(new_etag) raw_cache.write_text(resp.text) return resp.text log.warning("%s: unexpected status %s for %s, using local cache if any", entry_id, resp.status_code, url) return raw_cache.read_text() if raw_cache.is_file() else None