diff --git a/scraper/__main__.py b/scraper/__main__.py new file mode 100644 index 0000000..cb679e6 --- /dev/null +++ b/scraper/__main__.py @@ -0,0 +1,200 @@ +"""Точка входа CLI для scraper. + +Использование: + python -m scraper xenforo URL [--workers N] [--no-delay] + python -m scraper phpbb ID [--base URL] [--workers N] + +Примеры: + python -m scraper xenforo https://vwts.ru/forum/vag/benzinovye-dvigateli/ + python -m scraper xenforo URL --workers 8 + python -m scraper phpbb 22 + python -m scraper phpbb 22 --base https://нива-лада.рф/n/ +""" + +import sys +import logging +from pathlib import Path + +# ─── Инициализация логирования ────────────────────────────────────────── +# Должна быть первой, чтобы все логи писались сразу +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", + handlers=[ + logging.StreamHandler(sys.stdout), + ], +) +log = logging.getLogger(__name__) + + +def _print_usage(): + """Вывести справку по использованию.""" + print("Использование:") + print(" python -m scraper xenforo URL [опции]") + print(" python -m scraper phpbb FORUM_ID [опции]") + print() + print("XenForo (vwts.ru):") + print(" python -m scraper xenforo https://vwts.ru/forum/vag/.../") + print() + print("phpBB (нива-лада.рф):") + print(" python -m scraper phpbb 22") + print(" python -m scraper phpbb 22 --base https://нива-лада.рф/n/") + print() + print("Опции:") + print(" --workers N количество потоков (по умолчанию 4)") + print(" --no-delay без координации задержек") + print(" --no-ban-test не проверять бан") + print(" --data-dir DIR папка для данных (по умолчанию vwts_data / lada_data)") + print(" --base URL базовый URL форума (для phpBB)") + + +def main(): + """Точка входа: разбор аргументов → запуск нужного адаптера.""" + args = sys.argv[1:] + + if not args or args[0] in ("-h", "--help"): + _print_usage() + sys.exit(0) + + # ── Определяем движок ─────────────────────────────────────────────── + engine = args.pop(0) + + if engine == "xenforo": + _run_xenforo(args) + elif engine == "phpbb": + _run_phpbb(args) + else: + print(f"❌ Неизвестный движок: {engine}") + print(" Доступно: xenforo, phpbb") + sys.exit(1) + + +# ═══════════════════════════════════════════════════════════════════════════ +# XenForo +# ═══════════════════════════════════════════════════════════════════════════ + +def _run_xenforo(args: list[str]): + """Запуск парсинга для XenForo (vwts.ru). + + Args: + args: список аргументов после "xenforo". + """ + if not args or args[0].startswith("-"): + print("❌ Укажи URL раздела. Пример:") + print(" python -m scraper xenforo https://vwts.ru/forum/vag/.../") + sys.exit(1) + + section_url = args.pop(0).rstrip("/") + "/" + + # Парсим опции + workers = 4 + no_delay = False + ban_test = True + data_dir = "vwts_data" + + i = 0 + while i < len(args): + if args[i] == "--workers" and i + 1 < len(args): + workers = int(args[i + 1]) + i += 2 + elif args[i] == "--no-delay": + no_delay = True + i += 1 + elif args[i] == "--no-ban-test": + ban_test = False + i += 1 + elif args[i] == "--data-dir" and i + 1 < len(args): + data_dir = args[i + 1] + i += 2 + else: + i += 1 + + # Импортируем адаптер + from .forums.xenforo.adapter import XenForoAdapter + from .core.runner import run + + adapter = XenForoAdapter(data_dir=data_dir) + + log.info("=" * 60) + log.info("🚀 scraper — XenForo: %s", section_url.split("/")[-2]) + log.info("📂 %s", Path(data_dir) / section_url.split("/")[-2] / "part_*.jsonl") + log.info("🛑 Ctrl+C = пауза | Повторить команду = продолжить") + log.info("=" * 60) + + try: + run(adapter, section_url, + workers=workers, no_delay=no_delay, ban_test=ban_test) + except KeyboardInterrupt: + log.info("\n🛑 ПАУЗА. Продолжить: та же команда.") + sys.exit(0) + + +# ═══════════════════════════════════════════════════════════════════════════ +# phpBB +# ═══════════════════════════════════════════════════════════════════════════ + +def _run_phpbb(args: list[str]): + """Запуск парсинга для phpBB (нива-лада.рф). + + Args: + args: список аргументов после "phpbb". + """ + if not args or args[0].startswith("-"): + print("❌ Укажи ID раздела. Пример:") + print(" python -m scraper phpbb 22") + sys.exit(1) + + forum_id = args.pop(0) + + # Парсим опции + workers = 4 + no_delay = False + ban_test = True + data_dir = "lada_data" + forum_base = "https://нива-лада.рф/n/" + + i = 0 + while i < len(args): + if args[i] == "--workers" and i + 1 < len(args): + workers = int(args[i + 1]) + i += 2 + elif args[i] == "--no-delay": + no_delay = True + i += 1 + elif args[i] == "--no-ban-test": + ban_test = False + i += 1 + elif args[i] == "--data-dir" and i + 1 < len(args): + data_dir = args[i + 1] + i += 2 + elif args[i] == "--base" and i + 1 < len(args): + forum_base = args[i + 1] + i += 2 + else: + i += 1 + + # Формируем URL раздела + section_url = f"{forum_base}viewforum.php?f={forum_id}" + + from .forums.phpbb.adapter import PhpBBAdapter + from .core.runner import run + + adapter = PhpBBAdapter(data_dir=data_dir, forum_base=forum_base) + + log.info("=" * 60) + log.info("🚀 scraper — phpBB: f=%s", forum_id) + log.info("📂 %s", Path(data_dir) / f"f{forum_id}" / "part_*.jsonl") + log.info("🛑 Ctrl+C = пауза | Повторить команду = продолжить") + log.info("=" * 60) + + try: + run(adapter, section_url, section_slug=f"f{forum_id}", + workers=workers, no_delay=no_delay, ban_test=ban_test) + except KeyboardInterrupt: + log.info("\n🛑 ПАУЗА. Продолжить: та же команда.") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scraper/core/__init__.py b/scraper/core/__init__.py new file mode 100644 index 0000000..006632b --- /dev/null +++ b/scraper/core/__init__.py @@ -0,0 +1,4 @@ +# scraper.core — общий слой парсера +# Содержит модули, независимые от движка форума. +# См. fetch.py, throttle.py, output.py, state.py, runner.py. + diff --git a/scraper/core/fetch.py b/scraper/core/fetch.py new file mode 100644 index 0000000..3ef493b --- /dev/null +++ b/scraper/core/fetch.py @@ -0,0 +1,118 @@ +"""HTTP-запросы: GET с ретраями. + +Единая точка вызова HTTP для всех адаптеров. +Не зависит от движка форума. +""" + +import time +import logging + +import requests +from bs4 import BeautifulSoup + +# ─── Модульный логгер ─────────────────────────────────────────────────── +log = logging.getLogger(__name__) + +# ─── Глобальный throttle (устанавливается в runner.py) ────────────────── +# fetch.get() вызывает throttle.wait() перед каждым запросом. +throttle = None # type: ignore + + +# ─── Дефолтные значения ───────────────────────────────────────────────── +TIMEOUT = (10, 30) # (connect, read) — requests-таймаут +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/125.0.0.0 Safari/537.36" + ), +} +RETRIES = 3 # максимум HTTP-попыток на один URL + + +def make_session() -> requests.Session: + """Создать HTTP-сессию с дефолтными заголовками. + + Returns: + requests.Session с предустановленным User-Agent. + """ + session = requests.Session() + session.headers.update(HEADERS) + return session + + +def get(url: str, session=None) -> BeautifulSoup | None: + """GET-запрос с ретраями, парсинг в BeautifulSoup (lxml). + + Логика повторных попыток: + 1. 404 → сразу None (бесполезно ретраить). + 2. 403/429/503 → ждём 10*N сек, потом ретрай. + 3. Сеть/Таймаут → ждём 5 сек, потом ретрай. + 4. Прочее → ждём 5 сек, потом ретрай. + 5. Все RETRIES исчерпаны → None. + + Args: + url: Полный URL для загрузки. + session: Сессия (если None — временная). + + Returns: + BeautifulSoup или None при недоступности. + """ + # ── Сессия ────────────────────────────────────────────────────────── + if session is None: + session = make_session() + + # ── Throttle ──────────────────────────────────────────────────────── + if throttle is not None: + throttle.wait() + + # ── Цикл ретраев ──────────────────────────────────────────────────── + for attempt in range(1, RETRIES + 1): + try: + response = session.get(url, timeout=TIMEOUT) + + # 404 — не найдено + if response.status_code == 404: + log.warning(" ⏭️ 404: %s", url[:80]) + return None + + # 403/429/503 — сервер банит/перегружен + if response.status_code in (403, 429, 503): + wait = 10 * attempt + log.warning( + "⚠️ HTTP %d — попытка %d/%d, жду %d с", + response.status_code, attempt, RETRIES, wait, + ) + time.sleep(wait) + continue + + # Любая другая HTTP-ошибка + response.raise_for_status() + + # Успех + return BeautifulSoup(response.text, "lxml") + + except (requests.ConnectionError, requests.Timeout) as exc: + log.warning( + "⚠️ Сеть: %s — попытка %d/%d, жду 5 с", + exc, attempt, RETRIES, + ) + time.sleep(5) + + except requests.HTTPError as exc: + log.warning( + "⚠️ HTTP %s — попытка %d/%d, жду 5 с", + exc, attempt, RETRIES, + ) + time.sleep(5) + + except Exception as exc: + log.warning( + "⚠️ Ошибка: %s — попытка %d/%d, жду 5 с", + exc, attempt, RETRIES, + ) + time.sleep(5) + + # Все попытки исчерпаны + log.error("❌ Не загружено (%d попыток): %s", RETRIES, url[:80]) + return None diff --git a/scraper/core/output.py b/scraper/core/output.py new file mode 100644 index 0000000..2b8ddc4 --- /dev/null +++ b/scraper/core/output.py @@ -0,0 +1,134 @@ +"""Запись данных: JSONL с ротацией по N тем на файл. + +Формат одной записи (одна тема): + { + "section": str, # Название раздела + "section_id": str, # ID раздела + "topic_id": int, # ID темы на форуме + "topic_title": str, # Заголовок темы + "topic_url": str, # Прямая ссылка на тему + "tags": [str], # Список тегов (если есть) + "total_posts": int, # Количество постов в теме + "posts": [ # Список постов + { + "author": str, # Имя автора + "date": str, # Дата/время поста + "num": str, # Номер поста (#1, #2...) + "text": str, # Текст поста + } + ], + "scraped_at": str, # TIMESTAMP в ISO-формате + } +""" + +import json +import logging +from datetime import datetime, timezone +from pathlib import Path + +log = logging.getLogger(__name__) + + +class TopicWriter: + """Потокобезопасная запись тем в JSONL-файлы с ротацией. + + Каждый воркер пишет в свою папку (worker_N/). + При достижении TOPICS_PER_FILE — создаётся новый part_*.jsonl. + + Атрибуты: + _worker_dir: Путь к папке воркера. + _topics_file: Счётчик записанных тем в текущий файл. + _file_index: Индекс текущего part_*.jsonl. + _handle: Открытый файловый дескриптор. + """ + + def __init__(self, worker_dir: Path, topics_per_file: int = 100): + """Инициализация. + + Args: + worker_dir: Папка воркера (worker_N/). + topics_per_file: Сколько тем писать в один файл перед ротацией. + """ + self._worker_dir = worker_dir + self._topics_per_file = topics_per_file + + # Счётчики + self._topics_file = 0 + self._file_index = 0 + + # Открываем первый файл + self._handle = self._open_next() + + # ── Открытие/закрытие файла ───────────────────────────────────────── + + def _open_next(self): + """Закрыть текущий файл (если есть) и открыть следующий part_N.jsonl.""" + # Закрываем предыдущий + if hasattr(self, "_handle") and self._handle and not self._handle.closed: + self._handle.close() + + # Новый путь + path = self._worker_dir / f"part_{self._file_index:04d}.jsonl" + + # Сбрасываем счётчик тем в файле + self._topics_file = 0 + + log.info(" 💾 %s", path.name) + fh = open(path, "w", encoding="utf-8") + self._file_index += 1 + return fh + + def close(self): + """Закрыть текущий файл.""" + if self._handle and not self._handle.closed: + self._handle.close() + + # ── Запись ────────────────────────────────────────────────────────── + + def write(self, + section_name: str, + section_slug: str, + topic: dict, + posts: list, + tags: list | None = None): + """Записать одну тему в JSONL. + + Если текущий файл заполнен — автоматически создаёт новый. + + Args: + section_name: Название раздела (напр. "Бензиновые двигатели"). + section_slug: Короткий ID раздела (напр. "benzinovye-dvigateli"). + topic: Словарь темы: {id, title, url}. + posts: Список постов: [{author, date, num, text}]. + tags: Список тегов (опционально). + """ + # ── Ротация ───────────────────────────────────────────────────── + if self._topics_file >= self._topics_per_file: + self._open_next() + + # ── Формируем запись ──────────────────────────────────────────── + record = { + "section": section_name, + "section_id": section_slug, + "topic_id": topic["id"], + "topic_title": topic["title"], + "topic_url": topic.get("url", ""), + "tags": tags or [], + "total_posts": len(posts), + "posts": posts, + "scraped_at": datetime.now(timezone.utc).isoformat(), + } + + # ── Пишем в файл ──────────────────────────────────────────────── + line = json.dumps(record, ensure_ascii=False) + self._handle.write(line + "\n") + self._handle.flush() + self._topics_file += 1 + + # ── Контекстный менеджер ──────────────────────────────────────────── + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() diff --git a/scraper/core/runner.py b/scraper/core/runner.py new file mode 100644 index 0000000..7813f9a --- /dev/null +++ b/scraper/core/runner.py @@ -0,0 +1,322 @@ +"""Главный цикл: обход разделов через адаптер. + +Единая точка запуска для любого движка форума. +Не содержит кода парсинга — использует адаптер из forums/. +""" + +import sys +import logging +import shutil +import json +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +from . import fetch as _fetch +from .throttle import Throttle, BanDetector +from .output import TopicWriter +from .state import load, save, merge_workers + +log = logging.getLogger(__name__) + +# ─── Глобальный throttle (виден fetch.py) ─────────────────────────────── +_throttle: Throttle | None = None + + +# ═══════════════════════════════════════════════════════════════════════════ +# Функция одного воркера (запускается в ThreadPoolExecutor) +# ═══════════════════════════════════════════════════════════════════════════ + +def _worker(section_url: str, + section_slug: str, + section_name: str, + pages: range, + worker_id: int, + adapter) -> tuple: + """Обойти свой диапазон страниц и сохранить темы в worker_N/. + + Args: + section_url: URL раздела (первая страница). + section_slug: Короткое имя для папки (напр. "diagnostika"). + section_name: Человеческое название раздела. + pages: Диапазон страниц для этого воркера. + worker_id: Номер воркера (0, 1, 2...). + adapter: Экземпляр адаптера (forums/*/adapter.py). + + Returns: + (worker_id, topic_count) — для汇总 в главном потоке. + """ + session = _fetch.make_session() + + # ── Состояние воркера ─────────────────────────────────────────────── + worker_state = { + "topics_done": {}, + "pages_done": [], + "file_index": 0, + "topic_count": 0, + } + + consecutive_404 = 0 + writer = None + + # ── Обход страниц раздела ─────────────────────────────────────────── + for page_num in pages: + # Формируем URL страницы через адаптер + url = adapter.page_url(section_url, page_num) + log.info("[W%d] 📄 стр.%d: %s", worker_id, page_num, url[:100]) + + # Загружаем + soup = _fetch.get(url, session) + if soup is None: + # 404 подряд — вероятно, страницы кончились + consecutive_404 += 1 + if consecutive_404 >= 3: + log.info("[W%d] ⏹️ 3 пустых страницы подряд — завершаем", worker_id) + break + continue + consecutive_404 = 0 + + # Парсим список тем через адаптер + topics = adapter.parse_topics(soup) + log.info("[W%d] Тем на странице: %d", worker_id, len(topics)) + + # ── Обход тем на странице ──────────────────────────────────────── + for idx, topic in enumerate(topics, 1): + topic_id = str(topic["id"]) + + # Пропускаем уже обработанные + if topic_id in worker_state["topics_done"]: + continue + + log.info( + "[W%d] [%d/%d] #%s: %s", + worker_id, idx, len(topics), topic_id, topic["title"][:80], + ) + + # Загружаем страницу темы (первую) + topic_url = adapter.topic_url(section_url, topic) + topic["url"] = topic_url + topic_soup = _fetch.get(topic_url, session) + if topic_soup is None: + # Не загрузилась — помечаем как сделанную и идём дальше + worker_state["topics_done"][topic_id] = topic["title"] + continue + + # Определяем количество страниц в теме + topic_pages = adapter.count_pages(topic_soup) + log.info( + "[W%d] Страниц в теме: %d", + worker_id, topic_pages, + ) + + # Парсим посты с первой страницы + all_posts = adapter.parse_posts(topic_soup) + + # Догружаем остальные страницы темы + for tp in range(2, topic_pages + 1): + tp_url = adapter.topic_page_url(topic_url, tp) + tp_soup = _fetch.get(tp_url, session) + if tp_soup: + all_posts.extend(adapter.parse_posts(tp_soup)) + + # Парсим теги (если адаптер поддерживает) + tags = adapter.parse_tags(topic_soup) if hasattr(adapter, "parse_tags") else [] + + log.info( + "[W%d] %d постов, теги: %s", + worker_id, len(all_posts), tags, + ) + + # ── Сохраняем ──────────────────────────────────────────────── + # Лениво инициализируем writer при первой теме + if writer is None: + worker_dir = Path(adapter.data_dir) / section_slug / f"worker_{worker_id}" + worker_dir.mkdir(parents=True, exist_ok=True) + writer = TopicWriter(worker_dir, adapter.topics_per_file) + + writer.write(section_name, section_slug, topic, all_posts, tags) + worker_state["topics_done"][topic_id] = topic["title"] + worker_state["topic_count"] += 1 + + # Помечаем страницу как пройденную + worker_state["pages_done"].append(page_num) + + # ── Сохраняем состояние воркера ───────────────────────────────────── + if writer is not None: + writer.close() + + worker_dir = Path(adapter.data_dir) / section_slug / f"worker_{worker_id}" + worker_dir.mkdir(parents=True, exist_ok=True) + state_file = worker_dir / "state.json" + state_file.write_text( + json.dumps(worker_state, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + session.close() + return worker_id, worker_state["topic_count"] + + +# ═══════════════════════════════════════════════════════════════════════════ +# Функция мержа воркеров +# ═══════════════════════════════════════════════════════════════════════════ + +def _merge_workers(adapter, section_slug: str): + """Собрать part_*.jsonl из worker_N/ в корень раздела. + + Переименовывает файлы с единой нумерацией, удаляет пустые + worker-папки, мержит state.json. + + Args: + adapter: Экземпляр адаптера. + section_slug: Короткое имя раздела. + """ + root = Path(adapter.data_dir) / section_slug + + # ── Собираем все part_*.jsonl из worker_N/ ────────────────────────── + all_parts = [] + for worker_dir in sorted(root.glob("worker_*")): + if worker_dir.is_dir(): + for part_file in sorted(worker_dir.glob("part_*.jsonl")): + all_parts.append(part_file) + + if not all_parts: + log.warning("⚠️ Нет файлов для мержа в %s", root) + return + + log.info( + "🔗 Мерж %d файлов из %d воркеров → %s", + len(all_parts), len(list(root.glob("worker_*"))), root, + ) + + # ── Переименовываем с единой нумерацией ───────────────────────────── + for new_index, src in enumerate(all_parts): + dst = root / f"part_{new_index:04d}.jsonl" + shutil.move(str(src), str(dst)) + + # ── Удаляем пустые папки воркеров ─────────────────────────────────── + for worker_dir in root.glob("worker_*"): + if worker_dir.is_dir(): + try: + worker_dir.rmdir() # удаляет только пустую папку + except OSError: + pass # если не пустая — оставляем + + # ── Мержим state.json ─────────────────────────────────────────────── + merge_workers(adapter.data_dir, section_slug) + + log.info("✅ Мерж завершён: %d файлов → %s", len(all_parts), root) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Публичный запуск +# ═══════════════════════════════════════════════════════════════════════════ + +def run(adapter, + section_url: str, + section_slug: str | None = None, + workers: int = 4, + no_delay: bool = False, + ban_test: bool = True): + """Главный цикл: многопоточный обход раздела через адаптер. + + Порядок работы: + 1. Проверка бана (BanDetector). + 2. Загрузка первой страницы для определения названия и пагинации. + 3. Разбиение страниц на диапазоны по числу воркеров. + 4. Запуск ThreadPoolExecutor. + 5. Мерж результатов. + + Args: + adapter: Экземпляр адаптера (forums/*/adapter.py). + section_url: URL раздела (первая страница). + section_slug: Короткое имя (если None — берётся из URL). + workers: Количество потоков. + no_delay: True — отключить координацию задержек. + ban_test: True — проверить бан перед стартом. + """ + # ── Определяем slug из URL ────────────────────────────────────────── + if section_slug is None: + section_slug = section_url.rstrip("/").split("/")[-1] + + # ── Настройка throttle ────────────────────────────────────────────── + throttle_enabled = not no_delay + + # Если workers=1, координация не нужна — один поток не конфликтует + if workers <= 1: + throttle_enabled = False + + # Проверка бана + if ban_test and throttle_enabled and workers > 1: + if BanDetector.test(section_url, adapter.ban_test_count): + throttle_enabled = True + else: + throttle_enabled = False + + # Устанавливаем глобальный throttle + global _throttle + _throttle = Throttle(adapter.delay, enabled=throttle_enabled) + _fetch.throttle = _throttle + + log.info( + "⚙️ Потоков: %d, координация: %s", + workers, "вкл" if throttle_enabled else "выкл", + ) + + # ── Определяем название раздела и число страниц ───────────────────── + session = _fetch.make_session() + soup = _fetch.get(section_url, session) + if soup is None: + log.error("❌ Сайт недоступен: %s", section_url) + sys.exit(1) + + # Название раздела (из h1 или из URL) + section_name = adapter.parse_section_name(soup) or section_slug + + # Количество страниц в разделе + total_pages = adapter.count_pages(soup) + log.info("📋 %s | страниц: %d", section_name, total_pages) + session.close() + + # ── Разбиваем страницы на диапазоны для воркеров ──────────────────── + if workers > total_pages: + workers = max(1, total_pages) + + base_size = total_pages // workers + remainder = total_pages % workers + + ranges = [] + start = 1 + for w in range(workers): + size = base_size + (1 if w < remainder else 0) + ranges.append(range(start, start + size)) + start += size + + log.info("📦 Диапазоны: %s", [f"{r.start}-{r[-1]}" for r in ranges]) + + # ── Запуск воркеров ───────────────────────────────────────────────── + total_topics = 0 + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = [ + pool.submit(_worker, section_url, section_slug, section_name, + rng, wid, adapter) + for wid, rng in enumerate(ranges) + ] + for future in as_completed(futures): + wid, cnt = future.result() + total_topics += cnt + log.info("[W%d] ✅ завершён: %d тем", wid, cnt) + + # ── Мерж ──────────────────────────────────────────────────────────── + _merge_workers(adapter, section_slug) + + # ── Итог ──────────────────────────────────────────────────────────── + data_dir = Path(adapter.data_dir) + part_files = sorted((data_dir / section_slug).glob("part_*.jsonl")) + total_size_mb = sum(f.stat().st_size for f in part_files) / (1024 * 1024) + + log.info("=" * 60) + log.info( + "✅ '%s' — %d тем в %d частях (%.1f MB)", + section_name, total_topics, len(part_files), total_size_mb, + ) + log.info("=" * 60) diff --git a/scraper/core/state.py b/scraper/core/state.py new file mode 100644 index 0000000..559ca36 --- /dev/null +++ b/scraper/core/state.py @@ -0,0 +1,114 @@ +"""Управление состоянием: state.json в папке раздела. + +Позволяет: + - Сохранять прогресс (какие темы обработаны, какие страницы пройдены). + - Продолжать парсинг с места остановки (той же командой). + - Мержить состояния воркеров в единый state.json (после завершения). + +Файл state.json (раздел): + { + "topics_done": {"123": "Название темы", "456": "..."}, + "pages_done": [1, 2, 3], + "file_index": 5, + "topic_count": 432 + } + +Файл worker_N/state.json: + Аналогичная структура, но только для одного воркера. +""" + +import json +import logging +from pathlib import Path + +log = logging.getLogger(__name__) + + +def _state_file(data_dir: Path, section_slug: str) -> Path: + """Полный путь к state.json для раздела. + + Args: + data_dir: Корневая папка с данными (напр. ./vwts_data). + section_slug: Короткое имя раздела. + + Returns: + Path к state.json. + """ + section_dir = data_dir / section_slug + section_dir.mkdir(parents=True, exist_ok=True) + return section_dir / "state.json" + + +def load(data_dir: Path, section_slug: str) -> dict: + """Загрузить state.json раздела. + + Args: + data_dir: Корневая папка с данными. + section_slug: Короткое имя раздела. + + Returns: + Словарь состояния. Если файла нет — пустой state. + """ + path = _state_file(data_dir, section_slug) + + if path.exists(): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + log.warning("⚠️ Ошибка чтения %s: %s", path, exc) + + # Пустой state по умолчанию + return {"topics_done": {}, "pages_done": [], "file_index": 0, "topic_count": 0} + + +def save(data_dir: Path, section_slug: str, state: dict): + """Сохранить state.json раздела. + + Args: + data_dir: Корневая папка с данными. + section_slug: Короткое имя раздела. + state: Словарь состояния для сохранения. + """ + path = _state_file(data_dir, section_slug) + path.write_text( + json.dumps(state, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def merge_workers(data_dir: Path, section_slug: str): + """Собрать состояния всех воркеров в единый state.json раздела. + + Проходит по worker_*/state.json, сливает topics_done, pages_done, + суммирует topic_count. + + Args: + data_dir: Корневая папка с данными. + section_slug: Короткое имя раздела. + """ + root = data_dir / section_slug + + # ── Собираем все worker_*/state.json ──────────────────────────────── + merged = {"topics_done": {}, "pages_done": [], "file_index": 0, "topic_count": 0} + + for worker_state_path in sorted(root.glob("worker_*/state.json")): + try: + worker_state = json.loads( + worker_state_path.read_text(encoding="utf-8"), + ) + merged["topics_done"].update(worker_state.get("topics_done", {})) + merged["pages_done"].extend(worker_state.get("pages_done", [])) + merged["topic_count"] += worker_state.get("topic_count", 0) + except (json.JSONDecodeError, OSError) as exc: + log.warning("⚠️ Ошибка чтения %s: %s", worker_state_path, exc) + + # Чистим дубликаты и сортируем + merged["pages_done"] = sorted(set(merged["pages_done"])) + + # Определяем следующий file_index по количеству part_*.jsonl + existing_parts = list(root.glob("part_*.jsonl")) + merged["file_index"] = max(0, len(existing_parts) - 1) + + # ── Пишем ─────────────────────────────────────────────────────────── + save(data_dir, section_slug, merged) + log.info("🔗 Мерж состояний: %d воркеров → state.json", len(list(root.glob("worker_*")))) diff --git a/scraper/core/throttle.py b/scraper/core/throttle.py new file mode 100644 index 0000000..efb9643 --- /dev/null +++ b/scraper/core/throttle.py @@ -0,0 +1,147 @@ +"""Координация задержек между потоками + автоопределение бана. + +Throttle: + Thread-safe координатор: максимум 1 запрос в DELAY секунд. + При отключённой координации — просто time.sleep(DELAY). + +BanDetector: + Проверяет, банит ли сервер за быстрые запросы. + Шлёт COUNT запросов подряд без задержки, ищет 403/429. +""" + +import time +import threading +import logging + +import requests + +log = logging.getLogger(__name__) + + +class Throttle: + """Координатор задержек между потоками. + + Позволяет запускать N потоков, но реальные HTTP-запросы + идут не чаще 1 в delay секунд. Потоки ждут в очереди + через threading.Lock. + + Атрибуты: + _delay: Минимальный интервал между запросами (сек). + _enabled: True — координация включена, False — только sleep. + _lock: Мьютекс для потокобезопасности. + _last: Монотонное время последнего запроса. + """ + + def __init__(self, delay: float = 1.5, enabled: bool = True): + """Инициализация. + + Args: + delay: Минимальный интервал между запросами (сек). + enabled: Если False — вместо координации просто sleep(delay). + """ + self._delay = delay + self._enabled = enabled + self._lock = threading.Lock() + self._last = 0.0 # time.monotonic() последнего запроса + + # ── Свойства ──────────────────────────────────────────────────────── + + @property + def delay(self) -> float: + """Текущая задержка.""" + return self._delay + + @property + def enabled(self) -> bool: + """Включена ли координация.""" + return self._enabled + + # ── Основной метод ────────────────────────────────────────────────── + + def wait(self): + """Подождать своей очереди на запрос. + + Если координация ВЫКЛЮЧЕНА: + Просто спим delay секунд. + Если координация ВКЛЮЧЕНА: + Захватываем мьютекс, считаем сколько прошло с _last, + если меньше delay — спим разницу, обновляем _last. + """ + if not self._enabled: + # Без координации: каждый поток спит свою задержку + time.sleep(self._delay) + return + + with self._lock: + elapsed = time.monotonic() - self._last + if elapsed < self._delay: + time.sleep(self._delay - elapsed) + self._last = time.monotonic() + + # ── Управление ────────────────────────────────────────────────────── + + def disable(self): + """Отключить координацию.""" + self._enabled = False + + def enable(self): + """Включить координацию.""" + self._enabled = True + + +class BanDetector: + """Определяет, банит ли сервер за быстрые запросы. + + Статический метод test() шлёт count запросов подряд + (без задержек) и смотрит ответы. Если хоть один 403/429 — + сервер банит. + """ + + @staticmethod + def test(base_url: str, count: int = 5) -> bool: + """Проверить бан быстрыми запросами. + + Args: + base_url: Любой URL того же хоста (главная раздела). + count: Сколько запросов сделать. + + Returns: + True — сервер банит (есть 403/429), + False — бан не обнаружен. + """ + log.info("🔍 Проверка бана: %d запросов подряд...", count) + + session = requests.Session() + session.headers.update({ + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36" + ), + }) + + banned = False + + for i in range(1, count + 1): + try: + response = session.get(base_url, timeout=(10, 30)) + log.info(" [%d/%d] HTTP %d", i, count, response.status_code) + + # Сервер явно банит + if response.status_code in (403, 429): + banned = True + break + + except Exception as exc: + # Сетевая ошибка — вероятно, бан на уровне соединения + log.warning(" [%d/%d] ошибка: %s", i, count, exc) + banned = True + break + + session.close() + + if banned: + log.warning("⚠️ Сервер банит быстрые запросы — включаю координацию") + else: + log.info("✅ Бан не обнаружен — без координации") + + return banned diff --git a/scraper/forums/__init__.py b/scraper/forums/__init__.py new file mode 100644 index 0000000..0bb6f81 --- /dev/null +++ b/scraper/forums/__init__.py @@ -0,0 +1,3 @@ +# scraper.forums — адаптеры под разные движки форумов +# Каждый пакет реализует BaseAdapter. +# См. base.py, xenforo/, phpbb/ diff --git a/scraper/forums/base.py b/scraper/forums/base.py new file mode 100644 index 0000000..2a735ca --- /dev/null +++ b/scraper/forums/base.py @@ -0,0 +1,179 @@ +"""Абстрактный базовый класс адаптера форума. + +Каждый движок (XenForo, phpBB, ...) реализует этот интерфейс. +core/runner.py вызывает эти методы, ничего не зная о конкретном движке. + +Методы для обязательной реализации: + count_pages(soup) -> int + parse_topics(soup) -> list[dict] + parse_posts(soup) -> list[dict] + parse_section_name(soup) -> str | None + page_url(section_url, page_num) -> str + topic_url(section_url, topic) -> str + topic_page_url(topic_url, page_num) -> str + +Опционально: + parse_tags(soup) -> list[str] + +Атрибуты (должны быть определены в конструкторе адаптера): + data_dir: Path — куда сохранять данные. + delay: float — задержка между запросами (сек). + ban_test_count: int — сколько запросов для проверки бана. + topics_per_file: int — сколько тем в один JSONL-файл. +""" + +from abc import ABC, abstractmethod + + +class BaseAdapter(ABC): + """Интерфейс адаптера форума. + + Все методы, кроме data_dir, delay и т.д., получают + BeautifulSoup-объект и возвращают структурированные данные. + """ + + # ─── Атрибуты конфигурации ────────────────────────────────────────── + + @property + @abstractmethod + def data_dir(self): + """Path: корневая директория для сохранения данных.""" + ... + + @property + @abstractmethod + def delay(self) -> float: + """float: задержка между запросами в секундах.""" + ... + + @property + @abstractmethod + def ban_test_count(self) -> int: + """int: количество быстрых запросов для проверки бана.""" + ... + + @property + @abstractmethod + def topics_per_file(self) -> int: + """int: сколько тем писать в один JSONL-файл.""" + ... + + # ─── Определение количества страниц ───────────────────────────────── + + @abstractmethod + def count_pages(self, soup) -> int: + """Сколько страниц в разделе (или теме). + + Args: + soup: BeautifulSoup первой страницы раздела/темы. + + Returns: + int: количество страниц (минимум 1). + """ + ... + + # ─── Парсинг списка тем ───────────────────────────────────────────── + + @abstractmethod + def parse_topics(self, soup) -> list[dict]: + """Распарсить список тем со страницы раздела. + + Args: + soup: BeautifulSoup страницы раздела. + + Returns: + list[dict]: каждая тема — словарь с ключами: + - id: int — ID темы на форуме. + - title: str — заголовок темы. + - url: str | None — URL темы (если известен). + """ + ... + + # ─── Парсинг постов ───────────────────────────────────────────────── + + @abstractmethod + def parse_posts(self, soup) -> list[dict]: + """Распарсить посты со страницы темы. + + Args: + soup: BeautifulSoup страницы темы. + + Returns: + list[dict]: каждый пост — словарь с ключами: + - author: str — имя автора. + - date: str — дата/время поста. + - num: str — номер поста (#1, #2...). + - text: str — текст поста. + """ + ... + + # ─── Парсинг названия раздела ─────────────────────────────────────── + + @abstractmethod + def parse_section_name(self, soup) -> str | None: + """Извлечь название раздела из HTML. + + Args: + soup: BeautifulSoup страницы раздела. + + Returns: + str | None: название (напр. "Бензиновые двигатели") + или None, если не удалось определить. + """ + ... + + # ─── Формирование URL ─────────────────────────────────────────────── + + @abstractmethod + def page_url(self, section_url: str, page_num: int) -> str: + """Сформировать URL страницы раздела. + + Args: + section_url: URL раздела (первая страница). + page_num: Номер страницы (1-based). + + Returns: + str: полный URL страницы. + """ + ... + + @abstractmethod + def topic_url(self, section_url: str, topic: dict) -> str: + """Сформировать полный URL темы. + + Args: + section_url: URL раздела. + topic: Словарь темы из parse_topics(). + + Returns: + str: полный URL темы. + """ + ... + + @abstractmethod + def topic_page_url(self, topic_url: str, page_num: int) -> str: + """Сформировать URL страницы внутри темы. + + Args: + topic_url: Полный URL темы. + page_num: Номер страницы (1-based, начиная с 2). + + Returns: + str: полный URL страницы темы. + """ + ... + + # ─── Опционально: теги ────────────────────────────────────────────── + + def parse_tags(self, soup) -> list[str]: + """Распарсить теги темы (если движок поддерживает). + + По умолчанию — пустой список. + + Args: + soup: BeautifulSoup страницы темы. + + Returns: + list[str]: список тегов. + """ + return [] diff --git a/scraper/forums/phpbb/__init__.py b/scraper/forums/phpbb/__init__.py new file mode 100644 index 0000000..ec99456 --- /dev/null +++ b/scraper/forums/phpbb/__init__.py @@ -0,0 +1 @@ +# scraper.forums.phpbb — адаптер для форумов на phpBB (нива-лада.рф и др.) diff --git a/scraper/forums/phpbb/adapter.py b/scraper/forums/phpbb/adapter.py new file mode 100644 index 0000000..a8e5479 --- /dev/null +++ b/scraper/forums/phpbb/adapter.py @@ -0,0 +1,193 @@ +"""Адаптер для форумов на phpBB (нива-лада.рф и др.). + +Настройки задаются в конструкторе: + data_dir: куда сохранять. + forum_base: базовый URL форума (напр. https://нива-лада.рф/n/). + topics_per_page: сколько тем на странице раздела. + posts_per_page: сколько постов на странице темы. + delay: задержка между запросами. + ban_test: сколько запросов для проверки бана. + topics_file: сколько тем в один JSONL. +""" + +import re +from pathlib import Path +from urllib.parse import urljoin + +from ..base import BaseAdapter +from . import paginate, parse + + +class PhpBBAdapter(BaseAdapter): + """Адаптер для форума phpBB. + + Параметризуется под любой phpBB-форум: + достаточно указать forum_base и per_page-значения. + """ + + def __init__(self, + data_dir: str | Path = "lada_data", + forum_base: str = "https://нива-лада.рф/n/", + topics_per_page: int = 25, + posts_per_page: int = 10): + """Инициализация адаптера phpBB. + + Args: + data_dir: Папка для данных. + forum_base: Базовый URL форума (с / на конце). + topics_per_page: Тем на одной странице раздела. + posts_per_page: Постов на одной странице темы. + """ + self._data_dir = Path(data_dir) + self._forum_base = forum_base.rstrip("/") + "/" + self._topics_per_page = topics_per_page + self._posts_per_page = posts_per_page + + # ─── Атрибуты конфигурации ────────────────────────────────────────── + + @property + def data_dir(self) -> Path: + return self._data_dir + + @property + def delay(self) -> float: + return 1.5 + + @property + def ban_test_count(self) -> int: + return 5 + + @property + def topics_per_file(self) -> int: + return 100 + + @property + def topics_per_page(self) -> int: + return self._topics_per_page + + @property + def posts_per_page(self) -> int: + return self._posts_per_page + + # ─── Вспомогательные ──────────────────────────────────────────────── + + def _abs_url(self, path_or_url: str) -> str: + """Преобразовать относительный URL в абсолютный. + + Если path_or_url уже абсолютный — возвращаем как есть. + Иначе — присоединяем к forum_base. + + Args: + path_or_url: URL или относительный путь. + + Returns: + str: абсолютный URL. + """ + if path_or_url.startswith("http"): + # Убираем sid из URL + clean = re.sub(r"[?&]sid=[^&]+", "", path_or_url) + # Нормализуем http → https + clean = clean.replace("http://", "https://") + return clean + + # Относительный — присоединяем к базе + clean = re.sub(r"[?&]sid=[^&]+", "", path_or_url) + return urljoin(self._forum_base, clean) + + # ─── Пагинация раздела ────────────────────────────────────────────── + + def count_pages(self, soup) -> int: + """Количество страниц в разделе. + + Args: + soup: BeautifulSoup страницы раздела. + + Returns: + int: количество страниц. + """ + return paginate.count_pages(soup, self._topics_per_page) + + def page_url(self, section_url: str, page_num: int) -> str: + """URL страницы раздела. + + section_url — это viewforum.php?f=ID (первая страница). + + Args: + section_url: URL раздела (первая страница). + page_num: Номер страницы. + + Returns: + str: URL страницы с ?start= (если page>1). + """ + return paginate.page_url(section_url, page_num, self._topics_per_page) + + # ─── Пагинация темы ───────────────────────────────────────────────── + + def topic_url(self, section_url: str, topic: dict) -> str: + """Полный URL темы. + + Если у темы есть url — нормализуем его. + Иначе формируем из ID. + + Args: + section_url: URL раздела. + topic: Словарь темы. + + Returns: + str: полный URL темы. + """ + if topic.get("url"): + return self._abs_url(topic["url"]) + + # Вытаскиваем f=ID из section_url + f_match = re.search(r"[?&]f=(\d+)", section_url) + f_id = f_match.group(1) if f_match else "0" + + return self._abs_url(f"./viewtopic.php?f={f_id}&t={topic['id']}") + + def topic_page_url(self, topic_url: str, page_num: int) -> str: + """URL страницы темы. + + Args: + topic_url: Полный URL темы. + page_num: Номер страницы. + + Returns: + str: URL страницы с ?start=. + """ + return paginate.topic_page_url(topic_url, page_num, self._posts_per_page) + + # ─── Парсинг ──────────────────────────────────────────────────────── + + def parse_section_name(self, soup) -> str | None: + """Название раздела. + + Args: + soup: BeautifulSoup страницы раздела. + + Returns: + str | None: название. + """ + return parse.parse_section_name(soup) + + def parse_topics(self, soup) -> list[dict]: + """Список тем со страницы раздела. + + Args: + soup: BeautifulSoup. + + Returns: + list[dict]: [{id, title, url}]. + """ + return parse.parse_topics(soup) + + def parse_posts(self, soup) -> list[dict]: + """Посты со страницы темы. + + Args: + soup: BeautifulSoup страницы темы. + + Returns: + list[dict]: [{author, date, num, text}]. + """ + return parse.parse_posts(soup) diff --git a/scraper/forums/phpbb/paginate.py b/scraper/forums/phpbb/paginate.py new file mode 100644 index 0000000..2e2d656 --- /dev/null +++ b/scraper/forums/phpbb/paginate.py @@ -0,0 +1,110 @@ +"""Пагинация phpBB. + +phpBB использует параметр ?start=N для пагинации, где: + N = (page - 1) * items_per_page + +Страницы раздела: + viewforum.php?f=22&start=25 ← страница 2 + viewforum.php?f=22&start=50 ← страница 3 + ... + +Страницы темы: + viewtopic.php?f=22&t=123&start=10 ← страница 2 + viewtopic.php?f=22&t=123&start=20 ← страница 3 + ... + +HTML-структура пагинации: + +""" + +import re +import logging +from bs4 import BeautifulSoup + +log = logging.getLogger(__name__) + + +def count_pages(soup: BeautifulSoup, per_page: int) -> int: + """Определить количество страниц в пагинации phpBB. + + Алгоритм: + 1. Ищем ul.pagination (или ul.page-nav). + 2. Собираем все start=N из href. + 3. Находим max_start. + 4. Вычисляем: pages = max_start / per_page + 1. + + Args: + soup: BeautifulSoup страницы раздела или темы. + per_page: Количество элементов на странице (темы=25, посты=10). + + Returns: + int: количество страниц (минимум 1). + """ + # Ищем контейнер пагинации (два варианта класса) + nav = soup.find("ul", class_="pagination") + if nav is None: + nav = soup.find("ul", class_="page-nav") + if nav is None: + return 1 + + # Собираем все start=N из ссылок + starts = set() + for link in nav.select("a"): + href = link.get("href", "") + match = re.search(r"[?&]start=(\d+)", href) + if match: + starts.add(int(match.group(1))) + + if not starts: + return 1 + + max_start = max(starts) + pages = (max_start // per_page) + 1 + return pages + + +def page_url(base_url: str, page_num: int, per_page: int) -> str: + """Сформировать URL страницы раздела phpBB. + + Формат: + page=1 → base_url (без ?start=) + page>1 → base_url + &start=N + + Args: + base_url: URL страницы без параметра start (напр. viewforum.php?f=22). + page_num: Номер страницы (1-based). + per_page: Количество элементов на страницу. + + Returns: + str: URL страницы с ?start= (если page>1). + """ + if page_num <= 1: + return base_url + + start = (page_num - 1) * per_page + separator = "&" if "?" in base_url else "?" + return f"{base_url}{separator}start={start}" + + +def topic_page_url(topic_url: str, page_num: int, per_page: int) -> str: + """Сформировать URL страницы темы phpBB. + + Args: + topic_url: Полный URL темы. + page_num: Номер страницы (1-based, начиная с 2). + per_page: Количество постов на страницу. + + Returns: + str: URL страницы темы с ?start=. + """ + if page_num <= 1: + return topic_url + + start = (page_num - 1) * per_page + separator = "&" if "?" in topic_url else "?" + return f"{topic_url}{separator}start={start}" diff --git a/scraper/forums/phpbb/parse.py b/scraper/forums/phpbb/parse.py new file mode 100644 index 0000000..5241a6e --- /dev/null +++ b/scraper/forums/phpbb/parse.py @@ -0,0 +1,142 @@ +"""Парсинг HTML форума phpBB (subsilver2, нива-лада.рф). + +Структуры: + +Список тем в разделе (subsilver2): + Заголовок + +Пост в теме (табличная вёрстка subsilver2): + ← строка: автор + дата + Автор + +
+ Добавлено: 12 авг 2025, 20:00 +
+ + + ← строка: аватар + текст + ... +
текст поста
+ +""" + +import re +import logging +from bs4 import BeautifulSoup + +log = logging.getLogger(__name__) + + +def parse_topics(soup: BeautifulSoup) -> list[dict]: + """Распарсить список тем со страницы раздела phpBB. + + Ищем все a.topictitle → из href вытаскиваем t=ID. + + Args: + soup: BeautifulSoup страницы раздела. + + Returns: + list[dict]: [{id, title, url}]. + url может быть None (будет сформирован адаптером). + """ + items = [] + + for link in soup.select("a.topictitle"): + href = link.get("href", "") + + # Из href вытаскиваем t=ID + match = re.search(r"[?&]t=(\d+)", href) + if match is None: + continue + + topic_id = int(match.group(1)) + title = link.text.strip() + + items.append({ + "id": topic_id, + "title": title, + "url": None, # адаптер сформирует сам + }) + + return items + + +def parse_posts(soup: BeautifulSoup) -> list[dict]: + """Распарсить посты со страницы темы phpBB (subsilver2). + + Каждый пост — это 3 в таблице. Ищем b.postauthor + как маркер начала поста, затем: + - Тот же : дата из "Добавлено:" + - Следующий : текст из div.postbody + + Args: + soup: BeautifulSoup страницы темы. + + Returns: + list[dict]: [{author, date, num, text}, ...]. + """ + posts = [] + post_num = 0 + + for author_tag in soup.select("b.postauthor"): + post_num += 1 + author = author_tag.text.strip() + + # ── Дата: ищем "Добавлено:" в том же ─────────────────────── + date = "" + header_row = author_tag.find_parent("tr") + if header_row is not None: + date_cell = header_row.select_one("td[width='100%']") + if date_cell is not None: + date_text = date_cell.get_text(" ", strip=True) + date_match = re.search(r"Добавлено:\s*(.+)", date_text) + if date_match: + date = date_match.group(1).strip() + + # ── Текст: ищем div.postbody в следующем ─────────────────── + text = "" + if header_row is not None: + body_row = header_row.find_next_sibling("tr") + if body_row is not None: + body_div = body_row.select_one("div.postbody") + if body_div is not None: + text = body_div.get_text("\n", strip=True) + + posts.append({ + "author": author, + "date": date, + "num": f"#{post_num}", + "text": text, + }) + + return posts + + +def parse_section_name(soup: BeautifulSoup) -> str | None: + """Извлечь название раздела из заголовка phpBB. + + phpBB обычно показывает название в h2 или в breadcrumbs. + + Args: + soup: BeautifulSoup страницы раздела. + + Returns: + str | None: название раздела. + """ + # Пробуем h2 (основной заголовок страницы) + h2 = soup.select_one("h2") + if h2 is not None: + text = h2.text.strip() + if text: + return text + + # Пробуем title страницы + title_tag = soup.select_one("title") + if title_tag is not None: + text = title_tag.text.strip() + # Чистим от названия сайта + text = re.sub(r"\s*–\s*.*$", "", text).strip() + if text: + return text + + return None diff --git a/scraper/forums/xenforo/__init__.py b/scraper/forums/xenforo/__init__.py new file mode 100644 index 0000000..c7e672a --- /dev/null +++ b/scraper/forums/xenforo/__init__.py @@ -0,0 +1 @@ +# scraper.forums.xenforo — адаптер для форумов на XenForo (vwts.ru) diff --git a/scraper/forums/xenforo/adapter.py b/scraper/forums/xenforo/adapter.py new file mode 100644 index 0000000..bea18b9 --- /dev/null +++ b/scraper/forums/xenforo/adapter.py @@ -0,0 +1,146 @@ +"""Адаптер для форумов на XenForo (vwts.ru). + +Настройки: + data_dir: куда сохранять данные (по умолчанию ./vwts_data). + delay: 1.5 сек между запросами. + ban_test: 5 быстрых запросов для проверки бана. + topics_file: 100 тем на один JSONL-файл. +""" + +from pathlib import Path + +from ..base import BaseAdapter +from . import paginate, parse + + +class XenForoAdapter(BaseAdapter): + """Адаптер для форума XenForo (vwts.ru). + + Использует модули paginate.py и parse.py из этого пакета. + """ + + def __init__(self, data_dir: str | Path = "vwts_data"): + """Инициализация. + + Args: + data_dir: Путь к папке с данными (строка или Path). + """ + self._data_dir = Path(data_dir) + + # ─── Атрибуты конфигурации ────────────────────────────────────────── + + @property + def data_dir(self) -> Path: + return self._data_dir + + @property + def delay(self) -> float: + return 1.5 + + @property + def ban_test_count(self) -> int: + return 5 + + @property + def topics_per_file(self) -> int: + return 100 + + # ─── Пагинация ────────────────────────────────────────────────────── + + def count_pages(self, soup) -> int: + """Количество страниц в разделе/теме XenForo. + + Args: + soup: BeautifulSoup первой страницы. + + Returns: + int: количество страниц. + """ + return paginate.count_pages(soup) + + def page_url(self, section_url: str, page_num: int) -> str: + """URL страницы раздела XenForo. + + Args: + section_url: URL раздела (первая страница). + page_num: Номер страницы. + + Returns: + str: URL страницы. + """ + return paginate.page_url(section_url, page_num) + + def topic_url(self, section_url: str, topic: dict) -> str: + """Полный URL темы XenForo. + + Приоритет: topic['url'] (если есть), + иначе формируем из ID. + + Args: + section_url: URL раздела (не используется в XenForo). + topic: Словарь темы. + + Returns: + str: полный URL темы. + """ + if topic.get("url"): + return topic["url"] + return f"https://vwts.ru/forum/topic/{topic['id']}/" + + def topic_page_url(self, topic_url: str, page_num: int) -> str: + """URL страницы внутри темы XenForo. + + Args: + topic_url: Полный URL темы. + page_num: Номер страницы. + + Returns: + str: URL страницы темы. + """ + return paginate.topic_page_url(topic_url, page_num) + + # ─── Парсинг ──────────────────────────────────────────────────────── + + def parse_section_name(self, soup) -> str | None: + """Название раздела из h1. + + Args: + soup: BeautifulSoup страницы раздела. + + Returns: + str | None: название раздела. + """ + return parse.parse_section_name(soup) + + def parse_topics(self, soup) -> list[dict]: + """Список тем со страницы раздела. + + Args: + soup: BeautifulSoup страницы раздела. + + Returns: + list[dict]: [{id, title, url}]. + """ + return parse.parse_topics(soup) + + def parse_posts(self, soup) -> list[dict]: + """Список постов со страницы темы. + + Args: + soup: BeautifulSoup страницы темы. + + Returns: + list[dict]: [{author, date, num, text}]. + """ + return parse.parse_posts(soup) + + def parse_tags(self, soup) -> list[str]: + """Теги темы XenForo. + + Args: + soup: BeautifulSoup страницы темы. + + Returns: + list[str]: теги (может быть пусто). + """ + return parse.parse_tags(soup) diff --git a/scraper/forums/xenforo/paginate.py b/scraper/forums/xenforo/paginate.py new file mode 100644 index 0000000..0ce366f --- /dev/null +++ b/scraper/forums/xenforo/paginate.py @@ -0,0 +1,109 @@ +"""Пагинация XenForo (vwts.ru и другие форумы на XenForo). + +Страницы раздела: /forum/vag/benzinovye-dvigateli/page-2 +Страницы темы: /forum/topic/12345/page-2 + +HTML-структура пагинации: + + +Все номера страниц видны на любой странице раздела. +Кнопки