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