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