""" Асинхронный парсер данных 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