scraper: новая архитектура — core/ + forums/ (BaseAdapter, XenForo, phpBB)
- core/ — общий слой: fetch, throttle, output, state, runner - forums/base.py — абстрактный BaseAdapter - forums/xenforo/ — адаптер для XenForo (vwts.ru) - forums/phpbb/ — адаптер для phpBB (нива-лада.рф) - __main__.py — точка входа CLI - history/001-initial-structure.md — журнал изменений Также на ВМ (не в этом коммите): - lada_scraper/parse.py — пропуск sticky-тем при многопоточном парсинге
This commit is contained in:
@@ -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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# scraper.core — общий слой парсера
|
||||||
|
# Содержит модули, независимые от движка форума.
|
||||||
|
# См. fetch.py, throttle.py, output.py, state.py, runner.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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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_*"))))
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# scraper.forums — адаптеры под разные движки форумов
|
||||||
|
# Каждый пакет реализует BaseAdapter.
|
||||||
|
# См. base.py, xenforo/, phpbb/
|
||||||
@@ -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 []
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# scraper.forums.phpbb — адаптер для форумов на phpBB (нива-лада.рф и др.)
|
||||||
@@ -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)
|
||||||
@@ -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-структура пагинации:
|
||||||
|
<ul class="pagination">
|
||||||
|
<li><a href="./viewforum.php?f=22&start=25">2</a></li>
|
||||||
|
<li><a href="./viewforum.php?f=22&start=50">3</a></li>
|
||||||
|
...
|
||||||
|
<li><a href="./viewforum.php?f=22&start=1800">72</a></li>
|
||||||
|
</ul>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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}"
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Парсинг HTML форума phpBB (subsilver2, нива-лада.рф).
|
||||||
|
|
||||||
|
Структуры:
|
||||||
|
|
||||||
|
Список тем в разделе (subsilver2):
|
||||||
|
<a href="./viewtopic.php?f=22&t=123" class="topictitle">Заголовок</a>
|
||||||
|
|
||||||
|
Пост в теме (табличная вёрстка subsilver2):
|
||||||
|
<tr class="row1"> ← строка: автор + дата
|
||||||
|
<td><b class="postauthor">Автор</b></td>
|
||||||
|
<td width="100%">
|
||||||
|
<div style="float: right;">
|
||||||
|
<b>Добавлено:</b> 12 авг 2025, 20:00
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="row1"> ← строка: аватар + текст
|
||||||
|
<td class="profile">...</td>
|
||||||
|
<td><div class="postbody">текст поста</div></td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 <tr> в таблице. Ищем b.postauthor
|
||||||
|
как маркер начала поста, затем:
|
||||||
|
- Тот же <tr>: дата из "Добавлено:"
|
||||||
|
- Следующий <tr>: текст из 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()
|
||||||
|
|
||||||
|
# ── Дата: ищем "Добавлено:" в том же <tr> ───────────────────────
|
||||||
|
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 в следующем <tr> ───────────────────
|
||||||
|
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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# scraper.forums.xenforo — адаптер для форумов на XenForo (vwts.ru)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Пагинация XenForo (vwts.ru и другие форумы на XenForo).
|
||||||
|
|
||||||
|
Страницы раздела: /forum/vag/benzinovye-dvigateli/page-2
|
||||||
|
Страницы темы: /forum/topic/12345/page-2
|
||||||
|
|
||||||
|
HTML-структура пагинации:
|
||||||
|
<div class="pageNav">
|
||||||
|
<a>1</a> ← текущая (без href)
|
||||||
|
<a href="page-2">2</a> ← соседняя
|
||||||
|
<a>…</a> ← эллипсис (пропускаем)
|
||||||
|
<a href="page-218">218</a> ← последняя
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Все номера страниц видны на любой странице раздела.
|
||||||
|
Кнопки <button> не трогаем — ищем только <a>.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def count_pages(soup: BeautifulSoup) -> int:
|
||||||
|
"""Определить количество страниц в пагинации XenForo.
|
||||||
|
|
||||||
|
Ищем:
|
||||||
|
<div class="pageNav"> → все <a>
|
||||||
|
Из href вытаскиваем /page-N
|
||||||
|
Из текста — числа
|
||||||
|
Возвращаем максимум
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: BeautifulSoup страницы раздела или темы.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: количество страниц (минимум 1).
|
||||||
|
"""
|
||||||
|
nav = soup.select_one("div.pageNav")
|
||||||
|
|
||||||
|
# Нет пагинации — всего 1 страница
|
||||||
|
if nav is None:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
nums = set()
|
||||||
|
|
||||||
|
# Проходим по всем ссылкам внутри пагинации
|
||||||
|
for link in nav.select("a"):
|
||||||
|
href = link.get("href", "")
|
||||||
|
text = link.text.strip()
|
||||||
|
|
||||||
|
# Из href вытаскиваем page-N
|
||||||
|
match = re.search(r"page-(\d+)", href)
|
||||||
|
if match:
|
||||||
|
nums.add(int(match.group(1)))
|
||||||
|
|
||||||
|
# Из текста — тоже числа (текущая страница без href)
|
||||||
|
try:
|
||||||
|
nums.add(int(text))
|
||||||
|
except ValueError:
|
||||||
|
# "…", "Назад", "Вперёд" — пропускаем
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Защита от пустой пагинации
|
||||||
|
return max(nums) if nums else 1
|
||||||
|
|
||||||
|
|
||||||
|
def page_url(section_url: str, page_num: int) -> str:
|
||||||
|
"""Сформировать URL страницы раздела XenForo.
|
||||||
|
|
||||||
|
Формат:
|
||||||
|
page-1 (первая) → сам section_url
|
||||||
|
page-N (N>1) → section_url + page-N
|
||||||
|
|
||||||
|
Args:
|
||||||
|
section_url: URL раздела (с / на конце).
|
||||||
|
page_num: Номер страницы (1-based).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: полный URL страницы.
|
||||||
|
"""
|
||||||
|
if page_num <= 1:
|
||||||
|
return section_url
|
||||||
|
|
||||||
|
# Убеждаемся что URL заканчивается на /
|
||||||
|
base = section_url.rstrip("/") + "/"
|
||||||
|
return f"{base}page-{page_num}"
|
||||||
|
|
||||||
|
|
||||||
|
def topic_page_url(topic_url: str, page_num: int) -> str:
|
||||||
|
"""Сформировать URL страницы темы XenForo.
|
||||||
|
|
||||||
|
Формат:
|
||||||
|
page-1 → сам topic_url
|
||||||
|
page-N → topic_url + page-N
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic_url: Полный URL темы.
|
||||||
|
page_num: Номер страницы (1-based).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: URL страницы темы.
|
||||||
|
"""
|
||||||
|
if page_num <= 1:
|
||||||
|
return topic_url
|
||||||
|
|
||||||
|
base = topic_url.rstrip("/") + "/"
|
||||||
|
return f"{base}page-{page_num}"
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Парсинг HTML форума XenForo (vwts.ru).
|
||||||
|
|
||||||
|
Структуры:
|
||||||
|
|
||||||
|
Список тем в разделе:
|
||||||
|
<div class="structItem"> ← контейнер темы
|
||||||
|
<div class="structItem-title">
|
||||||
|
<a href="/forum/topic/12345/">Заголовок темы</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Пост в теме:
|
||||||
|
<article class="message"> ← контейнер поста
|
||||||
|
<h4 class="message-name">Автор</h4>
|
||||||
|
<time class="u-dt" datetime="2024-01-15T10:00:00Z">...</time>
|
||||||
|
<ul class="message-attribution-opposite">
|
||||||
|
<li><a>#1</a></li> ← номер поста
|
||||||
|
</ul>
|
||||||
|
<div class="message-content"> ← тело поста
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
Теги темы:
|
||||||
|
<li class="tagItem">
|
||||||
|
<a>Название тега</a>
|
||||||
|
</li>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_topics(soup: BeautifulSoup) -> list[dict]:
|
||||||
|
"""Распарсить список тем со страницы раздела XenForo.
|
||||||
|
|
||||||
|
Ищем:
|
||||||
|
div.structItem → a с href, содержащим /topic/ID/
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: BeautifulSoup страницы раздела.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict]: [{id, title, url}, ...].
|
||||||
|
"""
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for container in soup.select("div.structItem"):
|
||||||
|
# Ссылка на тему
|
||||||
|
link = container.select_one("div.structItem-title a")
|
||||||
|
if link is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
href = link.get("href", "")
|
||||||
|
|
||||||
|
# Из href вытаскиваем ID темы: /topic/12345/
|
||||||
|
match = re.search(r"/topic/(\d+)/", href)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
topic_id = int(match.group(1))
|
||||||
|
title = link.text.strip()
|
||||||
|
|
||||||
|
# Формируем полный URL (если относительный)
|
||||||
|
if href.startswith("/"):
|
||||||
|
full_url = f"https://vwts.ru{href}"
|
||||||
|
elif href.startswith("http"):
|
||||||
|
full_url = href
|
||||||
|
else:
|
||||||
|
full_url = f"https://vwts.ru/forum/topic/{topic_id}/"
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": topic_id,
|
||||||
|
"title": title,
|
||||||
|
"url": full_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def parse_posts(soup: BeautifulSoup) -> list[dict]:
|
||||||
|
"""Распарсить посты со страницы темы XenForo.
|
||||||
|
|
||||||
|
Ищем:
|
||||||
|
article.message → автор, дата, номер, текст
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: BeautifulSoup страницы темы.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict]: [{author, date, num, text}, ...].
|
||||||
|
"""
|
||||||
|
posts = []
|
||||||
|
|
||||||
|
for article in soup.select("article.message"):
|
||||||
|
# ── Автор ───────────────────────────────────────────────────────
|
||||||
|
author_elem = article.select_one("h4.message-name")
|
||||||
|
author = author_elem.get_text(strip=True) if author_elem else "?"
|
||||||
|
|
||||||
|
# ── Дата ────────────────────────────────────────────────────────
|
||||||
|
time_elem = article.select_one("time.u-dt")
|
||||||
|
date = time_elem.get("datetime", "") if time_elem else ""
|
||||||
|
|
||||||
|
# ── Номер поста (#1, #2...) ─────────────────────────────────────
|
||||||
|
num_elem = article.select_one("ul.message-attribution-opposite a")
|
||||||
|
num = num_elem.text.strip() if num_elem else ""
|
||||||
|
|
||||||
|
# ── Текст поста ─────────────────────────────────────────────────
|
||||||
|
content = article.select_one("div.message-content")
|
||||||
|
text = ""
|
||||||
|
if content is not None:
|
||||||
|
# Удаляем скрытые элементы (спойлеры, скрытые блоки)
|
||||||
|
for hidden in content.select(
|
||||||
|
"div[style*='none'], span[style*='none'], details",
|
||||||
|
):
|
||||||
|
hidden.decompose()
|
||||||
|
# Извлекаем текст
|
||||||
|
text = content.get_text("\n", strip=True)
|
||||||
|
|
||||||
|
posts.append({
|
||||||
|
"author": author,
|
||||||
|
"date": date,
|
||||||
|
"num": num,
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tags(soup: BeautifulSoup) -> list[str]:
|
||||||
|
"""Распарсить теги темы XenForo.
|
||||||
|
|
||||||
|
Ищем:
|
||||||
|
li.tagItem → a → текст тега
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: BeautifulSoup страницы темы.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: список тегов (может быть пустым).
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
tag.text.strip()
|
||||||
|
for tag in soup.select("li.tagItem a")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_section_name(soup: BeautifulSoup) -> str | None:
|
||||||
|
"""Извлечь название раздела из h1.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: BeautifulSoup страницы раздела.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: название раздела или None.
|
||||||
|
"""
|
||||||
|
h1 = soup.select_one("h1")
|
||||||
|
if h1 is not None:
|
||||||
|
return h1.text.strip()
|
||||||
|
return None
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# История изменений — scraper/
|
||||||
|
|
||||||
|
## 001 — 2026-06-06: Начальная структура
|
||||||
|
|
||||||
|
### Создано
|
||||||
|
- **scraper/core/** — общий слой, независимый от движка форума
|
||||||
|
- `fetch.py` — HTTP GET с ретраями, автоопределение бана
|
||||||
|
- `throttle.py` — координация задержек между потоками
|
||||||
|
- `output.py` — запись JSONL, ротация по N тем
|
||||||
|
- `state.py` — сохранение/восстановление состояния (пауза/продолжение)
|
||||||
|
- `runner.py` — ThreadPoolExecutor, единый цикл обхода через адаптер
|
||||||
|
|
||||||
|
- **scraper/forums/base.py** — абстрактный базовый класс адаптера форума
|
||||||
|
- Единый интерфейс: `count_pages()`, `parse_topics()`, `parse_posts()`, `page_url()`, `topic_url()`
|
||||||
|
|
||||||
|
- **scraper/forums/xenforo/** — адаптер для XenForo (vwts.ru)
|
||||||
|
- `paginate.py` — `/page-N`, `div.pageNav`
|
||||||
|
- `parse.py` — `div.structItem`, `article.message`, `li.tagItem`
|
||||||
|
- `adapter.py` — подстановка параметров
|
||||||
|
|
||||||
|
- **scraper/forums/phpbb/** — адаптер для phpBB (нива-лада.рф)
|
||||||
|
- `paginate.py` — `?start=N`, `ul.pagination`
|
||||||
|
- `parse.py` — `a.topictitle`, `div.postbody`, `b.postauthor`
|
||||||
|
- `adapter.py` — подстановка параметров
|
||||||
|
|
||||||
|
- **scraper/__main__.py** — точка входа CLI
|
||||||
|
|
||||||
|
### Принципы
|
||||||
|
- `core/` ничего не знает про движок форума — работает через абстракцию `BaseAdapter`
|
||||||
|
- Адаптер реализует только парсинг HTML и формирование URL — никакой логики обхода
|
||||||
|
- Каждый адаптер — отдельный пакет, можно добавить новый движок создав один пакет
|
||||||
|
- Все решения по вёрстке документированы в комментариях перед функциями
|
||||||
Reference in New Issue
Block a user