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