Добавил кеш к lyngsat

This commit is contained in:
2025-11-11 21:43:59 +03:00
parent 4f21c9d7c8
commit a3c381b9c7
15 changed files with 1282 additions and 229 deletions

View File

@@ -0,0 +1,564 @@
"""
Асинхронный парсер данных LyngSat с поддержкой кеширования в Redis.
"""
import requests
from bs4 import BeautifulSoup
from datetime import datetime
import re
import logging
from typing import Optional
from django.core.cache import cache
logger = logging.getLogger(__name__)
def parse_satellite_names(satellite_string: str) -> list[str]:
"""Извлекает все возможные имена спутников из строки."""
slash_parts = [part.strip() for part in satellite_string.split('/')]
all_names = []
for part in slash_parts:
main_match = re.match(r'^([^(]+)', part)
if main_match:
main_name = main_match.group(1).strip()
if main_name:
all_names.append(main_name)
bracket_match = re.search(r'\(([^)]+)\)', part)
if bracket_match:
bracket_name = bracket_match.group(1).strip()
if bracket_name:
all_names.append(bracket_name)
seen = set()
result = []
for name in all_names:
if name not in seen:
seen.add(name)
result.append(name.strip().lower())
return result
class AsyncLyngSatParser:
"""
Асинхронный парсер данных для LyngSat с поддержкой кеширования.
Кеширование:
- Страницы регионов кешируются на 7 дней
- Данные спутников кешируются на 1 день
"""
# Время жизни кеша
REGION_CACHE_TTL = 60 * 60 * 24 * 7 # 7 дней
SATELLITE_CACHE_TTL = 60 * 60 * 24 # 1 день
# Префиксы для ключей кеша
REGION_CACHE_PREFIX = "lyngsat_region"
SATELLITE_CACHE_PREFIX = "lyngsat_satellite"
SATELLITE_LIST_CACHE_PREFIX = "lyngsat_sat_list"
def __init__(
self,
flaresolver_url: str = "http://localhost:8191/v1",
regions: list[str] | None = None,
target_sats: list[str] | None = None,
use_cache: bool = True,
):
"""
Инициализация парсера.
Args:
flaresolver_url: URL FlareSolverr для обхода защиты
regions: Список регионов для парсинга
target_sats: Список целевых спутников (в нижнем регистре)
use_cache: Использовать ли кеширование
"""
self.flaresolver_url = flaresolver_url
self.use_cache = use_cache
self.target_sats = (
list(map(lambda sat: sat.strip().lower(), target_sats)) if target_sats else None
)
self.regions = regions if regions else ["europe", "asia", "america", "atlantic"]
self.BASE_URL = "https://www.lyngsat.com"
def _get_cache_key(self, prefix: str, identifier: str) -> str:
"""Генерирует ключ для кеша."""
return f"{prefix}:{identifier}"
def _get_from_cache(self, key: str) -> Optional[any]:
"""Получает данные из кеша."""
if not self.use_cache:
return None
try:
data = cache.get(key)
if data:
logger.debug(f"Данные получены из кеша: {key}")
return data
except Exception as e:
logger.warning(f"Ошибка при получении из кеша {key}: {e}")
return None
def _set_to_cache(self, key: str, value: any, ttl: int) -> None:
"""Сохраняет данные в кеш."""
if not self.use_cache:
return
try:
cache.set(key, value, timeout=ttl)
logger.debug(f"Данные сохранены в кеш: {key} (TTL: {ttl}s)")
except Exception as e:
logger.warning(f"Ошибка при сохранении в кеш {key}: {e}")
@classmethod
def clear_cache(cls, cache_type: str = "all") -> dict:
"""
Очищает кеш парсера.
Args:
cache_type: Тип кеша для очистки ("regions", "satellites", "all")
Returns:
dict: Статистика очистки
"""
stats = {"cleared": 0, "errors": []}
try:
from django.core.cache import cache as django_cache
if cache_type in ("regions", "all"):
# Очищаем кеш регионов
regions = ["europe", "asia", "america", "atlantic"]
for region in regions:
key = f"{cls.REGION_CACHE_PREFIX}:{region}"
try:
result = django_cache.delete(key)
if result:
stats["cleared"] += 1
logger.info(f"Очищен кеш региона: {region}")
else:
logger.debug(f"Кеш региона {region} не найден или уже удален")
except Exception as e:
error_msg = f"Ошибка при очистке кеша региона {region}: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
if cache_type in ("satellites", "all"):
# Для очистки кеша спутников используем keys()
if hasattr(django_cache, 'keys'):
try:
# Очищаем списки спутников
list_keys = django_cache.keys(f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*")
if list_keys:
if hasattr(django_cache, 'delete_many'):
django_cache.delete_many(list_keys)
else:
for key in list_keys:
django_cache.delete(key)
stats["cleared"] += len(list_keys)
logger.info(f"Очищено {len(list_keys)} списков спутников")
# Очищаем данные спутников
sat_keys = django_cache.keys(f"{cls.SATELLITE_CACHE_PREFIX}:*")
if sat_keys:
if hasattr(django_cache, 'delete_many'):
django_cache.delete_many(sat_keys)
else:
for key in sat_keys:
django_cache.delete(key)
stats["cleared"] += len(sat_keys)
logger.info(f"Очищено {len(sat_keys)} данных спутников")
except Exception as e:
error_msg = f"Ошибка при очистке кеша спутников: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
else:
logger.warning("Бэкенд кеша не поддерживает keys()")
logger.info("Для полной очистки используйте: redis-cli flushdb")
except Exception as e:
error_msg = f"Критическая ошибка при очистке кеша: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
return stats
@classmethod
def clear_all_cache(cls) -> dict:
"""Полностью очищает весь кеш LyngSat."""
stats = {"cleared": 0, "errors": []}
try:
from django.core.cache import cache as django_cache
# Для django-redis используем keys() + delete_many()
if hasattr(django_cache, 'keys'):
patterns = [
f"{cls.REGION_CACHE_PREFIX}:*",
f"{cls.SATELLITE_CACHE_PREFIX}:*",
f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*",
]
all_keys = []
for pattern in patterns:
try:
keys = django_cache.keys(pattern)
if keys:
all_keys.extend(keys)
logger.info(f"Найдено {len(keys)} ключей по паттерну: {pattern}")
except Exception as e:
error_msg = f"Ошибка при поиске ключей {pattern}: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
# Удаляем все найденные ключи
if all_keys:
try:
if hasattr(django_cache, 'delete_many'):
django_cache.delete_many(all_keys)
else:
for key in all_keys:
django_cache.delete(key)
stats["cleared"] = len(all_keys)
logger.info(f"Удалено {len(all_keys)} ключей")
except Exception as e:
error_msg = f"Ошибка при удалении ключей: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
else:
logger.info("Ключи для удаления не найдены")
elif hasattr(django_cache, 'delete_pattern'):
# Fallback на delete_pattern
patterns = [
f"{cls.REGION_CACHE_PREFIX}:*",
f"{cls.SATELLITE_CACHE_PREFIX}:*",
f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*",
]
for pattern in patterns:
try:
deleted = django_cache.delete_pattern(pattern)
if deleted and isinstance(deleted, int):
stats["cleared"] += deleted
logger.info(f"Очищено {deleted} ключей по паттерну: {pattern}")
except Exception as e:
error_msg = f"Ошибка при очистке паттерна {pattern}: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
else:
# Fallback для других бэкендов кеша
logger.warning("Бэкенд кеша не поддерживает keys() или delete_pattern()")
return cls.clear_cache("all")
except Exception as e:
error_msg = f"Критическая ошибка при полной очистке кеша: {e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
return stats
def parse_metadata(self, metadata: str) -> dict:
"""Парсит метаданные транспондера."""
if not metadata or not metadata.strip():
return {
"standard": None,
"modulation": None,
"symbol_rate": None,
"fec": None,
}
normalized = re.sub(r"\s+", "", metadata.strip())
fec_match = re.search(r"([1-9]/[1-9])$", normalized)
fec = fec_match.group(1) if fec_match else None
if fec_match:
core = normalized[: fec_match.start()]
else:
core = normalized
std_match = re.match(r"(DVB-S2?|ABS-S|DVB-T2?|ATSC|ISDB)", core)
standard = std_match.group(1) if std_match else None
rest = core[len(standard) :] if standard else core
modulation = None
mod_match = re.match(r"(8PSK|QPSK|16APSK|32APSK|64QAM|256QAM|BPSK)", rest)
if mod_match:
modulation = mod_match.group(1)
rest = rest[len(modulation) :]
symbol_rate = None
sr_match = re.search(r"(\d+)$", rest)
if sr_match:
try:
symbol_rate = int(sr_match.group(1))
except ValueError:
pass
return {
"standard": standard,
"modulation": modulation,
"symbol_rate": symbol_rate,
"fec": fec,
}
def extract_date(self, s: str) -> datetime | None:
"""Извлекает дату из строки формата YYMMDD."""
s = s.strip()
match = re.search(r"(\d{6})$", s)
if not match:
return None
yymmdd = match.group(1)
try:
return datetime.strptime(yymmdd, "%y%m%d").date()
except ValueError:
return None
def convert_polarization(self, polarization: str) -> str:
"""Преобразует код поляризации в понятное название на русском."""
polarization_map = {
"V": "Вертикальная",
"H": "Горизонтальная",
"R": "Правая",
"L": "Левая",
}
return polarization_map.get(polarization.upper(), polarization)
def fetch_region_page(self, region: str, force_refresh: bool = False) -> Optional[str]:
"""
Получает HTML страницу региона с кешированием.
Args:
region: Название региона
force_refresh: Принудительно обновить кеш
Returns:
HTML содержимое страницы или None при ошибке
"""
cache_key = self._get_cache_key(self.REGION_CACHE_PREFIX, region)
# Проверяем кеш
if not force_refresh:
cached_html = self._get_from_cache(cache_key)
if cached_html:
logger.info(f"Страница региона {region} получена из кеша")
return cached_html
# Запрашиваем страницу
url = f"{self.BASE_URL}/{region}.html"
logger.info(f"Запрос страницы региона: {url}")
try:
payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000}
response = requests.post(self.flaresolver_url, json=payload, timeout=70)
if response.status_code != 200:
logger.error(f"Ошибка при запросе {url}: статус {response.status_code}")
return None
html_content = response.json().get("solution", {}).get("response", "")
if html_content:
# Сохраняем в кеш
self._set_to_cache(cache_key, html_content, self.REGION_CACHE_TTL)
logger.info(f"Страница региона {region} получена и закеширована")
return html_content
except Exception as e:
logger.error(f"Ошибка при получении страницы {url}: {e}", exc_info=True)
return None
def get_satellite_list_from_region(self, region: str, force_refresh: bool = False) -> list[dict]:
"""
Получает список спутников из региона.
Args:
region: Название региона
force_refresh: Принудительно обновить кеш
Returns:
Список словарей с информацией о спутниках
"""
# Создаем уникальный ключ кеша с учетом целевых спутников
# Если target_sats не указаны, используем "all"
sats_key = "all" if not self.target_sats else "_".join(sorted(self.target_sats))
cache_key = self._get_cache_key(self.SATELLITE_LIST_CACHE_PREFIX, f"{region}_{sats_key}")
# Проверяем кеш
if not force_refresh:
cached_list = self._get_from_cache(cache_key)
if cached_list:
logger.info(f"Список спутников региона {region} (фильтр: {sats_key[:50]}) получен из кеша")
return cached_list
# Получаем HTML страницы
html_content = self.fetch_region_page(region, force_refresh)
if not html_content:
return []
# Парсим список спутников
satellites = []
try:
soup = BeautifulSoup(html_content, "html.parser")
col_table = soup.find_all("div", class_="desktab")[0]
tables = col_table.find_next_sibling("table").find_all("table")
trs = []
for table in tables:
trs.extend(table.find_all("tr"))
for tr in trs:
sat_name = tr.find("span").text.replace("ü", "u").strip().lower()
# Фильтруем по целевым спутникам
if self.target_sats is not None:
names = parse_satellite_names(sat_name)
if len(names) == 1:
sat_name = names[0]
else:
for name in names:
if name in self.target_sats:
sat_name = name
break
if sat_name not in self.target_sats:
continue
try:
sat_url = tr.find_all("a")[2]["href"]
except IndexError:
sat_url = tr.find_all("a")[0]["href"]
update_date_str = tr.find_all("td")[-1].text
try:
update_date = datetime.strptime(update_date_str, "%y%m%d").date()
except (ValueError, TypeError):
update_date = None
satellites.append({
"name": sat_name,
"url": sat_url,
"update_date": update_date,
"region": region
})
# Сохраняем в кеш
self._set_to_cache(cache_key, satellites, self.REGION_CACHE_TTL)
sats_filter = "все" if not self.target_sats else f"{len(self.target_sats)} целевых"
logger.info(f"Найдено {len(satellites)} спутников в регионе {region} (фильтр: {sats_filter})")
except Exception as e:
logger.error(f"Ошибка при парсинге списка спутников региона {region}: {e}", exc_info=True)
return satellites
def fetch_satellite_data(self, sat_name: str, sat_url: str, force_refresh: bool = False) -> Optional[dict]:
"""
Получает данные одного спутника с кешированием.
Args:
sat_name: Название спутника
sat_url: URL страницы спутника
force_refresh: Принудительно обновить кеш
Returns:
Словарь с данными спутника или None при ошибке
"""
cache_key = self._get_cache_key(self.SATELLITE_CACHE_PREFIX, sat_name)
# Проверяем кеш
if not force_refresh:
cached_data = self._get_from_cache(cache_key)
if cached_data:
logger.info(f"Данные спутника {sat_name} получены из кеша")
return cached_data
# Запрашиваем данные
full_url = f"{self.BASE_URL}/{sat_url}"
logger.info(f"Запрос данных спутника {sat_name}: {full_url}")
try:
payload = {"cmd": "request.get", "url": full_url, "maxTimeout": 60000}
response = requests.post(self.flaresolver_url, json=payload, timeout=70)
if response.status_code != 200:
logger.error(f"Ошибка при запросе {full_url}: статус {response.status_code}")
return None
html_content = response.json().get("solution", {}).get("response", "")
if not html_content:
logger.warning(f"Пустой ответ для спутника {sat_name}")
return None
# Парсим данные
sources = self.parse_satellite_content(html_content)
satellite_data = {
"name": sat_name,
"url": full_url,
"sources": sources,
"fetched_at": datetime.now().isoformat()
}
# Сохраняем в кеш
self._set_to_cache(cache_key, satellite_data, self.SATELLITE_CACHE_TTL)
logger.info(f"Данные спутника {sat_name} получены и закешированы ({len(sources)} источников)")
return satellite_data
except Exception as e:
logger.error(f"Ошибка при получении данных спутника {sat_name}: {e}", exc_info=True)
return None
def parse_satellite_content(self, html_content: str) -> list[dict]:
"""Парсит содержимое страницы спутника."""
data = []
try:
sat_soup = BeautifulSoup(html_content, "html.parser")
big_table = sat_soup.find("table", class_="bigtable")
if not big_table:
logger.warning("Таблица bigtable не найдена")
return data
all_tables = big_table.find_all("div", class_="desktab")[:-1]
for table in all_tables:
trs = table.find_next_sibling("table").find_all("tr")
for idx, tr in enumerate(trs):
tds = tr.find_all("td")
if len(tds) < 9 or idx < 2:
continue
try:
freq, polarization = tds[0].find("b").text.strip().split("\xa0")
polarization = self.convert_polarization(polarization)
meta = self.parse_metadata(tds[1].text)
provider_name = tds[3].text
last_update = self.extract_date(tds[-1].text)
data.append({
"freq": freq,
"pol": polarization,
"metadata": meta,
"provider_name": provider_name,
"last_update": last_update,
})
except Exception as e:
logger.debug(f"Ошибка при парсинге строки транспондера: {e}")
continue
except Exception as e:
logger.error(f"Ошибка при парсинге содержимого спутника: {e}", exc_info=True)
return data
def get_all_satellites_list(self, force_refresh: bool = False) -> list[dict]:
"""
Получает список всех спутников из всех регионов.
Args:
force_refresh: Принудительно обновить кеш
Returns:
Список словарей с информацией о спутниках
"""
all_satellites = []
for region in self.regions:
logger.info(f"Получение списка спутников из региона: {region}")
satellites = self.get_satellite_list_from_region(region, force_refresh)
all_satellites.extend(satellites)
logger.info(f"Всего найдено спутников: {len(all_satellites)}")
return all_satellites