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
|
|||
|
|
|