100 lines
3.8 KiB
Python
100 lines
3.8 KiB
Python
"""
|
||
Скачивание удалённых списков с 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
|
||
|