lada_scraper: парсер phpBB форума нива-лада.рф + доку
This commit is contained in:
+1
-1
@@ -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
@@ -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 и т.д.).
|
||||||
@@ -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
@@ -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 невозможен.
|
||||||
|
|||||||
@@ -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