- 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-тем при многопоточном парсинге
194 lines
6.9 KiB
Python
194 lines
6.9 KiB
Python
"""Адаптер для форумов на 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)
|