lada_scraper: парсер phpBB форума нива-лада.рф + доку

This commit is contained in:
2026-06-04 11:47:02 +03:00
parent 928c979509
commit eca5e3cda6
15 changed files with 1108 additions and 7 deletions
+1 -1
View File
@@ -12,4 +12,4 @@ __pycache__/
# vwts.ru scraper # vwts.ru scraper
VWTS/ VWTS/
vwts_data/ vwts_data/
vwts_scraper.py vwts_scraper.pylada_data/
+141
View File
@@ -0,0 +1,141 @@
# Парсеры автомобильных форумов для RAG
## Цель
Выкачать техническую информацию с автомобильных форумов для создания RAG-базы знаний по ремонту автомобилей.
## Источники данных
### 1. vwts.ru — VAG (Volkswagen/Audi/Škoda/SEAT)
- **Движок:** XenForo
- **~60 000+ тем, ~1.2 млн сообщений**
- **Парсер:** `vwts_scraper/`
- Структура URL:
- Раздел: /forum/vag/{slug}/ (пагинация: page-N)
- Тема: /forum/topic/{id}/ (пагинация: page-N)
- Поиск: /forum/search/
- Теги: /forum/tags/
- RSS: /forum/vag/-/index.rss
### 2. нива-лада.рф — Lada Niva / Chevrolet Niva / ВАЗ
- **Движок:** phpBB (subsilver2) + Board3 Portal
- **~20 технических разделов, активный, без CloudFlare**
- **Парсер:** `lada_scraper/`
- Структура URL:
- Раздел: /n/viewforum.php?f=ID (пагинация: ?start=N)
- Тема: /n/viewtopic.php?f=ID&t=ID (пагинация: ?start=N)
- RSS: /n/feed.php
### 3. Статьи vwts.ru
- ~500+ статей по ремонту VAG
- Sitemap: https://vwts.ru/sitemap.xml
## Разделы форума (18 шт)
| Раздел | Slug | Тем | Постов |
|---|---|---|---|
| Диагностика | diagnostika | 2 322 | 36 052 |
| Бензиновые двигатели | benzinovye-dvigateli | 4 559 | 98 158 |
| Дизельные двигатели | dizelnye-dvigateli | 3 857 | 101 174 |
| Система охлаждения/отопление/кондиц. | sistema-ohlazhdeniya-otoplenie-konditsionirovanie | 6 133 | 90 689 |
| Система смазки | sistema-smazki | 258 | 11 723 |
| Системы впрыска и зажигания | sistemy-vpryska-i-zazhiganiya | 3 435 | 87 636 |
| Топливная система | toplivnaya-sistema | 326 | 4 916 |
| Выпускная система | vypusknaya-sistema | 1 064 | 16 171 |
| Подвеска | podveska | 4 304 | 56 200 |
| Рулевое управление | rulevoe-upravlenie | 2 383 | 27 212 |
| Тормозная система | tormoznaya-sistema | 2 736 | 39 673 |
| Электрооборудование | elektrooborudovanie | 13 715 | 146 883 |
| Коробки передач, сцепление | korobki-peredach-stseplenie | 6 165 | 67 286 |
| Кузов | kuzov | 5 480 | 57 564 |
| Шины / Диски | shiny-diski | 1 262 | 18 866 |
| ГСМ | goryuche-smazochnye-materialy | 606 | 15 286 |
| Разное | raznoe | 3 359 | 35 918 |
| Гараж / инструменты | garazh-instrumenty | 471 | 9 630 |
| Автосигнализации | avtosignalizatsii-i-zaschita-ot-ugona | 1 431 | 13 763 |
| Сервисы, дилеры | servisy-ofitsialnye-dilery | 1 474 | 13 244 |
| Тюнинг | tyuning | 1 695 | 41 043 |
| Автозвук | avtozvuk | 2 514 | 46 462 |
| Автоэлектроника | avtomobilnaya-elektronika-i-aksessuary | 36 | 1 740 |
| Наши машины | nashi-mashiny | 460 | 66 809 |
| Оперативные вопросы | operativnye-voprosy-tehnicheskie | 1 677 | 31 579 |
| Документация по VW | dokumentatsiya-po-volkswagen | 1 413 | 9 396 |
| Модели VW | volkswagen | 41 | 248 |
| Skoda | skoda | 17 | 59 |
| Электромобили VAG | electric-cars | 21 | 108 |
| Отзывы, выбор, покупка | otzyvy-vybor-pokupka-avtomobilya | 2 190 | 56 837 |
| Покупка/продажа авто | pokupka-i-prodazha-avtomobiley | 89 | 1 978 |
| Отчёты об эксплуатации | otchety-o-ekspluatatsii-avtomobiley | 38 | 1 298 |
| Покупка/продажа запчастей | pokupka--prodazha-zapchastey | 122 | 1 348 |
| Отзывы о магазинах | otzyvy-o-magazinah-zapchastey-razborkah | 427 | 4 261 |
| Барахолка | baraholka | 18 | 38 |
| ГАИ и законодательство | gai-i-zakonodatelstvo | 1 564 | 24 634 |
| Дороги, поездки | dorogi-poezdki-nashi-puteshestviya | 454 | 11 895 |
## Скрипт (пакет Python)
### Структура
```
vwts_scraper/
├── __init__.py # package
├── __main__.py # CLI entry point
├── config.py # константы (DELAY, TIMEOUT, HEADERS)
├── fetch.py # HTTP GET с ретраями
├── paginate.py # page_count + HEAD-проверка границ
├── parse.py # парсинг HTML (темы, посты, теги)
├── state.py # state.json (per-section)
├── output.py # JSONL с ротацией
└── run.py # главный цикл обхода
```
### Запуск
```bash
python -m vwts_scraper https://vwts.ru/forum/vag/benzinovye-dvigateli/
```
### Пауза / продолжение
- Ctrl+C — пауза, состояние сохраняется в `vwts_data/{slug}/state.json`
- Повторить команду — продолжит с места остановки
- Разные разделы не конфликтуют (у каждого своя папка + state.json)
### Вывод
- `vwts_data/{slug}/part_0001.jsonl`, `part_0002.jsonl`... — по 100 тем в файле
- Формат строки JSONL:
```json
{
"section": "Диагностика",
"section_slug": "diagnostika",
"topic_id": 253735,
"topic_title": "ошибка 00513. Заглох Passat B3 VR6",
"topic_url": "https://vwts.ru/forum/topic/253735/",
"total_posts": 98,
"posts": [
{"author": "Wizard13", "date": "2026-05-27T10:00:00", "num": "#1", "text": "...", "tags": ["passat b3", "vr6"]},
...
],
"scraped_at": "2026-06-03T20:30:00"
}
```
## Оценка времени
- Задержка между запросами: 1.5 сек
- В среднем 2 запроса на тему (список + посты)
- ~120 000 запросов → ~50 часов чистого времени
- Реально с учётом ошибок/повторов: ~3-5 дней
## Resume (продолжение с места остановки)
- state.json хранит какие разделы завершены и какие темы обработаны
- При перезапуске пропускает готовые темы и разделы
- Данные дописываются в существующие JSONL-файлы (append)
## Для RAG
Каждый пост — отдельный chunk с метаданными:
- `section` — раздел форума
- `topic_title` — название темы
- `tags` — теги темы
- `author` — автор
- `date` — дата
- `text` — содержимое поста
Можно индексировать в любую векторную БД (Chroma, Qdrant и т.д.).
+80
View File
@@ -0,0 +1,80 @@
# Анализ зарубежных VAG-форумов для парсинга
Дата: 2026-06-04
## Проверенные форумы
### 1. vwforum.com (VW общий)
- Адрес: https://www.vwforum.com/
- Движок: XenForo (кастомная сборка XenForo Cloud)
- Постов: ~465K, участников: ~84K
- Доступ из РФ: ✅ (открывается, но нестабильно)
**Проблемы:**
- ❌ Пагинация разделов отсутствует — показываются только последние темы
- ❌ HTTP 406 (Not Acceptable) на `page-2` — пагинация отключена на сервере
- ❌ После 2–3 запросов — Read timeout (защита от парсинга)
- ❌ Невозможно получить список ВСЕХ тем раздела
### 2. audiforum.us (Audi)
- Адрес: https://www.audiforum.us/
- Движок: XenForo (та же кастомная сборка, что и vwforum.com)
- Постов: ~105K, участников: ~15K
- Доступ из РФ: ✅
**Проблемы:** те же, что у vwforum.com (оба на одной платформе)
### 3. Другие (недоступны из РФ)
| Форум | Причина |
|---|---|
| vwvortex.com | Cloudflare |
| forums.tdiclub.com | Cloudflare |
| briskoda.net | Cloudflare |
| vwaudiforum.co.uk | DNS не резолвится |
| volkswagenforum.co.uk | Таймаут |
| passatworld.com | Таймаут |
## Выводы
Совместимость с нашим скриптом определяется по двум критериям:
| Критерий | vwts.ru | vwforum.com / audiforum.us |
|---|---|---|
| Пагинация разделов (`div.pageNav`) | ✅ | ❌ (нет пагинации) |
| Доступность (не блокирует) | ✅ | ❌ (таямауты после 2-3 запросов) |
| `page_count()` работает | ✅ | ❌ |
| Полнота данных (все темы) | ✅ | ❌ (только последние) |
| Формат URL page-N | ✅ | ❌ (HTTP 406) |
**Рекомендация:** искать форумы с:
1. Классическим XenForo (не XenForo Cloud)
2. Полноценной пагинацией `div.pageNav`
3. Доступом без Cloudflare
4. Возможностью парсинга через requests (без JS)
5. Для phpBB — пагинация через `?start=N` (subsilver2 стиль)
Пример подходящих: vwts.ru (XenForo), нива-лада.рф (phpBB).
### Проверка немецких Mercedes-форумов (2026-06-04, с германской ВМ 95.179.252.111)
| Форум | Движок | Статус | Причина |
|-------|--------|--------|---------|
| benzworld.org | XenForo | ❌ 409 | `xf_is_suspected_bot=1` — бот-детекция, даже с DE IP |
| mercedes-benz-forum.com | — | ❌ | DNS не резолвится |
| mercedes-treff.de | — | ❌ | Таймаут (SSL handshake доходит, ответа нет) |
| motor-talk.de | React SPA | ❌ | CloudFlare + контент только через JS |
| mercedes-fans.de | Symfony | ❌ | Не форум, а онлайн-журнал |
| mercedesforum.de, mb-forum.de, forum.mb-passion.com и др. | — | ❌ | Не отвечают / DNS не резолвится |
**Итог:** доступных немецких форумов по Mercedes для парсинга не найдено.
benzworld.org — единственный крупный XenForo (7.9M постов), но блокирует даже с немецкого IP.
## Тесты page_count
| Тест | Результат |
|---|---|
| 50 страниц раздела (стр.1…113) | 0 ошибок ✅ |
| 50 тем (1–48 страниц) | 0 ошибок ✅ |
| Проблемная #138549 (139 стр) | Определена правильно |
| Проблемная #253565 (VCDS шнурок) | 1 стр (правильно) |
+27 -6
View File
@@ -16,20 +16,41 @@
| kianiroforum.com | Kia Niro (en) | XenForo | 🟡 | 79K сообщений, timeout | | kianiroforum.com | Kia Niro (en) | XenForo | 🟡 | 79K сообщений, timeout |
| mercedes-* (все русские) | Mercedes (ru) | — | ❌ | Все мертвы | | mercedes-* (все русские) | Mercedes (ru) | — | ❌ | Все мертвы |
| lada-* (все) | Lada | — | ❌ | Все домены мертвы | | lada-* (все) | Lada | — | ❌ | Все домены мертвы |
| **нива-лада.рф/n/** | Lada/Niva/Chevy Niva | **phpBB** | ✅ | Крупный, ~1K+ тем в каждом разделе, очень активный |
| **lada.cc/board/** | Lada/ВАЗ | **Invision Power Board** | ✅ | Живой, есть пагинация, ~250KB страница |
| dricar.ru/forum5.html | Жигули классика | Кастом | 🟡 | Мелкий, кастомный, только классика |
| niva-chevy.ru | Niva Chevrolet | Статика | ❌ | Не форум, а руководство по ремонту |
| niva-club.su | Niva | Статика | ❌ | Не обновлялся с 2020 |
| chevrolet-niva-club.ru | Niva | — | ❌ | Не отвечает |
| ladacenter.ru/forum/ | Lada | — | ❌ | HTTP 400 |
| lada-vesta-club.ru | Lada | Cloudflare | ❌ | Cloudflare, не форум |
| kia-* (все русские) | Kia (ru) | — | ❌ | CloudFlare/недоступны | | kia-* (все русские) | Kia (ru) | — | ❌ | CloudFlare/недоступны |
| bmwclub.ru | BMW | — | ❌ | DDoS-Guard | | bmwclub.ru | BMW | — | ❌ | DDoS-Guard |
| motor-talk.de | VAG (de) | — | ❌ | CloudFlare | | motor-talk.de | VAG (de) | React SPA | ❌ | React SPA + CloudFlare |
| toyota-club.net | Toyota (ru) | — | ❌ | 403 | | toyota-club.net | Toyota (ru) | — | ❌ | 403 |
| vwvortex.com | VW (en) | Кастом | 🟡 | Есть темы, нет pageNav | | vwvortex.com | VW (en) | Кастом | 🟡 | Есть темы, нет pageNav |
| vwforum.com | VW (en) | Кастом | 🟡 | Малый, не XenForo | | vwforum.com | VW (en) | Кастом | 🟡 | Малый, не XenForo |
### Немецкие Mercedes-форумы (с германской ВМ)
| Форум | Движок | Доступ | Причина |
|-------|--------|--------|---------|
| **benzworld.org** | XenForo | ❌ 409 | `xf_is_suspected_bot=1` — бот-детекция |
| mercedes-benz-forum.com | — | ❌ | DNS не резолвится |
| mercedes-treff.de | — | ❌ | Таймаут |
| motor-talk.de | React SPA | ❌ | CloudFlare + React |
| mercedes-fans.de | Symfony | ❌ | Не форум, а журнал |
| mercedesforum.de, mb-forum.de и др. | — | ❌ | Не отвечают |
## Выводы ## Выводы
1. **XenForo с открытым доступом — vwts.ru (работает) и benzworld.org (timeout с РФ).** Остальные либо под защитой, либо мёртвы. 1. **XenForo с открытым доступом — vwts.ru (работает).** Остальные либо под защитой, либо мёртвы.
2. benzworld.org (XenForo, 7.9M сообщений) — возможно доступен через прокси/VPN 2. benzworld.org (XenForo, 7.9M сообщений) — блокирует парсинг (HTTP 409) даже с немецкого IP.
3. **Живые форумы на других движках** — потребуют отдельных парсеров: 3. **Основной новый источник — нива-лада.рф (phpBB).** Крупный, живой, без CloudFlare. Есть парсер в `lada_scraper/`.
- phpBB (`viewtopic.php`) — lexus-club.ru 4. **Второстепенный — lada.cc/board/ (IPB).** Требует отдельного парсера под Invision Power Board.
5. **Живые форумы на других движках** — потребуют отдельных парсеров:
- phpBB (`viewtopic.php`) — нива-лада.рф ✅ (парсер готов), lexus-club.ru
- vBulletin (`forumdisplay.php`) — renault-club.ru - vBulletin (`forumdisplay.php`) — renault-club.ru
- Invision Power Board (`ipsPagination`) — ffclub.ru - Invision Power Board (`ipsPagination`) — ffclub.ru, lada.cc
3. **Англоязычные Kia-форумы** — крупные (kia-forums.com, 741K сообщений), но сабфорумы не грузятся с нашего IP (возможно гео- или CDN-ограничение). 3. **Англоязычные Kia-форумы** — крупные (kia-forums.com, 741K сообщений), но сабфорумы не грузятся с нашего IP (возможно гео- или CDN-ограничение).
4. **Российские автофорумы массово под CloudFlare/DDoS-Guard** — парсинг через requests невозможен. 4. **Российские автофорумы массово под CloudFlare/DDoS-Guard** — парсинг через requests невозможен.
+106
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Парсер phpBB-форума Нива Лада Клуб (нива-лада.рф)."""
+92
View File
@@ -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)
+52
View File
@@ -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 = "нива-лада.рф"
+44
View File
@@ -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
+44
View File
@@ -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
+86
View File
@@ -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}"
+117
View File
@@ -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;">
&nbsp;<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
+207
View File
@@ -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)
+48
View File
@@ -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")
+62
View File
@@ -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