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 @@
|
||||
# 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