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:
2026-06-06 11:46:57 +04:00
parent 673bfe5e2a
commit 5c142096de
18 changed files with 2118 additions and 0 deletions
+200
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
# scraper.core — общий слой парсера
# Содержит модули, независимые от движка форума.
# См. fetch.py, throttle.py, output.py, state.py, runner.py.
+118
View File
@@ -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
+134
View File
@@ -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()
+322
View File
@@ -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)
+114
View File
@@ -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_*"))))
+147
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# scraper.forums — адаптеры под разные движки форумов
# Каждый пакет реализует BaseAdapter.
# См. base.py, xenforo/, phpbb/
+179
View File
@@ -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 []
+1
View File
@@ -0,0 +1 @@
# scraper.forums.phpbb — адаптер для форумов на phpBB (нива-лада.рф и др.)
+193
View File
@@ -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)
+110
View File
@@ -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}"
+142
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# scraper.forums.xenforo — адаптер для форумов на XenForo (vwts.ru)
+146
View File
@@ -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)
+109
View File
@@ -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}"
+163
View File
@@ -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
+32
View File
@@ -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 — никакой логики обхода
- Каждый адаптер — отдельный пакет, можно добавить новый движок создав один пакет
- Все решения по вёрстке документированы в комментариях перед функциями