From eca5e3cda6bd7a2324ffbed94e4a35a98b125105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 4 Jun 2026 11:47:02 +0300 Subject: [PATCH] =?UTF-8?q?lada=5Fscraper:=20=D0=BF=D0=B0=D1=80=D1=81?= =?UTF-8?q?=D0=B5=D1=80=20phpBB=20=D1=84=D0=BE=D1=80=D1=83=D0=BC=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B8=D0=B2=D0=B0-=D0=BB=D0=B0=D0=B4=D0=B0.=D1=80=D1=84?= =?UTF-8?q?=20+=20=D0=B4=D0=BE=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- VWTS/README.md | 141 ++++++++++++++++++++++ VWTS/foreign-forums-analysis.md | 80 ++++++++++++ VWTS/forums-survey.md | 33 ++++- lada_scraper/README.md | 106 ++++++++++++++++ lada_scraper/__init__.py | 1 + lada_scraper/__main__.py | 92 ++++++++++++++ lada_scraper/config.py | 52 ++++++++ lada_scraper/fetch.py | 44 +++++++ lada_scraper/output.py | 44 +++++++ lada_scraper/paginate.py | 86 +++++++++++++ lada_scraper/parse.py | 117 ++++++++++++++++++ lada_scraper/run.py | 207 ++++++++++++++++++++++++++++++++ lada_scraper/state.py | 48 ++++++++ lada_scraper/throttle.py | 62 ++++++++++ 15 files changed, 1108 insertions(+), 7 deletions(-) create mode 100644 VWTS/README.md create mode 100644 VWTS/foreign-forums-analysis.md create mode 100644 lada_scraper/README.md create mode 100644 lada_scraper/__init__.py create mode 100644 lada_scraper/__main__.py create mode 100644 lada_scraper/config.py create mode 100644 lada_scraper/fetch.py create mode 100644 lada_scraper/output.py create mode 100644 lada_scraper/paginate.py create mode 100644 lada_scraper/parse.py create mode 100644 lada_scraper/run.py create mode 100644 lada_scraper/state.py create mode 100644 lada_scraper/throttle.py diff --git a/.gitignore b/.gitignore index e600cbf..4e8006d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,4 @@ __pycache__/ # vwts.ru scraper VWTS/ vwts_data/ -vwts_scraper.py \ No newline at end of file +vwts_scraper.pylada_data/ diff --git a/VWTS/README.md b/VWTS/README.md new file mode 100644 index 0000000..7c1c705 --- /dev/null +++ b/VWTS/README.md @@ -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 и т.д.). diff --git a/VWTS/foreign-forums-analysis.md b/VWTS/foreign-forums-analysis.md new file mode 100644 index 0000000..2ae3e1c --- /dev/null +++ b/VWTS/foreign-forums-analysis.md @@ -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 стр (правильно) | diff --git a/VWTS/forums-survey.md b/VWTS/forums-survey.md index 1fe2c1b..8e244b9 100644 --- a/VWTS/forums-survey.md +++ b/VWTS/forums-survey.md @@ -16,20 +16,41 @@ | kianiroforum.com | Kia Niro (en) | XenForo | 🟡 | 79K сообщений, timeout | | mercedes-* (все русские) | Mercedes (ru) | — | ❌ | Все мертвы | | 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/недоступны | | 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 | | vwvortex.com | VW (en) | Кастом | 🟡 | Есть темы, нет pageNav | | 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 с РФ).** Остальные либо под защитой, либо мёртвы. -2. benzworld.org (XenForo, 7.9M сообщений) — возможно доступен через прокси/VPN -3. **Живые форумы на других движках** — потребуют отдельных парсеров: - - phpBB (`viewtopic.php`) — lexus-club.ru +1. **XenForo с открытым доступом — vwts.ru (работает).** Остальные либо под защитой, либо мёртвы. +2. benzworld.org (XenForo, 7.9M сообщений) — блокирует парсинг (HTTP 409) даже с немецкого IP. +3. **Основной новый источник — нива-лада.рф (phpBB).** Крупный, живой, без CloudFlare. Есть парсер в `lada_scraper/`. +4. **Второстепенный — lada.cc/board/ (IPB).** Требует отдельного парсера под Invision Power Board. +5. **Живые форумы на других движках** — потребуют отдельных парсеров: + - phpBB (`viewtopic.php`) — нива-лада.рф ✅ (парсер готов), lexus-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-ограничение). 4. **Российские автофорумы массово под CloudFlare/DDoS-Guard** — парсинг через requests невозможен. diff --git a/lada_scraper/README.md b/lada_scraper/README.md new file mode 100644 index 0000000..e9207d8 --- /dev/null +++ b/lada_scraper/README.md @@ -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/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) diff --git a/lada_scraper/__init__.py b/lada_scraper/__init__.py new file mode 100644 index 0000000..882cd61 --- /dev/null +++ b/lada_scraper/__init__.py @@ -0,0 +1 @@ +"""Парсер phpBB-форума Нива Лада Клуб (нива-лада.рф).""" diff --git a/lada_scraper/__main__.py b/lada_scraper/__main__.py new file mode 100644 index 0000000..c0390bf --- /dev/null +++ b/lada_scraper/__main__.py @@ -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) diff --git a/lada_scraper/config.py b/lada_scraper/config.py new file mode 100644 index 0000000..3d25611 --- /dev/null +++ b/lada_scraper/config.py @@ -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 = "нива-лада.рф" diff --git a/lada_scraper/fetch.py b/lada_scraper/fetch.py new file mode 100644 index 0000000..1a774d8 --- /dev/null +++ b/lada_scraper/fetch.py @@ -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 diff --git a/lada_scraper/output.py b/lada_scraper/output.py new file mode 100644 index 0000000..4ee406a --- /dev/null +++ b/lada_scraper/output.py @@ -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 diff --git a/lada_scraper/paginate.py b/lada_scraper/paginate.py new file mode 100644 index 0000000..11c4f1b --- /dev/null +++ b/lada_scraper/paginate.py @@ -0,0 +1,86 @@ +"""Пагинация phpBB. + +phpBB: пагинация через параметр start=N, где N = (page-1) * items_per_page. + +На странице раздела: + 2 + 3 + ... + 72 + +На странице темы: + 2 + ... +""" + +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}" diff --git a/lada_scraper/parse.py b/lada_scraper/parse.py new file mode 100644 index 0000000..d7a055c --- /dev/null +++ b/lada_scraper/parse.py @@ -0,0 +1,117 @@ +"""Парсинг HTML phpBB: темы и посты. + +Структура phpBB (subsilver2, нива-лада.рф): + +Список тем в разделе: + ... + +Посты в теме (табличная вёрстка subsilver2): + + + AuthorName
+ Role + ... + + +
текст поста
+ ... + + + + +
+  AuthorName » Дата +
+ + +""" + +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 в ): + + ← шапка: автор + дата + + + + ← тело: аватар + текст + + + + ← подвал + + + + """ + 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 diff --git a/lada_scraper/run.py b/lada_scraper/run.py new file mode 100644 index 0000000..a071187 --- /dev/null +++ b/lada_scraper/run.py @@ -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) diff --git a/lada_scraper/state.py b/lada_scraper/state.py new file mode 100644 index 0000000..b56cdbf --- /dev/null +++ b/lada_scraper/state.py @@ -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") diff --git a/lada_scraper/throttle.py b/lada_scraper/throttle.py new file mode 100644 index 0000000..fe46995 --- /dev/null +++ b/lada_scraper/throttle.py @@ -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
+
+ Добавлено: 12 авг 2025, 20:00 +
+
...
текст
Вернуться к началу
...