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
+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