lada_scraper: парсер phpBB форума нива-лада.рф + доку
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
# Парсер phpBB-форума Нива Лада Клуб
|
||||
|
||||
Парсер для форума [нива-лада.рф](https://нива-лада.рф/n/) (phpBB, subsilver2).
|
||||
Предназначен для создания RAG-базы знаний по ремонту Lada Niva / Chevrolet Niva / ВАЗ.
|
||||
|
||||
## Источник
|
||||
|
||||
- **Сайт:** https://нива-лада.рф/n/
|
||||
- **Движок:** phpBB + Board3 Portal
|
||||
- **Стиль:** subsilver2 (табличная вёрстка)
|
||||
- **Разделы:** ~20 технических разделов (двигатель, ходовая, электрика, кузов и т.д.)
|
||||
- **Доступ:** ✅ открыт, без CloudFlare
|
||||
|
||||
## Разделы форума
|
||||
|
||||
| ID | Название | Описание |
|
||||
|----|----------|---------|
|
||||
| 21 | Двигатель | Категория с подразделами |
|
||||
| 22 | Двигатель: механика и ЭСУД | ~1849 тем, ~84K сообщений |
|
||||
| 23 | Система смазки | |
|
||||
| 24 | Система охлаждения | |
|
||||
| 25 | Система питания | |
|
||||
| 26 | Система выпуска | |
|
||||
| 27 | Система зажигания | |
|
||||
| 28 | Трансмиссия | |
|
||||
| 29 | Коробка передач | |
|
||||
| 30 | Ходовая часть | |
|
||||
| 31 | Тормозная система | |
|
||||
| 32 | Рулевое управление | |
|
||||
| 33 | Кузов и салон | |
|
||||
| 34 | Электрооборудование | |
|
||||
| 35 | Отопление и кондиционирование | |
|
||||
| 150 | Ремонт и ТО | Категория с подразделами |
|
||||
| 104 | Флуд | |
|
||||
| 114 | Ура! Купил Ниву | ~1151 страница |
|
||||
| 17 | Дилеры и сервисы | |
|
||||
|
||||
## Структура пакета
|
||||
|
||||
```
|
||||
lada_scraper/
|
||||
├── __init__.py # package
|
||||
├── __main__.py # CLI entry point
|
||||
├── config.py # константы, карта разделов, URL форума
|
||||
├── fetch.py # HTTP GET с ретраями
|
||||
├── paginate.py # phpBB пагинация (?start=N)
|
||||
├── parse.py # парсинг HTML subsilver2 (темы, посты)
|
||||
├── state.py # state.json (пауза/продолжение)
|
||||
├── output.py # JSONL с ротацией (100 тем/файл)
|
||||
├── throttle.py # координация задержек, автоопределение бана
|
||||
├── run.py # главный цикл (ThreadPoolExecutor)
|
||||
└── README.md # этот файл
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
```bash
|
||||
# Список разделов
|
||||
python3 -m lada_scraper --list
|
||||
|
||||
# Запуск парсинга раздела
|
||||
python3 -m lada_scraper 22 # Двигатель: механика и ЭСУД
|
||||
python3 -m lada_scraper 150 --workers 8 # Ремонт и ТО, 8 потоков
|
||||
python3 -m lada_scraper 23 --no-delay # без задержек (если не банит)
|
||||
|
||||
# Пауза: Ctrl+C → состояние сохраняется
|
||||
# Продолжить: та же команда
|
||||
```
|
||||
|
||||
## Формат вывода
|
||||
|
||||
Данные сохраняются в `lada_data/f<ID>/part_*.jsonl`:
|
||||
|
||||
```json
|
||||
{
|
||||
"section": "Система смазки",
|
||||
"section_id": 23,
|
||||
"topic_id": 12345,
|
||||
"topic_title": "Замена масла в двигателе",
|
||||
"topic_url": "https://xn----7sbbagpx1an.xn--p1ai/n/viewtopic.php?f=23&t=12345",
|
||||
"total_posts": 15,
|
||||
"posts": [
|
||||
{
|
||||
"author": "Glu777",
|
||||
"date": "12 авг 2025, 20:00",
|
||||
"num": "#1",
|
||||
"text": "текст сообщения..."
|
||||
}
|
||||
],
|
||||
"scraped_at": "2026-06-04T11:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
## Особенности парсинга
|
||||
|
||||
- **Пагинация:** phpBB использует `?start=N` где N = (page-1) * items_per_page
|
||||
- **Темы:** 25 на страницу раздела
|
||||
- **Посты:** 10 на страницу темы
|
||||
- **HTML:** subsilver2 (табличная вёрстка) — `b.postauthor`, `div.postbody`
|
||||
- **Ротация:** 100 тем на один JSONL-файл
|
||||
|
||||
## Resume
|
||||
|
||||
- `state.json` хранит какие темы обработаны
|
||||
- При перезапуске пропускает готовые темы
|
||||
- Данные дописываются в существующие JSONL-файлы (append)
|
||||
@@ -0,0 +1 @@
|
||||
"""Парсер phpBB-форума Нива Лада Клуб (нива-лада.рф)."""
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Точка входа для парсера Нива Лада Клуб (phpBB).
|
||||
|
||||
Использование:
|
||||
python -m lada_scraper 22 # раздел "Двигатель: механика и ЭСУД"
|
||||
python -m lada_scraper 150 # "Ремонт и ТО"
|
||||
python -m lada_scraper --list # список доступных разделов
|
||||
python -m lada_scraper 22 --workers 8
|
||||
python -m lada_scraper 22 --no-delay
|
||||
"""
|
||||
|
||||
import sys, logging
|
||||
from .config import OUTPUT_DIR, WORKERS, SECTIONS
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(OUTPUT_DIR / "scraper.log", encoding="utf-8"),
|
||||
],
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
|
||||
# ── Список разделов ───────────────────────────────────────────────
|
||||
if "--list" in args:
|
||||
print("📋 Доступные разделы форума Нива Лада Клуб (phpBB):")
|
||||
print(f" {'ID':>4} {'Название':<45} {'Тем':>8}")
|
||||
print(f" {'─'*4} {'─'*45} {'─'*8}")
|
||||
# Пока что выводим из конфига
|
||||
for fid in sorted(SECTIONS):
|
||||
print(f" {fid:>4} {SECTIONS[fid]:<45}")
|
||||
print(f"\nПример: python -m lada_scraper 22")
|
||||
sys.exit(0)
|
||||
|
||||
if not args or args[0].startswith("-"):
|
||||
print("❌ Укажи ID раздела. Пример:")
|
||||
print(" python -m lada_scraper 22")
|
||||
print(" python -m lada_scraper --list")
|
||||
print("\n⚙️ Опции:")
|
||||
print(" --workers N количество потоков (по умолчанию 4)")
|
||||
print(" --no-delay без координации задержек")
|
||||
print(" --no-ban-test не проверять бан")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
forum_id = int(args[0])
|
||||
except ValueError:
|
||||
print(f"❌ ID раздела должен быть числом: {args[0]}")
|
||||
sys.exit(1)
|
||||
|
||||
if forum_id not in SECTIONS:
|
||||
print(f"⚠️ Раздел f={forum_id} не найден в конфиге, но попробуем спарсить.")
|
||||
print(f" SECTIONS известные: {sorted(SECTIONS.keys())}")
|
||||
|
||||
workers = WORKERS
|
||||
no_delay = False
|
||||
ban_test = True
|
||||
|
||||
for a in args[1:]:
|
||||
if a == "--no-delay":
|
||||
no_delay = True
|
||||
elif a == "--no-ban-test":
|
||||
ban_test = False
|
||||
elif a == "--workers" or a == "-w":
|
||||
idx = args.index(a)
|
||||
if idx + 1 < len(args):
|
||||
try:
|
||||
workers = int(args[idx + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
from .run import run
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.info("=" * 60)
|
||||
log.info(f"🚀 Нива Лада Клуб — раздел f={forum_id}")
|
||||
section_name = SECTIONS.get(forum_id, f"Форум #{forum_id}")
|
||||
log.info(f"📋 {section_name}")
|
||||
log.info(f"📂 {OUTPUT_DIR}/f{forum_id}/part_*.jsonl")
|
||||
log.info("🛑 Ctrl+C = пауза | Повторить команду = продолжить")
|
||||
log.info("=" * 60)
|
||||
|
||||
try:
|
||||
run(forum_id, workers=workers, no_delay=no_delay, ban_test=ban_test)
|
||||
except KeyboardInterrupt:
|
||||
log.info("\n🛑 ПАУЗА. Продолжить: та же команда.")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Константы для парсера phpBB."""
|
||||
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
socket.setdefaulttimeout(30)
|
||||
|
||||
OUTPUT_DIR = Path("lada_data")
|
||||
DELAY = 1.5
|
||||
TIMEOUT = (10, 30)
|
||||
TOPICS_PER_FILE = 100
|
||||
WORKERS = 4
|
||||
BAN_TEST = 5
|
||||
TOPICS_PER_PAGE = 25 # phpBB: тем на страницу раздела
|
||||
POSTS_PER_PAGE = 10 # phpBB: постов на страницу темы
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/125.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
# Карта разделов форума: f_id → название
|
||||
# Получена со страницы https://нива-лада.рф/n/index.php
|
||||
SECTIONS = {
|
||||
# ── Технический раздел (f=149) ──
|
||||
21: "Двигатель",
|
||||
22: "Двигатель: механика и ЭСУД",
|
||||
23: "Система смазки",
|
||||
24: "Система охлаждения",
|
||||
25: "Система питания",
|
||||
26: "Система выпуска",
|
||||
27: "Система зажигания",
|
||||
28: "Трансмиссия",
|
||||
29: "Коробка передач",
|
||||
30: "Ходовая часть",
|
||||
31: "Тормозная система",
|
||||
32: "Рулевое управление",
|
||||
33: "Кузов и салон",
|
||||
34: "Электрооборудование",
|
||||
35: "Отопление и кондиционирование",
|
||||
150: "Ремонт и ТО",
|
||||
|
||||
# ── О форуме, флуд ──
|
||||
104: "Флуд",
|
||||
114: "Ура! Купил Ниву",
|
||||
17: "Дилеры и сервисы",
|
||||
}
|
||||
|
||||
# Базовый URL форума
|
||||
FORUM_BASE = "https://xn----7sbbagpx1an.xn--p1ai/n/"
|
||||
FORUM_DOMAIN = "нива-лада.рф"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""HTTP-запросы: GET с ретраями."""
|
||||
|
||||
import time, logging
|
||||
from bs4 import BeautifulSoup
|
||||
import requests
|
||||
from .config import DELAY, TIMEOUT, HEADERS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
throttle = None
|
||||
|
||||
|
||||
def make_session() -> requests.Session:
|
||||
s = requests.Session()
|
||||
s.headers.update(HEADERS)
|
||||
return s
|
||||
|
||||
|
||||
def get(url: str, session: requests.Session = None) -> BeautifulSoup | None:
|
||||
"""GET + ретраи (до 3)."""
|
||||
if session is None:
|
||||
session = make_session()
|
||||
if throttle:
|
||||
throttle.wait()
|
||||
else:
|
||||
time.sleep(DELAY)
|
||||
|
||||
for n in range(3):
|
||||
try:
|
||||
r = session.get(url, timeout=TIMEOUT)
|
||||
if r.status_code == 404:
|
||||
log.warning(f" ⏭️ 404: {url[:80]}")
|
||||
return None
|
||||
if r.status_code in (403, 429, 503):
|
||||
log.warning(f"⚠️ HTTP {r.status_code} — жду {10*(n+1)}с")
|
||||
time.sleep(10 * (n + 1))
|
||||
continue
|
||||
r.raise_for_status()
|
||||
return BeautifulSoup(r.text, "lxml")
|
||||
except Exception as e:
|
||||
log.warning(f"⚠️ Попытка {n+1}/3: {e}")
|
||||
time.sleep(5)
|
||||
log.error(f"❌ Не загружено: {url[:80]}")
|
||||
return None
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Сохранение темы в JSONL. Ротация каждые TOPICS_PER_FILE тем."""
|
||||
|
||||
import json, logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from .config import TOPICS_PER_FILE, OUTPUT_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def save_topic(section_slug: str, section_name: str, topic: dict,
|
||||
posts: list, state: dict, worker_dir: Path = None):
|
||||
"""Записать тему в part_XXXX.jsonl."""
|
||||
st = state
|
||||
|
||||
if st["topic_count"] > 0 and st["topic_count"] % TOPICS_PER_FILE == 0:
|
||||
st["file_index"] += 1
|
||||
|
||||
record = {
|
||||
"section": section_name,
|
||||
"section_id": topic.get("forum_id"),
|
||||
"topic_id": topic["id"],
|
||||
"topic_title": topic["title"],
|
||||
"topic_url": topic["url"],
|
||||
"total_posts": len(posts),
|
||||
"posts": posts,
|
||||
"scraped_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
d = worker_dir if worker_dir else (OUTPUT_DIR / section_slug)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
fpath = d / f"part_{st['file_index']:04d}.jsonl"
|
||||
|
||||
with open(fpath, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
st["topic_count"] += 1
|
||||
|
||||
if st["topic_count"] % 500 == 0:
|
||||
try:
|
||||
fpath.stat()
|
||||
except OSError as e:
|
||||
log.critical(f"❌ Ошибка диска: {e}")
|
||||
raise
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Пагинация phpBB.
|
||||
|
||||
phpBB: пагинация через параметр start=N, где N = (page-1) * items_per_page.
|
||||
|
||||
На странице раздела:
|
||||
<a href="./viewforum.php?f=22&start=25">2</a>
|
||||
<a href="./viewforum.php?f=22&start=50">3</a>
|
||||
...
|
||||
<a href="./viewforum.php?f=22&start=1800">72</a>
|
||||
|
||||
На странице темы:
|
||||
<a href="./viewtopic.php?f=22&t=123&start=10">2</a>
|
||||
...
|
||||
"""
|
||||
|
||||
import re, logging
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def count(soup: BeautifulSoup) -> int:
|
||||
"""Сколько страниц в пагинации. Если нет — 1."""
|
||||
for cls in ("pagination", "page-nav"):
|
||||
nav = soup.find("ul", class_=cls)
|
||||
if nav:
|
||||
break
|
||||
else:
|
||||
return 1
|
||||
|
||||
nums = set()
|
||||
for a in nav.select("a"):
|
||||
href = a.get("href", "")
|
||||
text = a.text.strip()
|
||||
|
||||
# start=N из href
|
||||
m = re.search(r"[?&]start=(\d+)", href)
|
||||
if m:
|
||||
per_page = int(m.group(1))
|
||||
# Определяем items_per_page по первому start
|
||||
if hasattr(count, "_per_page"):
|
||||
pass
|
||||
nums.add(per_page)
|
||||
|
||||
# Числовой текст ссылки
|
||||
try:
|
||||
nums.add(int(text))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not nums:
|
||||
return 1
|
||||
|
||||
# Находим максимальный номер страницы из текста ссылок
|
||||
max_page_num = max((int(a.text.strip()) for a in nav.select("a")
|
||||
if a.text.strip().isdigit()), default=1)
|
||||
|
||||
# Находим max start
|
||||
max_start = max(nums)
|
||||
|
||||
# Определяем per_page из наименьшего start > 0
|
||||
positive_starts = [s for s in nums if s > 0]
|
||||
if positive_starts:
|
||||
per_page = min(positive_starts)
|
||||
else:
|
||||
# Не можем определить — возвращаем по тексту ссылок
|
||||
return max_page_num
|
||||
|
||||
# Вычисляем: страниц = (max_start / per_page) + 1
|
||||
pages = (max_start // per_page) + 1
|
||||
return pages
|
||||
|
||||
|
||||
def page_url(base_url: str, page: int, per_page: int) -> str:
|
||||
"""Сформировать URL для страницы пагинации.
|
||||
|
||||
Args:
|
||||
base_url: URL без start= (напр. viewforum.php?f=22)
|
||||
page: номер страницы (1-based)
|
||||
per_page: элементов на странице
|
||||
"""
|
||||
if page <= 1:
|
||||
return base_url
|
||||
start = (page - 1) * per_page
|
||||
sep = "&" if "?" in base_url else "?"
|
||||
return f"{base_url}{sep}start={start}"
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Парсинг HTML phpBB: темы и посты.
|
||||
|
||||
Структура phpBB (subsilver2, нива-лада.рф):
|
||||
|
||||
Список тем в разделе:
|
||||
<a href="./viewtopic.php?f=22&t=123" class="topictitle">...</a>
|
||||
|
||||
Посты в теме (табличная вёрстка subsilver2):
|
||||
<tr>
|
||||
<td class="profile" rowspan="2">
|
||||
<strong><a href="./memberlist.php?...">AuthorName</a></strong><br />
|
||||
<span class="postdetails">Role</span>
|
||||
...
|
||||
</td>
|
||||
<td>
|
||||
<div class="postbody">текст поста</div>
|
||||
...
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="gensmall" style="float: left;">
|
||||
<a href="./memberlist.php?...">AuthorName</a> » Дата
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
import re, logging
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_topics(soup: BeautifulSoup) -> list:
|
||||
"""Список тем со страницы раздела."""
|
||||
items = []
|
||||
|
||||
for a in soup.select("a.topictitle"):
|
||||
href = a.get("href", "")
|
||||
m = re.search(r"[?&]t=(\d+)", href)
|
||||
if not m:
|
||||
continue
|
||||
topic_id = int(m.group(1))
|
||||
f_match = re.search(r"[?&]f=(\d+)", href)
|
||||
forum_id = int(f_match.group(1)) if f_match else 0
|
||||
items.append({
|
||||
"id": topic_id,
|
||||
"forum_id": forum_id,
|
||||
"title": a.text.strip(),
|
||||
"url": href if href.startswith("http") else None,
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def parse_posts(soup: BeautifulSoup) -> list:
|
||||
"""Посты со страницы темы (subsilver2, нива-лада.рф).
|
||||
|
||||
Структура одного поста (3 <tr> в <table class="tablebg">):
|
||||
|
||||
<tr class="row1"> ← шапка: автор + дата
|
||||
<td><b class="postauthor">Author</b></td>
|
||||
<td>
|
||||
<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>
|
||||
<tr class="row1"> ← подвал
|
||||
<td class="profile">Вернуться к началу</td>
|
||||
<td><div class="gensmall">...</div></td>
|
||||
</tr>
|
||||
"""
|
||||
out = []
|
||||
|
||||
# Ищем все блоки: b.postauthor = автор, затем ищем postbody
|
||||
for author_b in soup.select("b.postauthor"):
|
||||
author = author_b.text.strip()
|
||||
|
||||
# Дата: ищем "Добавлено:" в том же tr
|
||||
date = ""
|
||||
tr_header = author_b.find_parent("tr")
|
||||
if tr_header:
|
||||
date_td = tr_header.select_one("td[width='100%']")
|
||||
if date_td:
|
||||
dt_text = date_td.get_text(" ", strip=True)
|
||||
m = re.search(r"Добавлено:\s*(.+)", dt_text)
|
||||
if m:
|
||||
date = m.group(1).strip()
|
||||
|
||||
# Текст: ищем div.postbody в следующем tr после tr_header
|
||||
text = ""
|
||||
if tr_header:
|
||||
tr_body = tr_header.find_next_sibling("tr")
|
||||
if tr_body:
|
||||
pb = tr_body.select_one("div.postbody")
|
||||
if pb:
|
||||
for h in pb.select("div[style*='none'], span[style*='none'], "
|
||||
"blockquote cite, dl.codebox, div.codebox"):
|
||||
h.decompose()
|
||||
text = pb.get_text("\n", strip=True)
|
||||
|
||||
num = str(len(out) + 1)
|
||||
|
||||
out.append({
|
||||
"author": author,
|
||||
"date": date,
|
||||
"num": f"#{num}",
|
||||
"text": text,
|
||||
})
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Главный цикл: обход разделов phpBB-форума."""
|
||||
|
||||
import sys, logging, shutil, json, re
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
from .config import (
|
||||
OUTPUT_DIR, WORKERS, BAN_TEST, DELAY, SECTIONS,
|
||||
FORUM_BASE, TOPICS_PER_PAGE, POSTS_PER_PAGE,
|
||||
)
|
||||
from .fetch import get, make_session
|
||||
from .paginate import count
|
||||
from .parse import parse_topics, parse_posts
|
||||
from .state import load, save, merge_states
|
||||
from .output import save_topic
|
||||
from .throttle import Throttle, BanDetector
|
||||
from . import fetch as _fetch
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_throttle = None
|
||||
|
||||
|
||||
def _url(path: str) -> str:
|
||||
"""Полный URL относительно FORUM_BASE."""
|
||||
if path.startswith("http"):
|
||||
# Убираем sid из URL для чистоты
|
||||
path = re.sub(r"[?&]sid=[^&]+", "", path)
|
||||
# Заменяем http на https
|
||||
path = path.replace("http://", "https://")
|
||||
return path
|
||||
path = re.sub(r"[?&]sid=[^&]+", "", path)
|
||||
return urljoin(FORUM_BASE, path)
|
||||
|
||||
|
||||
def _worker(forum_id: int, section_name: str, pages: range, worker_id: int):
|
||||
"""Один поток: обходит свой диапазон страниц раздела."""
|
||||
session = make_session()
|
||||
st = {"topics_done": {}, "pages_done": [], "file_index": 0, "topic_count": 0}
|
||||
slug = f"f{forum_id}"
|
||||
consecutive_404 = 0
|
||||
|
||||
worker_dir = OUTPUT_DIR / slug / f"worker_{worker_id}"
|
||||
worker_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
base_url = f"{FORUM_BASE}viewforum.php?f={forum_id}"
|
||||
|
||||
for pg in pages:
|
||||
url = base_url if pg == 1 else f"{base_url}&start={(pg-1)*TOPICS_PER_PAGE}"
|
||||
log.info(f"[W{worker_id}] 📄 стр.{pg}")
|
||||
|
||||
page_soup = get(url, session)
|
||||
if not page_soup:
|
||||
consecutive_404 += 1
|
||||
if consecutive_404 >= 3:
|
||||
log.info(f"[W{worker_id}] ⏹️ 3 пустых — завершаем")
|
||||
break
|
||||
continue
|
||||
consecutive_404 = 0
|
||||
|
||||
topics = parse_topics(page_soup)
|
||||
log.info(f"[W{worker_id}] Тем: {len(topics)}")
|
||||
|
||||
for i, tp in enumerate(topics, 1):
|
||||
tid = tp["id"]
|
||||
if str(tid) in st["topics_done"]:
|
||||
continue
|
||||
|
||||
# Собираем полный URL темы
|
||||
topic_url = _url(tp["url"] or f"./viewtopic.php?f={forum_id}&t={tid}")
|
||||
tp["url"] = topic_url
|
||||
|
||||
log.info(f"[W{worker_id}] [{i}/{len(topics)}] #{tid}: {tp['title'][:80]}")
|
||||
topic_soup = get(topic_url, session)
|
||||
if not topic_soup:
|
||||
st["topics_done"][str(tid)] = tp["title"]
|
||||
continue
|
||||
|
||||
topic_pages = count(topic_soup)
|
||||
posts = []
|
||||
|
||||
for tpp in range(1, topic_pages + 1):
|
||||
if tpp == 1:
|
||||
posts += parse_posts(topic_soup)
|
||||
else:
|
||||
tp_url = f"{topic_url}&start={(tpp-1)*POSTS_PER_PAGE}"
|
||||
tp2 = get(tp_url, session)
|
||||
if tp2:
|
||||
posts += parse_posts(tp2)
|
||||
|
||||
log.info(f"[W{worker_id}] {len(posts)} постов ({topic_pages} стр.)")
|
||||
save_topic(slug, section_name, tp, posts, st, worker_dir=worker_dir)
|
||||
st["topics_done"][str(tid)] = tp["title"]
|
||||
|
||||
st["pages_done"].append(pg)
|
||||
|
||||
_state_file = worker_dir / "state.json"
|
||||
_state_file.write_text(json.dumps(st, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
session.close()
|
||||
return worker_id, st["topic_count"]
|
||||
|
||||
|
||||
def _merge_workers(slug: str):
|
||||
"""Собрать part_*.jsonl из всех worker_N/ в корень."""
|
||||
root = OUTPUT_DIR / slug
|
||||
|
||||
all_parts = []
|
||||
for wdir in sorted(root.glob("worker_*")):
|
||||
if wdir.is_dir():
|
||||
for f in sorted(wdir.glob("part_*.jsonl")):
|
||||
all_parts.append(f)
|
||||
|
||||
log.info(f"🔗 Мерж {len(all_parts)} файлов из {len(list(root.glob('worker_*')))} воркеров")
|
||||
|
||||
for idx, src in enumerate(all_parts):
|
||||
dst = root / f"part_{idx:04d}.jsonl"
|
||||
shutil.move(str(src), str(dst))
|
||||
|
||||
for wdir in root.glob("worker_*"):
|
||||
if wdir.is_dir():
|
||||
try:
|
||||
wdir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
merge_states(slug)
|
||||
log.info(f"✅ Мерж: {len(all_parts)} файлов → {root}")
|
||||
|
||||
|
||||
def run(forum_id: int, workers: int = WORKERS,
|
||||
no_delay: bool = False, ban_test: bool = True):
|
||||
"""Главный цикл.
|
||||
|
||||
Args:
|
||||
forum_id: ID раздела на форуме (напр. 22)
|
||||
workers: количество потоков
|
||||
no_delay: без координации задержек
|
||||
ban_test: проверить бан перед стартом
|
||||
"""
|
||||
slug = f"f{forum_id}"
|
||||
section_name = SECTIONS.get(forum_id, f"Форум #{forum_id}")
|
||||
|
||||
throttle_enabled = not no_delay
|
||||
if ban_test and throttle_enabled and workers > 1:
|
||||
if not BanDetector.test(f"{FORUM_BASE}viewforum.php?f={forum_id}", BAN_TEST):
|
||||
throttle_enabled = False
|
||||
|
||||
global _throttle
|
||||
_throttle = Throttle(DELAY, enabled=throttle_enabled)
|
||||
_fetch.throttle = _throttle
|
||||
|
||||
log.info(f"⚙️ Потоков: {workers}, координация: {'вкл' if throttle_enabled else 'выкл'}")
|
||||
|
||||
# ── Страница 1: определяем количество страниц ─────────────────────
|
||||
first_url = f"{FORUM_BASE}viewforum.php?f={forum_id}"
|
||||
soup = get(first_url)
|
||||
if not soup:
|
||||
log.error(f"❌ Раздел f={forum_id} недоступен")
|
||||
sys.exit(1)
|
||||
|
||||
total_pages = count(soup)
|
||||
log.info(f"📋 '{section_name}' (f={forum_id}) | страниц: {total_pages}")
|
||||
|
||||
if total_pages <= 1:
|
||||
log.info("ℹ️ Всего одна страница — проверяем, есть ли темы...")
|
||||
topics = parse_topics(soup)
|
||||
if not topics:
|
||||
log.warning(f"⚠️ В разделе f={forum_id} нет тем (возможно, это категория с подразделами)")
|
||||
sys.exit(0)
|
||||
|
||||
# ── Диапазоны страниц ─────────────────────────────────────────────
|
||||
if workers > total_pages:
|
||||
workers = total_pages
|
||||
|
||||
base_size = total_pages // workers
|
||||
remainder = total_pages % workers
|
||||
ranges = []
|
||||
start = 1
|
||||
for w in range(workers):
|
||||
size = base_size + (1 if w < remainder else 0)
|
||||
ranges.append(range(start, start + size))
|
||||
start += size
|
||||
|
||||
log.info(f"📦 Диапазоны: {[f'{r.start}-{r[-1]}' for r in ranges]}")
|
||||
|
||||
# ── Запуск потоков ────────────────────────────────────────────────
|
||||
total_topics = 0
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [
|
||||
pool.submit(_worker, forum_id, section_name, rng, i)
|
||||
for i, rng in enumerate(ranges)
|
||||
]
|
||||
for fut in as_completed(futures):
|
||||
wid, cnt = fut.result()
|
||||
total_topics += cnt
|
||||
log.info(f"[W{wid}] ✅ завершён: {cnt} тем")
|
||||
|
||||
# ── Мерж ──────────────────────────────────────────────────────────
|
||||
_merge_workers(slug)
|
||||
|
||||
# ── Итог ──────────────────────────────────────────────────────────
|
||||
log.info("=" * 60)
|
||||
log.info(f"✅ '{section_name}' (f={forum_id}) — {total_topics} тем")
|
||||
for f in sorted((OUTPUT_DIR / slug).glob("part_*.jsonl")):
|
||||
log.info(f" 📄 {f.name} ({f.stat().st_size/1024/1024:.1f} MB)")
|
||||
log.info("=" * 60)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Управление состоянием (state.json в папке раздела)."""
|
||||
|
||||
import json, logging
|
||||
from pathlib import Path
|
||||
from .config import OUTPUT_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _state_file(section_slug: str) -> Path:
|
||||
d = OUTPUT_DIR / section_slug
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d / "state.json"
|
||||
|
||||
|
||||
def load(section_slug: str) -> dict:
|
||||
f = _state_file(section_slug)
|
||||
if f.exists():
|
||||
return json.loads(f.read_text(encoding="utf-8"))
|
||||
return {"topics_done": {}, "pages_done": [], "file_index": 0, "topic_count": 0}
|
||||
|
||||
|
||||
def save(section_slug: str, st: dict):
|
||||
_state_file(section_slug).write_text(
|
||||
json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def merge_states(section_slug: str):
|
||||
"""Собрать состояния всех воркеров в единый state.json."""
|
||||
root = OUTPUT_DIR / section_slug
|
||||
merged = {"topics_done": {}, "pages_done": [], "file_index": 0, "topic_count": 0}
|
||||
|
||||
for wdir in sorted(root.glob("worker_*/state.json")):
|
||||
try:
|
||||
wst = json.loads(wdir.read_text(encoding="utf-8"))
|
||||
merged["topics_done"].update(wst.get("topics_done", {}))
|
||||
merged["pages_done"].extend(wst.get("pages_done", []))
|
||||
merged["topic_count"] += wst.get("topic_count", 0)
|
||||
except Exception as e:
|
||||
log.warning(f"⚠️ Ошибка чтения {wdir}: {e}")
|
||||
|
||||
merged["pages_done"] = sorted(set(merged["pages_done"]))
|
||||
merged["file_index"] = len(list(root.glob("part_*.jsonl"))) - 1
|
||||
if merged["file_index"] < 0:
|
||||
merged["file_index"] = 0
|
||||
|
||||
_state_file(section_slug).write_text(
|
||||
json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Общая координация задержек и автоопределение бана."""
|
||||
|
||||
import time, threading, logging
|
||||
import requests
|
||||
from .config import DELAY, TIMEOUT, HEADERS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Throttle:
|
||||
"""Thread-safe координатор задержек."""
|
||||
|
||||
def __init__(self, delay: float = DELAY, enabled: bool = True):
|
||||
self._delay = delay
|
||||
self._enabled = enabled
|
||||
self._lock = threading.Lock()
|
||||
self._last = 0.0
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def wait(self):
|
||||
if not self._enabled:
|
||||
time.sleep(self._delay)
|
||||
return
|
||||
with self._lock:
|
||||
elapsed = time.monotonic() - self._last
|
||||
if elapsed < self._delay:
|
||||
time.sleep(self._delay - elapsed)
|
||||
self._last = time.monotonic()
|
||||
|
||||
def disable(self):
|
||||
self._enabled = False
|
||||
|
||||
|
||||
class BanDetector:
|
||||
"""Проверяет, банит ли сервер."""
|
||||
|
||||
@staticmethod
|
||||
def test(base_url: str, count: int = 5) -> bool:
|
||||
log.info(f"🔍 Проверка бана: {count} запросов подряд...")
|
||||
s = requests.Session()
|
||||
s.headers.update(HEADERS)
|
||||
banned = False
|
||||
for i in range(count):
|
||||
try:
|
||||
r = s.get(base_url, timeout=TIMEOUT)
|
||||
log.info(f" [{i+1}/{count}] HTTP {r.status_code}")
|
||||
if r.status_code in (403, 429):
|
||||
banned = True
|
||||
break
|
||||
except Exception as e:
|
||||
log.warning(f" [{i+1}/{count}] ошибка: {e}")
|
||||
banned = True
|
||||
break
|
||||
if banned:
|
||||
log.warning("⚠️ Сервер банит быстрые запросы — включаю координацию")
|
||||
else:
|
||||
log.info("✅ Бан не обнаружен")
|
||||
s.close()
|
||||
return banned
|
||||
Reference in New Issue
Block a user