Files

100 lines
3.8 KiB
Python
Raw Permalink Normal View History

2026-08-16 23:21:59 +00:00
"""
Скачивание удалённых списков с 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