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,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
|
||||
Reference in New Issue
Block a user