Compare commits

Author SHA1 Message Date
naeel 6ab90aa312 KIA PDF OCR: 408 pages -> structured JSON (339 sections, 1M chars) 2026-06-09 08:39:43 +04:00
naeel ecb96761e7 ElsaWin RAG: resume ingest with progress tracking + full docs update 2026-06-09 07:42:20 +04:00
naeel 84ae6cfa9d ElsaWin RAG pipeline: full parsing chain + ChromaDB ingest + docs 2026-06-09 06:58:57 +04:00
naeel 89d2301fdf ided 2026-06-08 20:28:51 +04:00
naeel 99e6fad861 vwts: скрипт выборочной загрузки + список разделов 2026-06-06 11:47:54 +04:00
naeel 78505d733e history: запись 002 — sticky-фикс phpBB 2026-06-06 11:47:29 +04:00
naeel 5c142096de scraper: новая архитектура — core/ + forums/ (BaseAdapter, XenForo, phpBB)
- core/ — общий слой: fetch, throttle, output, state, runner
- forums/base.py — абстрактный BaseAdapter
- forums/xenforo/ — адаптер для XenForo (vwts.ru)
- forums/phpbb/ — адаптер для phpBB (нива-лада.рф)
- __main__.py — точка входа CLI
- history/001-initial-structure.md — журнал изменений

Также на ВМ (не в этом коммите):
- lada_scraper/parse.py — пропуск sticky-тем при многопоточном парсинге
2026-06-06 11:46:57 +04:00
naeel 673bfe5e2a .gitignore: фикс vwts_scraper.py + lada_data 2026-06-04 11:47:56 +03:00
naeel eca5e3cda6 lada_scraper: парсер phpBB форума нива-лада.рф + доку 2026-06-04 11:47:02 +03:00
naeel 928c979509 docs: mercedes + обновление forums-survey 2026-06-04 10:50:58 +03:00
naeel c3211f0602 feat: многопоточный парсинг + forum survey
- Throttle: Thread-safe координация задержек
- BanDetector: автоопределение бана
- fetch: thread-local сессии вместо глобальной
- run: ThreadPoolExecutor + диапазоны страниц + мерж
- output/state: поддержка worker-папок и merge_states()
- __main__: --workers, --no-delay, --no-ban-test
- VWTS/forums-survey.md: обзор 20+ автофорумов
2026-06-04 10:48:57 +03:00
58 changed files with 7854 additions and 94 deletions
+1
View File
@@ -13,3 +13,4 @@ __pycache__/
VWTS/ VWTS/
vwts_data/ vwts_data/
vwts_scraper.py vwts_scraper.py
lada_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 стр (правильно) |
+56
View File
@@ -0,0 +1,56 @@
# Обзор автомобильных форумов РФ и мира
Дата: 2026-06-04
Цель: найти живые автофорумы с доступом для парсинга (RAG-база знаний по ремонту).
## Результаты проверки
| Форум | Марка | Движок | Доступ | Примечание |
|-------|-------|--------|--------|------------|
| **vwts.ru** | VW/Audi/Skoda/SEAT | **XenForo** | ✅ | 7 млн+ сообщений, основной источник |
| **benzworld.org** | Mercedes (en) | **XenForo** | 🟡 | 7.9 млн сообщений, 897K тем, но сабфорумы timeout |
| **lexus-club.ru** | Lexus/Toyota | phpBB | ✅ | Большой, активный |
| **renault-club.ru** | Renault | vBulletin 3.6 | ✅ | Активный, 84 раздела |
| ffclub.ru | Ford | Invision Power Board | 🟡 | Жив, другой движок |
| kia-forums.com | Kia (en) | XenForo | 🟡 | 741K сообщений, сабфорумы timeout |
| 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) | 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 (работает).** Остальные либо под защитой, либо мёртвы.
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, lada.cc
3. **Англоязычные Kia-форумы** — крупные (kia-forums.com, 741K сообщений), но сабфорумы не грузятся с нашего IP (возможно гео- или CDN-ограничение).
4. **Российские автофорумы массово под CloudFlare/DDoS-Guard** — парсинг через requests невозможен.
Executable
+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACD0fEtOzJ5LtVZVUf5auoQij6PXzcyfuxNA6BO25L8uKQAAAJhREf6nURH+
pwAAAAtzc2gtZWQyNTUxOQAAACD0fEtOzJ5LtVZVUf5auoQij6PXzcyfuxNA6BO25L8uKQ
AAAED0yPACVVsOjsZeobKiX6F1QX93DMvXpToTI4P47c6l5/R8S07Mnku1VlVR/lq6hCKP
o9fNzJ+7E0DoE7bkvy4pAAAADnRhemV0QG5hcm9kLnJ1AQIDBAUGBw==
-----END OPENSSH PRIVATE KEY-----
Executable
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPR8S07Mnku1VlVR/lq6hCKPo9fNzJ+7E0DoE7bkvy4p tazet@narod.ru
File diff suppressed because it is too large Load Diff
+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
+200
View File
@@ -0,0 +1,200 @@
"""Точка входа CLI для scraper.
Использование:
python -m scraper xenforo URL [--workers N] [--no-delay]
python -m scraper phpbb ID [--base URL] [--workers N]
Примеры:
python -m scraper xenforo https://vwts.ru/forum/vag/benzinovye-dvigateli/
python -m scraper xenforo URL --workers 8
python -m scraper phpbb 22
python -m scraper phpbb 22 --base https://нива-лада.рф/n/
"""
import sys
import logging
from pathlib import Path
# ─── Инициализация логирования ──────────────────────────────────────────
# Должна быть первой, чтобы все логи писались сразу
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
handlers=[
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger(__name__)
def _print_usage():
"""Вывести справку по использованию."""
print("Использование:")
print(" python -m scraper xenforo URL [опции]")
print(" python -m scraper phpbb FORUM_ID [опции]")
print()
print("XenForo (vwts.ru):")
print(" python -m scraper xenforo https://vwts.ru/forum/vag/.../")
print()
print("phpBB (нива-лада.рф):")
print(" python -m scraper phpbb 22")
print(" python -m scraper phpbb 22 --base https://нива-лада.рф/n/")
print()
print("Опции:")
print(" --workers N количество потоков (по умолчанию 4)")
print(" --no-delay без координации задержек")
print(" --no-ban-test не проверять бан")
print(" --data-dir DIR папка для данных (по умолчанию vwts_data / lada_data)")
print(" --base URL базовый URL форума (для phpBB)")
def main():
"""Точка входа: разбор аргументов → запуск нужного адаптера."""
args = sys.argv[1:]
if not args or args[0] in ("-h", "--help"):
_print_usage()
sys.exit(0)
# ── Определяем движок ───────────────────────────────────────────────
engine = args.pop(0)
if engine == "xenforo":
_run_xenforo(args)
elif engine == "phpbb":
_run_phpbb(args)
else:
print(f"❌ Неизвестный движок: {engine}")
print(" Доступно: xenforo, phpbb")
sys.exit(1)
# ═══════════════════════════════════════════════════════════════════════════
# XenForo
# ═══════════════════════════════════════════════════════════════════════════
def _run_xenforo(args: list[str]):
"""Запуск парсинга для XenForo (vwts.ru).
Args:
args: список аргументов после "xenforo".
"""
if not args or args[0].startswith("-"):
print("❌ Укажи URL раздела. Пример:")
print(" python -m scraper xenforo https://vwts.ru/forum/vag/.../")
sys.exit(1)
section_url = args.pop(0).rstrip("/") + "/"
# Парсим опции
workers = 4
no_delay = False
ban_test = True
data_dir = "vwts_data"
i = 0
while i < len(args):
if args[i] == "--workers" and i + 1 < len(args):
workers = int(args[i + 1])
i += 2
elif args[i] == "--no-delay":
no_delay = True
i += 1
elif args[i] == "--no-ban-test":
ban_test = False
i += 1
elif args[i] == "--data-dir" and i + 1 < len(args):
data_dir = args[i + 1]
i += 2
else:
i += 1
# Импортируем адаптер
from .forums.xenforo.adapter import XenForoAdapter
from .core.runner import run
adapter = XenForoAdapter(data_dir=data_dir)
log.info("=" * 60)
log.info("🚀 scraper — XenForo: %s", section_url.split("/")[-2])
log.info("📂 %s", Path(data_dir) / section_url.split("/")[-2] / "part_*.jsonl")
log.info("🛑 Ctrl+C = пауза | Повторить команду = продолжить")
log.info("=" * 60)
try:
run(adapter, section_url,
workers=workers, no_delay=no_delay, ban_test=ban_test)
except KeyboardInterrupt:
log.info("\n🛑 ПАУЗА. Продолжить: та же команда.")
sys.exit(0)
# ═══════════════════════════════════════════════════════════════════════════
# phpBB
# ═══════════════════════════════════════════════════════════════════════════
def _run_phpbb(args: list[str]):
"""Запуск парсинга для phpBB (нива-лада.рф).
Args:
args: список аргументов после "phpbb".
"""
if not args or args[0].startswith("-"):
print("❌ Укажи ID раздела. Пример:")
print(" python -m scraper phpbb 22")
sys.exit(1)
forum_id = args.pop(0)
# Парсим опции
workers = 4
no_delay = False
ban_test = True
data_dir = "lada_data"
forum_base = "https://нива-лада.рф/n/"
i = 0
while i < len(args):
if args[i] == "--workers" and i + 1 < len(args):
workers = int(args[i + 1])
i += 2
elif args[i] == "--no-delay":
no_delay = True
i += 1
elif args[i] == "--no-ban-test":
ban_test = False
i += 1
elif args[i] == "--data-dir" and i + 1 < len(args):
data_dir = args[i + 1]
i += 2
elif args[i] == "--base" and i + 1 < len(args):
forum_base = args[i + 1]
i += 2
else:
i += 1
# Формируем URL раздела
section_url = f"{forum_base}viewforum.php?f={forum_id}"
from .forums.phpbb.adapter import PhpBBAdapter
from .core.runner import run
adapter = PhpBBAdapter(data_dir=data_dir, forum_base=forum_base)
log.info("=" * 60)
log.info("🚀 scraper — phpBB: f=%s", forum_id)
log.info("📂 %s", Path(data_dir) / f"f{forum_id}" / "part_*.jsonl")
log.info("🛑 Ctrl+C = пауза | Повторить команду = продолжить")
log.info("=" * 60)
try:
run(adapter, section_url, section_slug=f"f{forum_id}",
workers=workers, no_delay=no_delay, ban_test=ban_test)
except KeyboardInterrupt:
log.info("\n🛑 ПАУЗА. Продолжить: та же команда.")
sys.exit(0)
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
# scraper.core — общий слой парсера
# Содержит модули, независимые от движка форума.
# См. fetch.py, throttle.py, output.py, state.py, runner.py.
+118
View File
@@ -0,0 +1,118 @@
"""HTTP-запросы: GET с ретраями.
Единая точка вызова HTTP для всех адаптеров.
Не зависит от движка форума.
"""
import time
import logging
import requests
from bs4 import BeautifulSoup
# ─── Модульный логгер ───────────────────────────────────────────────────
log = logging.getLogger(__name__)
# ─── Глобальный throttle (устанавливается в runner.py) ──────────────────
# fetch.get() вызывает throttle.wait() перед каждым запросом.
throttle = None # type: ignore
# ─── Дефолтные значения ─────────────────────────────────────────────────
TIMEOUT = (10, 30) # (connect, read) — requests-таймаут
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"
),
}
RETRIES = 3 # максимум HTTP-попыток на один URL
def make_session() -> requests.Session:
"""Создать HTTP-сессию с дефолтными заголовками.
Returns:
requests.Session с предустановленным User-Agent.
"""
session = requests.Session()
session.headers.update(HEADERS)
return session
def get(url: str, session=None) -> BeautifulSoup | None:
"""GET-запрос с ретраями, парсинг в BeautifulSoup (lxml).
Логика повторных попыток:
1. 404 → сразу None (бесполезно ретраить).
2. 403/429/503 → ждём 10*N сек, потом ретрай.
3. Сеть/Таймаут → ждём 5 сек, потом ретрай.
4. Прочее → ждём 5 сек, потом ретрай.
5. Все RETRIES исчерпаны → None.
Args:
url: Полный URL для загрузки.
session: Сессия (если None — временная).
Returns:
BeautifulSoup или None при недоступности.
"""
# ── Сессия ──────────────────────────────────────────────────────────
if session is None:
session = make_session()
# ── Throttle ────────────────────────────────────────────────────────
if throttle is not None:
throttle.wait()
# ── Цикл ретраев ────────────────────────────────────────────────────
for attempt in range(1, RETRIES + 1):
try:
response = session.get(url, timeout=TIMEOUT)
# 404 — не найдено
if response.status_code == 404:
log.warning(" ⏭️ 404: %s", url[:80])
return None
# 403/429/503 — сервер банит/перегружен
if response.status_code in (403, 429, 503):
wait = 10 * attempt
log.warning(
"⚠️ HTTP %d — попытка %d/%d, жду %d с",
response.status_code, attempt, RETRIES, wait,
)
time.sleep(wait)
continue
# Любая другая HTTP-ошибка
response.raise_for_status()
# Успех
return BeautifulSoup(response.text, "lxml")
except (requests.ConnectionError, requests.Timeout) as exc:
log.warning(
"⚠️ Сеть: %s — попытка %d/%d, жду 5 с",
exc, attempt, RETRIES,
)
time.sleep(5)
except requests.HTTPError as exc:
log.warning(
"⚠️ HTTP %s — попытка %d/%d, жду 5 с",
exc, attempt, RETRIES,
)
time.sleep(5)
except Exception as exc:
log.warning(
"⚠️ Ошибка: %s — попытка %d/%d, жду 5 с",
exc, attempt, RETRIES,
)
time.sleep(5)
# Все попытки исчерпаны
log.error("❌ Не загружено (%d попыток): %s", RETRIES, url[:80])
return None
+134
View File
@@ -0,0 +1,134 @@
"""Запись данных: JSONL с ротацией по N тем на файл.
Формат одной записи (одна тема):
{
"section": str, # Название раздела
"section_id": str, # ID раздела
"topic_id": int, # ID темы на форуме
"topic_title": str, # Заголовок темы
"topic_url": str, # Прямая ссылка на тему
"tags": [str], # Список тегов (если есть)
"total_posts": int, # Количество постов в теме
"posts": [ # Список постов
{
"author": str, # Имя автора
"date": str, # Дата/время поста
"num": str, # Номер поста (#1, #2...)
"text": str, # Текст поста
}
],
"scraped_at": str, # TIMESTAMP в ISO-формате
}
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
log = logging.getLogger(__name__)
class TopicWriter:
"""Потокобезопасная запись тем в JSONL-файлы с ротацией.
Каждый воркер пишет в свою папку (worker_N/).
При достижении TOPICS_PER_FILE — создаётся новый part_*.jsonl.
Атрибуты:
_worker_dir: Путь к папке воркера.
_topics_file: Счётчик записанных тем в текущий файл.
_file_index: Индекс текущего part_*.jsonl.
_handle: Открытый файловый дескриптор.
"""
def __init__(self, worker_dir: Path, topics_per_file: int = 100):
"""Инициализация.
Args:
worker_dir: Папка воркера (worker_N/).
topics_per_file: Сколько тем писать в один файл перед ротацией.
"""
self._worker_dir = worker_dir
self._topics_per_file = topics_per_file
# Счётчики
self._topics_file = 0
self._file_index = 0
# Открываем первый файл
self._handle = self._open_next()
# ── Открытие/закрытие файла ─────────────────────────────────────────
def _open_next(self):
"""Закрыть текущий файл (если есть) и открыть следующий part_N.jsonl."""
# Закрываем предыдущий
if hasattr(self, "_handle") and self._handle and not self._handle.closed:
self._handle.close()
# Новый путь
path = self._worker_dir / f"part_{self._file_index:04d}.jsonl"
# Сбрасываем счётчик тем в файле
self._topics_file = 0
log.info(" 💾 %s", path.name)
fh = open(path, "w", encoding="utf-8")
self._file_index += 1
return fh
def close(self):
"""Закрыть текущий файл."""
if self._handle and not self._handle.closed:
self._handle.close()
# ── Запись ──────────────────────────────────────────────────────────
def write(self,
section_name: str,
section_slug: str,
topic: dict,
posts: list,
tags: list | None = None):
"""Записать одну тему в JSONL.
Если текущий файл заполнен — автоматически создаёт новый.
Args:
section_name: Название раздела (напр. "Бензиновые двигатели").
section_slug: Короткий ID раздела (напр. "benzinovye-dvigateli").
topic: Словарь темы: {id, title, url}.
posts: Список постов: [{author, date, num, text}].
tags: Список тегов (опционально).
"""
# ── Ротация ─────────────────────────────────────────────────────
if self._topics_file >= self._topics_per_file:
self._open_next()
# ── Формируем запись ────────────────────────────────────────────
record = {
"section": section_name,
"section_id": section_slug,
"topic_id": topic["id"],
"topic_title": topic["title"],
"topic_url": topic.get("url", ""),
"tags": tags or [],
"total_posts": len(posts),
"posts": posts,
"scraped_at": datetime.now(timezone.utc).isoformat(),
}
# ── Пишем в файл ────────────────────────────────────────────────
line = json.dumps(record, ensure_ascii=False)
self._handle.write(line + "\n")
self._handle.flush()
self._topics_file += 1
# ── Контекстный менеджер ────────────────────────────────────────────
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
+322
View File
@@ -0,0 +1,322 @@
"""Главный цикл: обход разделов через адаптер.
Единая точка запуска для любого движка форума.
Не содержит кода парсинга — использует адаптер из forums/.
"""
import sys
import logging
import shutil
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from . import fetch as _fetch
from .throttle import Throttle, BanDetector
from .output import TopicWriter
from .state import load, save, merge_workers
log = logging.getLogger(__name__)
# ─── Глобальный throttle (виден fetch.py) ───────────────────────────────
_throttle: Throttle | None = None
# ═══════════════════════════════════════════════════════════════════════════
# Функция одного воркера (запускается в ThreadPoolExecutor)
# ═══════════════════════════════════════════════════════════════════════════
def _worker(section_url: str,
section_slug: str,
section_name: str,
pages: range,
worker_id: int,
adapter) -> tuple:
"""Обойти свой диапазон страниц и сохранить темы в worker_N/.
Args:
section_url: URL раздела (первая страница).
section_slug: Короткое имя для папки (напр. "diagnostika").
section_name: Человеческое название раздела.
pages: Диапазон страниц для этого воркера.
worker_id: Номер воркера (0, 1, 2...).
adapter: Экземпляр адаптера (forums/*/adapter.py).
Returns:
(worker_id, topic_count) — для汇总 в главном потоке.
"""
session = _fetch.make_session()
# ── Состояние воркера ───────────────────────────────────────────────
worker_state = {
"topics_done": {},
"pages_done": [],
"file_index": 0,
"topic_count": 0,
}
consecutive_404 = 0
writer = None
# ── Обход страниц раздела ───────────────────────────────────────────
for page_num in pages:
# Формируем URL страницы через адаптер
url = adapter.page_url(section_url, page_num)
log.info("[W%d] 📄 стр.%d: %s", worker_id, page_num, url[:100])
# Загружаем
soup = _fetch.get(url, session)
if soup is None:
# 404 подряд — вероятно, страницы кончились
consecutive_404 += 1
if consecutive_404 >= 3:
log.info("[W%d] ⏹️ 3 пустых страницы подряд — завершаем", worker_id)
break
continue
consecutive_404 = 0
# Парсим список тем через адаптер
topics = adapter.parse_topics(soup)
log.info("[W%d] Тем на странице: %d", worker_id, len(topics))
# ── Обход тем на странице ────────────────────────────────────────
for idx, topic in enumerate(topics, 1):
topic_id = str(topic["id"])
# Пропускаем уже обработанные
if topic_id in worker_state["topics_done"]:
continue
log.info(
"[W%d] [%d/%d] #%s: %s",
worker_id, idx, len(topics), topic_id, topic["title"][:80],
)
# Загружаем страницу темы (первую)
topic_url = adapter.topic_url(section_url, topic)
topic["url"] = topic_url
topic_soup = _fetch.get(topic_url, session)
if topic_soup is None:
# Не загрузилась — помечаем как сделанную и идём дальше
worker_state["topics_done"][topic_id] = topic["title"]
continue
# Определяем количество страниц в теме
topic_pages = adapter.count_pages(topic_soup)
log.info(
"[W%d] Страниц в теме: %d",
worker_id, topic_pages,
)
# Парсим посты с первой страницы
all_posts = adapter.parse_posts(topic_soup)
# Догружаем остальные страницы темы
for tp in range(2, topic_pages + 1):
tp_url = adapter.topic_page_url(topic_url, tp)
tp_soup = _fetch.get(tp_url, session)
if tp_soup:
all_posts.extend(adapter.parse_posts(tp_soup))
# Парсим теги (если адаптер поддерживает)
tags = adapter.parse_tags(topic_soup) if hasattr(adapter, "parse_tags") else []
log.info(
"[W%d] %d постов, теги: %s",
worker_id, len(all_posts), tags,
)
# ── Сохраняем ────────────────────────────────────────────────
# Лениво инициализируем writer при первой теме
if writer is None:
worker_dir = Path(adapter.data_dir) / section_slug / f"worker_{worker_id}"
worker_dir.mkdir(parents=True, exist_ok=True)
writer = TopicWriter(worker_dir, adapter.topics_per_file)
writer.write(section_name, section_slug, topic, all_posts, tags)
worker_state["topics_done"][topic_id] = topic["title"]
worker_state["topic_count"] += 1
# Помечаем страницу как пройденную
worker_state["pages_done"].append(page_num)
# ── Сохраняем состояние воркера ─────────────────────────────────────
if writer is not None:
writer.close()
worker_dir = Path(adapter.data_dir) / section_slug / f"worker_{worker_id}"
worker_dir.mkdir(parents=True, exist_ok=True)
state_file = worker_dir / "state.json"
state_file.write_text(
json.dumps(worker_state, ensure_ascii=False, indent=2),
encoding="utf-8",
)
session.close()
return worker_id, worker_state["topic_count"]
# ═══════════════════════════════════════════════════════════════════════════
# Функция мержа воркеров
# ═══════════════════════════════════════════════════════════════════════════
def _merge_workers(adapter, section_slug: str):
"""Собрать part_*.jsonl из worker_N/ в корень раздела.
Переименовывает файлы с единой нумерацией, удаляет пустые
worker-папки, мержит state.json.
Args:
adapter: Экземпляр адаптера.
section_slug: Короткое имя раздела.
"""
root = Path(adapter.data_dir) / section_slug
# ── Собираем все part_*.jsonl из worker_N/ ──────────────────────────
all_parts = []
for worker_dir in sorted(root.glob("worker_*")):
if worker_dir.is_dir():
for part_file in sorted(worker_dir.glob("part_*.jsonl")):
all_parts.append(part_file)
if not all_parts:
log.warning("⚠️ Нет файлов для мержа в %s", root)
return
log.info(
"🔗 Мерж %d файлов из %d воркеров → %s",
len(all_parts), len(list(root.glob("worker_*"))), root,
)
# ── Переименовываем с единой нумерацией ─────────────────────────────
for new_index, src in enumerate(all_parts):
dst = root / f"part_{new_index:04d}.jsonl"
shutil.move(str(src), str(dst))
# ── Удаляем пустые папки воркеров ───────────────────────────────────
for worker_dir in root.glob("worker_*"):
if worker_dir.is_dir():
try:
worker_dir.rmdir() # удаляет только пустую папку
except OSError:
pass # если не пустая — оставляем
# ── Мержим state.json ───────────────────────────────────────────────
merge_workers(adapter.data_dir, section_slug)
log.info("✅ Мерж завершён: %d файлов → %s", len(all_parts), root)
# ═══════════════════════════════════════════════════════════════════════════
# Публичный запуск
# ═══════════════════════════════════════════════════════════════════════════
def run(adapter,
section_url: str,
section_slug: str | None = None,
workers: int = 4,
no_delay: bool = False,
ban_test: bool = True):
"""Главный цикл: многопоточный обход раздела через адаптер.
Порядок работы:
1. Проверка бана (BanDetector).
2. Загрузка первой страницы для определения названия и пагинации.
3. Разбиение страниц на диапазоны по числу воркеров.
4. Запуск ThreadPoolExecutor.
5. Мерж результатов.
Args:
adapter: Экземпляр адаптера (forums/*/adapter.py).
section_url: URL раздела (первая страница).
section_slug: Короткое имя (если None — берётся из URL).
workers: Количество потоков.
no_delay: True — отключить координацию задержек.
ban_test: True — проверить бан перед стартом.
"""
# ── Определяем slug из URL ──────────────────────────────────────────
if section_slug is None:
section_slug = section_url.rstrip("/").split("/")[-1]
# ── Настройка throttle ──────────────────────────────────────────────
throttle_enabled = not no_delay
# Если workers=1, координация не нужна — один поток не конфликтует
if workers <= 1:
throttle_enabled = False
# Проверка бана
if ban_test and throttle_enabled and workers > 1:
if BanDetector.test(section_url, adapter.ban_test_count):
throttle_enabled = True
else:
throttle_enabled = False
# Устанавливаем глобальный throttle
global _throttle
_throttle = Throttle(adapter.delay, enabled=throttle_enabled)
_fetch.throttle = _throttle
log.info(
"⚙️ Потоков: %d, координация: %s",
workers, "вкл" if throttle_enabled else "выкл",
)
# ── Определяем название раздела и число страниц ─────────────────────
session = _fetch.make_session()
soup = _fetch.get(section_url, session)
if soup is None:
log.error("❌ Сайт недоступен: %s", section_url)
sys.exit(1)
# Название раздела (из h1 или из URL)
section_name = adapter.parse_section_name(soup) or section_slug
# Количество страниц в разделе
total_pages = adapter.count_pages(soup)
log.info("📋 %s | страниц: %d", section_name, total_pages)
session.close()
# ── Разбиваем страницы на диапазоны для воркеров ────────────────────
if workers > total_pages:
workers = max(1, 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("📦 Диапазоны: %s", [f"{r.start}-{r[-1]}" for r in ranges])
# ── Запуск воркеров ─────────────────────────────────────────────────
total_topics = 0
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [
pool.submit(_worker, section_url, section_slug, section_name,
rng, wid, adapter)
for wid, rng in enumerate(ranges)
]
for future in as_completed(futures):
wid, cnt = future.result()
total_topics += cnt
log.info("[W%d] ✅ завершён: %d тем", wid, cnt)
# ── Мерж ────────────────────────────────────────────────────────────
_merge_workers(adapter, section_slug)
# ── Итог ────────────────────────────────────────────────────────────
data_dir = Path(adapter.data_dir)
part_files = sorted((data_dir / section_slug).glob("part_*.jsonl"))
total_size_mb = sum(f.stat().st_size for f in part_files) / (1024 * 1024)
log.info("=" * 60)
log.info(
"'%s'%d тем в %d частях (%.1f MB)",
section_name, total_topics, len(part_files), total_size_mb,
)
log.info("=" * 60)
+114
View File
@@ -0,0 +1,114 @@
"""Управление состоянием: state.json в папке раздела.
Позволяет:
- Сохранять прогресс (какие темы обработаны, какие страницы пройдены).
- Продолжать парсинг с места остановки (той же командой).
- Мержить состояния воркеров в единый state.json (после завершения).
Файл state.json (раздел):
{
"topics_done": {"123": "Название темы", "456": "..."},
"pages_done": [1, 2, 3],
"file_index": 5,
"topic_count": 432
}
Файл worker_N/state.json:
Аналогичная структура, но только для одного воркера.
"""
import json
import logging
from pathlib import Path
log = logging.getLogger(__name__)
def _state_file(data_dir: Path, section_slug: str) -> Path:
"""Полный путь к state.json для раздела.
Args:
data_dir: Корневая папка с данными (напр. ./vwts_data).
section_slug: Короткое имя раздела.
Returns:
Path к state.json.
"""
section_dir = data_dir / section_slug
section_dir.mkdir(parents=True, exist_ok=True)
return section_dir / "state.json"
def load(data_dir: Path, section_slug: str) -> dict:
"""Загрузить state.json раздела.
Args:
data_dir: Корневая папка с данными.
section_slug: Короткое имя раздела.
Returns:
Словарь состояния. Если файла нет — пустой state.
"""
path = _state_file(data_dir, section_slug)
if path.exists():
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
log.warning("⚠️ Ошибка чтения %s: %s", path, exc)
# Пустой state по умолчанию
return {"topics_done": {}, "pages_done": [], "file_index": 0, "topic_count": 0}
def save(data_dir: Path, section_slug: str, state: dict):
"""Сохранить state.json раздела.
Args:
data_dir: Корневая папка с данными.
section_slug: Короткое имя раздела.
state: Словарь состояния для сохранения.
"""
path = _state_file(data_dir, section_slug)
path.write_text(
json.dumps(state, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def merge_workers(data_dir: Path, section_slug: str):
"""Собрать состояния всех воркеров в единый state.json раздела.
Проходит по worker_*/state.json, сливает topics_done, pages_done,
суммирует topic_count.
Args:
data_dir: Корневая папка с данными.
section_slug: Короткое имя раздела.
"""
root = data_dir / section_slug
# ── Собираем все worker_*/state.json ────────────────────────────────
merged = {"topics_done": {}, "pages_done": [], "file_index": 0, "topic_count": 0}
for worker_state_path in sorted(root.glob("worker_*/state.json")):
try:
worker_state = json.loads(
worker_state_path.read_text(encoding="utf-8"),
)
merged["topics_done"].update(worker_state.get("topics_done", {}))
merged["pages_done"].extend(worker_state.get("pages_done", []))
merged["topic_count"] += worker_state.get("topic_count", 0)
except (json.JSONDecodeError, OSError) as exc:
log.warning("⚠️ Ошибка чтения %s: %s", worker_state_path, exc)
# Чистим дубликаты и сортируем
merged["pages_done"] = sorted(set(merged["pages_done"]))
# Определяем следующий file_index по количеству part_*.jsonl
existing_parts = list(root.glob("part_*.jsonl"))
merged["file_index"] = max(0, len(existing_parts) - 1)
# ── Пишем ───────────────────────────────────────────────────────────
save(data_dir, section_slug, merged)
log.info("🔗 Мерж состояний: %d воркеров → state.json", len(list(root.glob("worker_*"))))
+147
View File
@@ -0,0 +1,147 @@
"""Координация задержек между потоками + автоопределение бана.
Throttle:
Thread-safe координатор: максимум 1 запрос в DELAY секунд.
При отключённой координации — просто time.sleep(DELAY).
BanDetector:
Проверяет, банит ли сервер за быстрые запросы.
Шлёт COUNT запросов подряд без задержки, ищет 403/429.
"""
import time
import threading
import logging
import requests
log = logging.getLogger(__name__)
class Throttle:
"""Координатор задержек между потоками.
Позволяет запускать N потоков, но реальные HTTP-запросы
идут не чаще 1 в delay секунд. Потоки ждут в очереди
через threading.Lock.
Атрибуты:
_delay: Минимальный интервал между запросами (сек).
_enabled: True — координация включена, False — только sleep.
_lock: Мьютекс для потокобезопасности.
_last: Монотонное время последнего запроса.
"""
def __init__(self, delay: float = 1.5, enabled: bool = True):
"""Инициализация.
Args:
delay: Минимальный интервал между запросами (сек).
enabled: Если False — вместо координации просто sleep(delay).
"""
self._delay = delay
self._enabled = enabled
self._lock = threading.Lock()
self._last = 0.0 # time.monotonic() последнего запроса
# ── Свойства ────────────────────────────────────────────────────────
@property
def delay(self) -> float:
"""Текущая задержка."""
return self._delay
@property
def enabled(self) -> bool:
"""Включена ли координация."""
return self._enabled
# ── Основной метод ──────────────────────────────────────────────────
def wait(self):
"""Подождать своей очереди на запрос.
Если координация ВЫКЛЮЧЕНА:
Просто спим delay секунд.
Если координация ВКЛЮЧЕНА:
Захватываем мьютекс, считаем сколько прошло с _last,
если меньше delay — спим разницу, обновляем _last.
"""
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
def enable(self):
"""Включить координацию."""
self._enabled = True
class BanDetector:
"""Определяет, банит ли сервер за быстрые запросы.
Статический метод test() шлёт count запросов подряд
(без задержек) и смотрит ответы. Если хоть один 403/429 —
сервер банит.
"""
@staticmethod
def test(base_url: str, count: int = 5) -> bool:
"""Проверить бан быстрыми запросами.
Args:
base_url: Любой URL того же хоста (главная раздела).
count: Сколько запросов сделать.
Returns:
True — сервер банит (есть 403/429),
False — бан не обнаружен.
"""
log.info("🔍 Проверка бана: %d запросов подряд...", count)
session = requests.Session()
session.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36"
),
})
banned = False
for i in range(1, count + 1):
try:
response = session.get(base_url, timeout=(10, 30))
log.info(" [%d/%d] HTTP %d", i, count, response.status_code)
# Сервер явно банит
if response.status_code in (403, 429):
banned = True
break
except Exception as exc:
# Сетевая ошибка — вероятно, бан на уровне соединения
log.warning(" [%d/%d] ошибка: %s", i, count, exc)
banned = True
break
session.close()
if banned:
log.warning("⚠️ Сервер банит быстрые запросы — включаю координацию")
else:
log.info("✅ Бан не обнаружен — без координации")
return banned
+3
View File
@@ -0,0 +1,3 @@
# scraper.forums — адаптеры под разные движки форумов
# Каждый пакет реализует BaseAdapter.
# См. base.py, xenforo/, phpbb/
+179
View File
@@ -0,0 +1,179 @@
"""Абстрактный базовый класс адаптера форума.
Каждый движок (XenForo, phpBB, ...) реализует этот интерфейс.
core/runner.py вызывает эти методы, ничего не зная о конкретном движке.
Методы для обязательной реализации:
count_pages(soup) -> int
parse_topics(soup) -> list[dict]
parse_posts(soup) -> list[dict]
parse_section_name(soup) -> str | None
page_url(section_url, page_num) -> str
topic_url(section_url, topic) -> str
topic_page_url(topic_url, page_num) -> str
Опционально:
parse_tags(soup) -> list[str]
Атрибуты (должны быть определены в конструкторе адаптера):
data_dir: Path — куда сохранять данные.
delay: float — задержка между запросами (сек).
ban_test_count: int — сколько запросов для проверки бана.
topics_per_file: int — сколько тем в один JSONL-файл.
"""
from abc import ABC, abstractmethod
class BaseAdapter(ABC):
"""Интерфейс адаптера форума.
Все методы, кроме data_dir, delay и т.д., получают
BeautifulSoup-объект и возвращают структурированные данные.
"""
# ─── Атрибуты конфигурации ──────────────────────────────────────────
@property
@abstractmethod
def data_dir(self):
"""Path: корневая директория для сохранения данных."""
...
@property
@abstractmethod
def delay(self) -> float:
"""float: задержка между запросами в секундах."""
...
@property
@abstractmethod
def ban_test_count(self) -> int:
"""int: количество быстрых запросов для проверки бана."""
...
@property
@abstractmethod
def topics_per_file(self) -> int:
"""int: сколько тем писать в один JSONL-файл."""
...
# ─── Определение количества страниц ─────────────────────────────────
@abstractmethod
def count_pages(self, soup) -> int:
"""Сколько страниц в разделе (или теме).
Args:
soup: BeautifulSoup первой страницы раздела/темы.
Returns:
int: количество страниц (минимум 1).
"""
...
# ─── Парсинг списка тем ─────────────────────────────────────────────
@abstractmethod
def parse_topics(self, soup) -> list[dict]:
"""Распарсить список тем со страницы раздела.
Args:
soup: BeautifulSoup страницы раздела.
Returns:
list[dict]: каждая тема — словарь с ключами:
- id: int — ID темы на форуме.
- title: str — заголовок темы.
- url: str | None — URL темы (если известен).
"""
...
# ─── Парсинг постов ─────────────────────────────────────────────────
@abstractmethod
def parse_posts(self, soup) -> list[dict]:
"""Распарсить посты со страницы темы.
Args:
soup: BeautifulSoup страницы темы.
Returns:
list[dict]: каждый пост — словарь с ключами:
- author: str — имя автора.
- date: str — дата/время поста.
- num: str — номер поста (#1, #2...).
- text: str — текст поста.
"""
...
# ─── Парсинг названия раздела ───────────────────────────────────────
@abstractmethod
def parse_section_name(self, soup) -> str | None:
"""Извлечь название раздела из HTML.
Args:
soup: BeautifulSoup страницы раздела.
Returns:
str | None: название (напр. "Бензиновые двигатели")
или None, если не удалось определить.
"""
...
# ─── Формирование URL ───────────────────────────────────────────────
@abstractmethod
def page_url(self, section_url: str, page_num: int) -> str:
"""Сформировать URL страницы раздела.
Args:
section_url: URL раздела (первая страница).
page_num: Номер страницы (1-based).
Returns:
str: полный URL страницы.
"""
...
@abstractmethod
def topic_url(self, section_url: str, topic: dict) -> str:
"""Сформировать полный URL темы.
Args:
section_url: URL раздела.
topic: Словарь темы из parse_topics().
Returns:
str: полный URL темы.
"""
...
@abstractmethod
def topic_page_url(self, topic_url: str, page_num: int) -> str:
"""Сформировать URL страницы внутри темы.
Args:
topic_url: Полный URL темы.
page_num: Номер страницы (1-based, начиная с 2).
Returns:
str: полный URL страницы темы.
"""
...
# ─── Опционально: теги ──────────────────────────────────────────────
def parse_tags(self, soup) -> list[str]:
"""Распарсить теги темы (если движок поддерживает).
По умолчанию — пустой список.
Args:
soup: BeautifulSoup страницы темы.
Returns:
list[str]: список тегов.
"""
return []
+1
View File
@@ -0,0 +1 @@
# scraper.forums.phpbb — адаптер для форумов на phpBB (нива-лада.рф и др.)
+193
View File
@@ -0,0 +1,193 @@
"""Адаптер для форумов на phpBB (нива-лада.рф и др.).
Настройки задаются в конструкторе:
data_dir: куда сохранять.
forum_base: базовый URL форума (напр. https://нива-лада.рф/n/).
topics_per_page: сколько тем на странице раздела.
posts_per_page: сколько постов на странице темы.
delay: задержка между запросами.
ban_test: сколько запросов для проверки бана.
topics_file: сколько тем в один JSONL.
"""
import re
from pathlib import Path
from urllib.parse import urljoin
from ..base import BaseAdapter
from . import paginate, parse
class PhpBBAdapter(BaseAdapter):
"""Адаптер для форума phpBB.
Параметризуется под любой phpBB-форум:
достаточно указать forum_base и per_page-значения.
"""
def __init__(self,
data_dir: str | Path = "lada_data",
forum_base: str = "https://нива-лада.рф/n/",
topics_per_page: int = 25,
posts_per_page: int = 10):
"""Инициализация адаптера phpBB.
Args:
data_dir: Папка для данных.
forum_base: Базовый URL форума (с / на конце).
topics_per_page: Тем на одной странице раздела.
posts_per_page: Постов на одной странице темы.
"""
self._data_dir = Path(data_dir)
self._forum_base = forum_base.rstrip("/") + "/"
self._topics_per_page = topics_per_page
self._posts_per_page = posts_per_page
# ─── Атрибуты конфигурации ──────────────────────────────────────────
@property
def data_dir(self) -> Path:
return self._data_dir
@property
def delay(self) -> float:
return 1.5
@property
def ban_test_count(self) -> int:
return 5
@property
def topics_per_file(self) -> int:
return 100
@property
def topics_per_page(self) -> int:
return self._topics_per_page
@property
def posts_per_page(self) -> int:
return self._posts_per_page
# ─── Вспомогательные ────────────────────────────────────────────────
def _abs_url(self, path_or_url: str) -> str:
"""Преобразовать относительный URL в абсолютный.
Если path_or_url уже абсолютный — возвращаем как есть.
Иначе — присоединяем к forum_base.
Args:
path_or_url: URL или относительный путь.
Returns:
str: абсолютный URL.
"""
if path_or_url.startswith("http"):
# Убираем sid из URL
clean = re.sub(r"[?&]sid=[^&]+", "", path_or_url)
# Нормализуем http → https
clean = clean.replace("http://", "https://")
return clean
# Относительный — присоединяем к базе
clean = re.sub(r"[?&]sid=[^&]+", "", path_or_url)
return urljoin(self._forum_base, clean)
# ─── Пагинация раздела ──────────────────────────────────────────────
def count_pages(self, soup) -> int:
"""Количество страниц в разделе.
Args:
soup: BeautifulSoup страницы раздела.
Returns:
int: количество страниц.
"""
return paginate.count_pages(soup, self._topics_per_page)
def page_url(self, section_url: str, page_num: int) -> str:
"""URL страницы раздела.
section_url — это viewforum.php?f=ID (первая страница).
Args:
section_url: URL раздела (первая страница).
page_num: Номер страницы.
Returns:
str: URL страницы с ?start= (если page>1).
"""
return paginate.page_url(section_url, page_num, self._topics_per_page)
# ─── Пагинация темы ─────────────────────────────────────────────────
def topic_url(self, section_url: str, topic: dict) -> str:
"""Полный URL темы.
Если у темы есть url — нормализуем его.
Иначе формируем из ID.
Args:
section_url: URL раздела.
topic: Словарь темы.
Returns:
str: полный URL темы.
"""
if topic.get("url"):
return self._abs_url(topic["url"])
# Вытаскиваем f=ID из section_url
f_match = re.search(r"[?&]f=(\d+)", section_url)
f_id = f_match.group(1) if f_match else "0"
return self._abs_url(f"./viewtopic.php?f={f_id}&t={topic['id']}")
def topic_page_url(self, topic_url: str, page_num: int) -> str:
"""URL страницы темы.
Args:
topic_url: Полный URL темы.
page_num: Номер страницы.
Returns:
str: URL страницы с ?start=.
"""
return paginate.topic_page_url(topic_url, page_num, self._posts_per_page)
# ─── Парсинг ────────────────────────────────────────────────────────
def parse_section_name(self, soup) -> str | None:
"""Название раздела.
Args:
soup: BeautifulSoup страницы раздела.
Returns:
str | None: название.
"""
return parse.parse_section_name(soup)
def parse_topics(self, soup) -> list[dict]:
"""Список тем со страницы раздела.
Args:
soup: BeautifulSoup.
Returns:
list[dict]: [{id, title, url}].
"""
return parse.parse_topics(soup)
def parse_posts(self, soup) -> list[dict]:
"""Посты со страницы темы.
Args:
soup: BeautifulSoup страницы темы.
Returns:
list[dict]: [{author, date, num, text}].
"""
return parse.parse_posts(soup)
+110
View File
@@ -0,0 +1,110 @@
"""Пагинация phpBB.
phpBB использует параметр ?start=N для пагинации, где:
N = (page - 1) * items_per_page
Страницы раздела:
viewforum.php?f=22&start=25 ← страница 2
viewforum.php?f=22&start=50 ← страница 3
...
Страницы темы:
viewtopic.php?f=22&t=123&start=10 ← страница 2
viewtopic.php?f=22&t=123&start=20 ← страница 3
...
HTML-структура пагинации:
<ul class="pagination">
<li><a href="./viewforum.php?f=22&start=25">2</a></li>
<li><a href="./viewforum.php?f=22&start=50">3</a></li>
...
<li><a href="./viewforum.php?f=22&start=1800">72</a></li>
</ul>
"""
import re
import logging
from bs4 import BeautifulSoup
log = logging.getLogger(__name__)
def count_pages(soup: BeautifulSoup, per_page: int) -> int:
"""Определить количество страниц в пагинации phpBB.
Алгоритм:
1. Ищем ul.pagination (или ul.page-nav).
2. Собираем все start=N из href.
3. Находим max_start.
4. Вычисляем: pages = max_start / per_page + 1.
Args:
soup: BeautifulSoup страницы раздела или темы.
per_page: Количество элементов на странице (темы=25, посты=10).
Returns:
int: количество страниц (минимум 1).
"""
# Ищем контейнер пагинации (два варианта класса)
nav = soup.find("ul", class_="pagination")
if nav is None:
nav = soup.find("ul", class_="page-nav")
if nav is None:
return 1
# Собираем все start=N из ссылок
starts = set()
for link in nav.select("a"):
href = link.get("href", "")
match = re.search(r"[?&]start=(\d+)", href)
if match:
starts.add(int(match.group(1)))
if not starts:
return 1
max_start = max(starts)
pages = (max_start // per_page) + 1
return pages
def page_url(base_url: str, page_num: int, per_page: int) -> str:
"""Сформировать URL страницы раздела phpBB.
Формат:
page=1 → base_url (без ?start=)
page>1 → base_url + &start=N
Args:
base_url: URL страницы без параметра start (напр. viewforum.php?f=22).
page_num: Номер страницы (1-based).
per_page: Количество элементов на страницу.
Returns:
str: URL страницы с ?start= (если page>1).
"""
if page_num <= 1:
return base_url
start = (page_num - 1) * per_page
separator = "&" if "?" in base_url else "?"
return f"{base_url}{separator}start={start}"
def topic_page_url(topic_url: str, page_num: int, per_page: int) -> str:
"""Сформировать URL страницы темы phpBB.
Args:
topic_url: Полный URL темы.
page_num: Номер страницы (1-based, начиная с 2).
per_page: Количество постов на страницу.
Returns:
str: URL страницы темы с ?start=.
"""
if page_num <= 1:
return topic_url
start = (page_num - 1) * per_page
separator = "&" if "?" in topic_url else "?"
return f"{topic_url}{separator}start={start}"
+142
View File
@@ -0,0 +1,142 @@
"""Парсинг HTML форума phpBB (subsilver2, нива-лада.рф).
Структуры:
Список тем в разделе (subsilver2):
<a href="./viewtopic.php?f=22&t=123" class="topictitle">Заголовок</a>
Пост в теме (табличная вёрстка subsilver2):
<tr class="row1"> ← строка: автор + дата
<td><b class="postauthor">Автор</b></td>
<td width="100%">
<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>
"""
import re
import logging
from bs4 import BeautifulSoup
log = logging.getLogger(__name__)
def parse_topics(soup: BeautifulSoup) -> list[dict]:
"""Распарсить список тем со страницы раздела phpBB.
Ищем все a.topictitle → из href вытаскиваем t=ID.
Args:
soup: BeautifulSoup страницы раздела.
Returns:
list[dict]: [{id, title, url}].
url может быть None (будет сформирован адаптером).
"""
items = []
for link in soup.select("a.topictitle"):
href = link.get("href", "")
# Из href вытаскиваем t=ID
match = re.search(r"[?&]t=(\d+)", href)
if match is None:
continue
topic_id = int(match.group(1))
title = link.text.strip()
items.append({
"id": topic_id,
"title": title,
"url": None, # адаптер сформирует сам
})
return items
def parse_posts(soup: BeautifulSoup) -> list[dict]:
"""Распарсить посты со страницы темы phpBB (subsilver2).
Каждый пост — это 3 <tr> в таблице. Ищем b.postauthor
как маркер начала поста, затем:
- Тот же <tr>: дата из "Добавлено:"
- Следующий <tr>: текст из div.postbody
Args:
soup: BeautifulSoup страницы темы.
Returns:
list[dict]: [{author, date, num, text}, ...].
"""
posts = []
post_num = 0
for author_tag in soup.select("b.postauthor"):
post_num += 1
author = author_tag.text.strip()
# ── Дата: ищем "Добавлено:" в том же <tr> ───────────────────────
date = ""
header_row = author_tag.find_parent("tr")
if header_row is not None:
date_cell = header_row.select_one("td[width='100%']")
if date_cell is not None:
date_text = date_cell.get_text(" ", strip=True)
date_match = re.search(r"Добавлено:\s*(.+)", date_text)
if date_match:
date = date_match.group(1).strip()
# ── Текст: ищем div.postbody в следующем <tr> ───────────────────
text = ""
if header_row is not None:
body_row = header_row.find_next_sibling("tr")
if body_row is not None:
body_div = body_row.select_one("div.postbody")
if body_div is not None:
text = body_div.get_text("\n", strip=True)
posts.append({
"author": author,
"date": date,
"num": f"#{post_num}",
"text": text,
})
return posts
def parse_section_name(soup: BeautifulSoup) -> str | None:
"""Извлечь название раздела из заголовка phpBB.
phpBB обычно показывает название в h2 или в breadcrumbs.
Args:
soup: BeautifulSoup страницы раздела.
Returns:
str | None: название раздела.
"""
# Пробуем h2 (основной заголовок страницы)
h2 = soup.select_one("h2")
if h2 is not None:
text = h2.text.strip()
if text:
return text
# Пробуем title страницы
title_tag = soup.select_one("title")
if title_tag is not None:
text = title_tag.text.strip()
# Чистим от названия сайта
text = re.sub(r"\s*\s*.*$", "", text).strip()
if text:
return text
return None
+1
View File
@@ -0,0 +1 @@
# scraper.forums.xenforo — адаптер для форумов на XenForo (vwts.ru)
+146
View File
@@ -0,0 +1,146 @@
"""Адаптер для форумов на XenForo (vwts.ru).
Настройки:
data_dir: куда сохранять данные (по умолчанию ./vwts_data).
delay: 1.5 сек между запросами.
ban_test: 5 быстрых запросов для проверки бана.
topics_file: 100 тем на один JSONL-файл.
"""
from pathlib import Path
from ..base import BaseAdapter
from . import paginate, parse
class XenForoAdapter(BaseAdapter):
"""Адаптер для форума XenForo (vwts.ru).
Использует модули paginate.py и parse.py из этого пакета.
"""
def __init__(self, data_dir: str | Path = "vwts_data"):
"""Инициализация.
Args:
data_dir: Путь к папке с данными (строка или Path).
"""
self._data_dir = Path(data_dir)
# ─── Атрибуты конфигурации ──────────────────────────────────────────
@property
def data_dir(self) -> Path:
return self._data_dir
@property
def delay(self) -> float:
return 1.5
@property
def ban_test_count(self) -> int:
return 5
@property
def topics_per_file(self) -> int:
return 100
# ─── Пагинация ──────────────────────────────────────────────────────
def count_pages(self, soup) -> int:
"""Количество страниц в разделе/теме XenForo.
Args:
soup: BeautifulSoup первой страницы.
Returns:
int: количество страниц.
"""
return paginate.count_pages(soup)
def page_url(self, section_url: str, page_num: int) -> str:
"""URL страницы раздела XenForo.
Args:
section_url: URL раздела (первая страница).
page_num: Номер страницы.
Returns:
str: URL страницы.
"""
return paginate.page_url(section_url, page_num)
def topic_url(self, section_url: str, topic: dict) -> str:
"""Полный URL темы XenForo.
Приоритет: topic['url'] (если есть),
иначе формируем из ID.
Args:
section_url: URL раздела (не используется в XenForo).
topic: Словарь темы.
Returns:
str: полный URL темы.
"""
if topic.get("url"):
return topic["url"]
return f"https://vwts.ru/forum/topic/{topic['id']}/"
def topic_page_url(self, topic_url: str, page_num: int) -> str:
"""URL страницы внутри темы XenForo.
Args:
topic_url: Полный URL темы.
page_num: Номер страницы.
Returns:
str: URL страницы темы.
"""
return paginate.topic_page_url(topic_url, page_num)
# ─── Парсинг ────────────────────────────────────────────────────────
def parse_section_name(self, soup) -> str | None:
"""Название раздела из h1.
Args:
soup: BeautifulSoup страницы раздела.
Returns:
str | None: название раздела.
"""
return parse.parse_section_name(soup)
def parse_topics(self, soup) -> list[dict]:
"""Список тем со страницы раздела.
Args:
soup: BeautifulSoup страницы раздела.
Returns:
list[dict]: [{id, title, url}].
"""
return parse.parse_topics(soup)
def parse_posts(self, soup) -> list[dict]:
"""Список постов со страницы темы.
Args:
soup: BeautifulSoup страницы темы.
Returns:
list[dict]: [{author, date, num, text}].
"""
return parse.parse_posts(soup)
def parse_tags(self, soup) -> list[str]:
"""Теги темы XenForo.
Args:
soup: BeautifulSoup страницы темы.
Returns:
list[str]: теги (может быть пусто).
"""
return parse.parse_tags(soup)
+109
View File
@@ -0,0 +1,109 @@
"""Пагинация XenForo (vwts.ru и другие форумы на XenForo).
Страницы раздела: /forum/vag/benzinovye-dvigateli/page-2
Страницы темы: /forum/topic/12345/page-2
HTML-структура пагинации:
<div class="pageNav">
<a>1</a> ← текущая (без href)
<a href="page-2">2</a> ← соседняя
<a>…</a> ← эллипсис (пропускаем)
<a href="page-218">218</a> ← последняя
</div>
Все номера страниц видны на любой странице раздела.
Кнопки <button> не трогаем — ищем только <a>.
"""
import re
import logging
from bs4 import BeautifulSoup
log = logging.getLogger(__name__)
def count_pages(soup: BeautifulSoup) -> int:
"""Определить количество страниц в пагинации XenForo.
Ищем:
<div class="pageNav"> → все <a>
Из href вытаскиваем /page-N
Из текста — числа
Возвращаем максимум
Args:
soup: BeautifulSoup страницы раздела или темы.
Returns:
int: количество страниц (минимум 1).
"""
nav = soup.select_one("div.pageNav")
# Нет пагинации — всего 1 страница
if nav is None:
return 1
nums = set()
# Проходим по всем ссылкам внутри пагинации
for link in nav.select("a"):
href = link.get("href", "")
text = link.text.strip()
# Из href вытаскиваем page-N
match = re.search(r"page-(\d+)", href)
if match:
nums.add(int(match.group(1)))
# Из текста — тоже числа (текущая страница без href)
try:
nums.add(int(text))
except ValueError:
# "…", "Назад", "Вперёд" — пропускаем
pass
# Защита от пустой пагинации
return max(nums) if nums else 1
def page_url(section_url: str, page_num: int) -> str:
"""Сформировать URL страницы раздела XenForo.
Формат:
page-1 (первая) → сам section_url
page-N (N>1) → section_url + page-N
Args:
section_url: URL раздела (с / на конце).
page_num: Номер страницы (1-based).
Returns:
str: полный URL страницы.
"""
if page_num <= 1:
return section_url
# Убеждаемся что URL заканчивается на /
base = section_url.rstrip("/") + "/"
return f"{base}page-{page_num}"
def topic_page_url(topic_url: str, page_num: int) -> str:
"""Сформировать URL страницы темы XenForo.
Формат:
page-1 → сам topic_url
page-N → topic_url + page-N
Args:
topic_url: Полный URL темы.
page_num: Номер страницы (1-based).
Returns:
str: URL страницы темы.
"""
if page_num <= 1:
return topic_url
base = topic_url.rstrip("/") + "/"
return f"{base}page-{page_num}"
+163
View File
@@ -0,0 +1,163 @@
"""Парсинг HTML форума XenForo (vwts.ru).
Структуры:
Список тем в разделе:
<div class="structItem"> ← контейнер темы
<div class="structItem-title">
<a href="/forum/topic/12345/">Заголовок темы</a>
</div>
</div>
Пост в теме:
<article class="message"> ← контейнер поста
<h4 class="message-name">Автор</h4>
<time class="u-dt" datetime="2024-01-15T10:00:00Z">...</time>
<ul class="message-attribution-opposite">
<li><a>#1</a></li> ← номер поста
</ul>
<div class="message-content"> ← тело поста
...
</div>
</article>
Теги темы:
<li class="tagItem">
<a>Название тега</a>
</li>
"""
import re
import logging
from bs4 import BeautifulSoup
log = logging.getLogger(__name__)
def parse_topics(soup: BeautifulSoup) -> list[dict]:
"""Распарсить список тем со страницы раздела XenForo.
Ищем:
div.structItem → a с href, содержащим /topic/ID/
Args:
soup: BeautifulSoup страницы раздела.
Returns:
list[dict]: [{id, title, url}, ...].
"""
items = []
for container in soup.select("div.structItem"):
# Ссылка на тему
link = container.select_one("div.structItem-title a")
if link is None:
continue
href = link.get("href", "")
# Из href вытаскиваем ID темы: /topic/12345/
match = re.search(r"/topic/(\d+)/", href)
if match is None:
continue
topic_id = int(match.group(1))
title = link.text.strip()
# Формируем полный URL (если относительный)
if href.startswith("/"):
full_url = f"https://vwts.ru{href}"
elif href.startswith("http"):
full_url = href
else:
full_url = f"https://vwts.ru/forum/topic/{topic_id}/"
items.append({
"id": topic_id,
"title": title,
"url": full_url,
})
return items
def parse_posts(soup: BeautifulSoup) -> list[dict]:
"""Распарсить посты со страницы темы XenForo.
Ищем:
article.message → автор, дата, номер, текст
Args:
soup: BeautifulSoup страницы темы.
Returns:
list[dict]: [{author, date, num, text}, ...].
"""
posts = []
for article in soup.select("article.message"):
# ── Автор ───────────────────────────────────────────────────────
author_elem = article.select_one("h4.message-name")
author = author_elem.get_text(strip=True) if author_elem else "?"
# ── Дата ────────────────────────────────────────────────────────
time_elem = article.select_one("time.u-dt")
date = time_elem.get("datetime", "") if time_elem else ""
# ── Номер поста (#1, #2...) ─────────────────────────────────────
num_elem = article.select_one("ul.message-attribution-opposite a")
num = num_elem.text.strip() if num_elem else ""
# ── Текст поста ─────────────────────────────────────────────────
content = article.select_one("div.message-content")
text = ""
if content is not None:
# Удаляем скрытые элементы (спойлеры, скрытые блоки)
for hidden in content.select(
"div[style*='none'], span[style*='none'], details",
):
hidden.decompose()
# Извлекаем текст
text = content.get_text("\n", strip=True)
posts.append({
"author": author,
"date": date,
"num": num,
"text": text,
})
return posts
def parse_tags(soup: BeautifulSoup) -> list[str]:
"""Распарсить теги темы XenForo.
Ищем:
li.tagItem → a → текст тега
Args:
soup: BeautifulSoup страницы темы.
Returns:
list[str]: список тегов (может быть пустым).
"""
return [
tag.text.strip()
for tag in soup.select("li.tagItem a")
]
def parse_section_name(soup: BeautifulSoup) -> str | None:
"""Извлечь название раздела из h1.
Args:
soup: BeautifulSoup страницы раздела.
Returns:
str: название раздела или None.
"""
h1 = soup.select_one("h1")
if h1 is not None:
return h1.text.strip()
return None
+50
View File
@@ -0,0 +1,50 @@
# История изменений — scraper/
## 001 — 2026-06-06: Начальная структура
### Создано
- **scraper/core/** — общий слой, независимый от движка форума
- `fetch.py` — HTTP GET с ретраями, автоопределение бана
- `throttle.py` — координация задержек между потоками
- `output.py` — запись JSONL, ротация по N тем
- `state.py` — сохранение/восстановление состояния (пауза/продолжение)
- `runner.py` — ThreadPoolExecutor, единый цикл обхода через адаптер
- **scraper/forums/base.py** — абстрактный базовый класс адаптера форума
- Единый интерфейс: `count_pages()`, `parse_topics()`, `parse_posts()`, `page_url()`, `topic_url()`
- **scraper/forums/xenforo/** — адаптер для XenForo (vwts.ru)
- `paginate.py``/page-N`, `div.pageNav`
- `parse.py``div.structItem`, `article.message`, `li.tagItem`
- `adapter.py` — подстановка параметров
- **scraper/forums/phpbb/** — адаптер для phpBB (нива-лада.рф)
- `paginate.py``?start=N`, `ul.pagination`
- `parse.py``a.topictitle`, `div.postbody`, `b.postauthor`
- `adapter.py` — подстановка параметров
- **scraper/__main__.py** — точка входа CLI
### Принципы
- `core/` ничего не знает про движок форума — работает через абстракцию `BaseAdapter`
- Адаптер реализует только парсинг HTML и формирование URL — никакой логики обхода
- Каждый адаптер — отдельный пакет, можно добавить новый движок создав один пакет
- Все решения по вёрстке документированы в комментариях перед функциями
---
## 002 — 2026-06-06: Фикс sticky-тем phpBB
**Проблема:** В phpBB закреплённые (sticky) темы видны на каждой странице раздела.
При многопоточном парсинге (4 workers) все потоки одновременно хватают одну и ту же
sticky-тему — скрипт зависает.
**Решение:** В `parse_topics()` добавлен фильтр — перед добавлением темы проверяем,
есть ли в родительском `<tr>` иконка `sticky_read.gif`.
Sticky-темы пропускаются (не попадают в список).
**Где:** `lada_scraper/parse.py` (на ВМ, через SSH)
**Проверено:** sticky #331 и #477 не попадают в выдачу, обычные темы на месте.
**Было/стало:** 28 тем → 24 темы на странице (4 sticky отфильтровано).
---
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Полный аудит Elsa: WI, hs2, www — какие типы, что извлекаемо."""
import os, struct, re
elsa = os.path.expanduser("~/nubes/data/elsa")
# 1. WI файлы
print("=" * 60)
print("1. WI-файлы (OLE2 compound documents)")
print("=" * 60)
docs_rl = os.path.join(elsa, "docs", "rl")
wi_files = [f for f in os.listdir(docs_rl) if f.endswith(".wi")]
wi_en = [f for f in wi_files if "en-GB" in f]
print(f"Total WI: {len(wi_files)}, en-GB: {len(wi_en)}")
if wi_en:
path = os.path.join(docs_rl, wi_en[0])
with open(path, "rb") as f:
data = f.read(min(10000, os.path.getsize(path)))
print(f"\nSample: {wi_en[0]}")
print(f"Size: {len(data)} bytes (shown), OLE2: {data[:4] == b'\xd0\xcf\x11\xe0'}")
print(f"Has <?xml: {b'<?xml' in data}")
print(f"Has <html: {b'<html' in data}")
# Search deeper
for tag in [b"<?xml", b"<html", b"<body", b"<p>", b"<title"]:
pos = data.find(tag)
if pos >= 0:
ctx = data[pos:pos+80]
readable = ctx.decode("utf-16-le", errors="replace")
print(f" Found '{tag.decode()}' at offset {pos}: {readable[:80]}")
# 2. hs2 en-GB
print("\n" + "=" * 60)
print("2. hs2/V/en-GB/ — HTML контент")
print("=" * 60)
hs2_en = os.path.join(elsa, "docs", "hs2", "V", "en-GB")
total_hs2 = 0
samples = []
for root, dirs, files in os.walk(hs2_en):
for f in files:
if f.endswith(".htm"):
total_hs2 += 1
if len(samples) < 3:
samples.append(os.path.join(root, f))
print(f"Total HTM: {total_hs2}")
for sp in samples:
with open(sp, "rb") as fh:
raw = fh.read(4096)
text = raw.decode("utf-16-le", errors="replace")
m = re.search(r"<title>(.*?)</title>", text, re.IGNORECASE)
title = m.group(1) if m else "(no title)"
body = re.sub(r"<[^>]+>", " ", text)[:200]
body = re.sub(r"\s+", " ", body).strip()
dir_name = sp.split("/")[-2]
print(f" [{dir_name}] {title}")
print(f" {body[:150]}")
print()
# 3. www en-GB
print("=" * 60)
print("3. www/V/en-GB/ — навигация и контент")
print("=" * 60)
www_en = os.path.join(elsa, "docs", "www", "V", "en-GB")
if os.path.exists(www_en):
items = os.listdir(www_en)
print(f"Items: {items}")
for item in items:
ipath = os.path.join(www_en, item)
if os.path.isfile(ipath) and item.endswith(".htm"):
print(f" {item}{os.path.getsize(ipath)} bytes")
# 4. WI en-GB in other dirs
print("\n" + "=" * 60)
print("4. WI en-GB в других директориях")
print("=" * 60)
for subdir in ["igg", "au", "ki"]:
d = os.path.join(elsa, "docs", subdir)
if os.path.exists(d):
total = 0
en = 0
for f in os.listdir(d):
if f.endswith(".wi"):
total += 1
if "en-GB" in f:
en += 1
print(f" {subdir}: {total} WI total, {en} en-GB")
# 5. Summary
print("\n" + "=" * 60)
print("ИТОГО: что можно извлечь en-GB")
print("=" * 60)
print("""
MDB (уже парсено):
rldal.V.en-GB.mdb — 191k док-тов, иерархия ✅
ipsvrap.mdb — 2.5M строк, каталог запчастей ✅
+ dbsvrfi, dbsvrfz — справочники авто (не тронуты)
HTM (настоящие):
au/V/en-GB/ — 1295 HTM (UTF-16) — уже в elsa_jsonl ✅
hs2/V/en-GB/ — 4473 HTM (UTF-16) — НЕ ПАРСЕНЫ ❗
www/.../en-GB/ — 2538 HTM (ASCII) — НЕ ПАРСЕНЫ ❗
slp/V/en-GB/ — 10845 (x64 code) — МУСОР ❌
WI (OLE2):
rl/ — 1716 en-GB WI-файлов — НЕ ПАРСЕНЫ ❗
igg/ — ? en-GB WI
au/ — ? en-GB WI
""")
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Проверить что за файлы в slp/V/en-GB/*.htm"""
import zlib, re, os, struct
path = "/home/naeel/nubes/data/elsa/docs/slp/V/en-GB/000502200001.htm"
with open(path, "rb") as f:
raw = f.read()
print(f"File size: {len(raw)} bytes")
print(f"First 64 hex: {raw[:64].hex()}")
# PE check
if raw[:2] == b"MZ":
print(">>> It's a PE/EXE/DLL file!")
elif raw[:2] == b"\x48\xc7":
print(">>> Starts with 48 c7 - could be x64 machine code!")
# Look for ELF
if raw[:4] == b"\x7fELF":
print(">>> It's an ELF binary!")
# Try full zlib decompress at different offsets
for offset in range(0, min(500, len(raw))):
if raw[offset] == 0x78 and raw[offset+1] in [0x01, 0x9c, 0xda]:
try:
decomp = zlib.decompress(raw[offset:])
print(f">>> zlib at offset {offset}: {len(decomp)} bytes decompressed")
# Try decode as UTF-16LE
text = decomp.decode("utf-16-le", errors="replace")
m = re.search(r"<title>(.*?)</title>", text, re.IGNORECASE)
if m:
print(f" Title: {m.group(1)}")
body = re.sub(r"<[^>]+>", " ", text)[:500]
body = re.sub(r"\s+", " ", body).strip()
print(f" Text: {body[:200]}")
break
except:
pass
else:
print(">>> No zlib content found")
# Also check ru-RU for comparison
ru_path = "/home/naeel/nubes/data/elsa/docs/slp/V/ru-RU"
if os.path.exists(ru_path):
ru_files = [f for f in os.listdir(ru_path) if f.endswith(".htm")]
if ru_files:
ru_file = os.path.join(ru_path, ru_files[0])
with open(ru_file, "rb") as f:
ru_raw = f.read(64)
print(f"\n>>> For comparison, ru-RU first 64 hex: {ru_raw.hex()}")
# Check encoding
import subprocess
result = subprocess.run(["file", ru_file], capture_output=True, text=True)
print(f" file says: {result.stdout.strip()}")
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
OCR KIA service manual PDF → structured JSON.
Usage:
python3 ocr_kia_pdf.py [--pages N] [--start N]
Output: kia_data/kia_ocr.json
"""
import pymupdf
import subprocess
import tempfile
import os
import json
import re
import sys
import argparse
from pathlib import Path
PDF_PATH = "/mnt/y/Torrents/KIA4_by_kiario.pdf"
OUT_DIR = Path(__file__).parent.parent / "kia_data"
OUT_JSON = OUT_DIR / "kia_ocr.json"
# OCR language: rus for Russian, eng for English terms
OCR_LANG = "rus+eng"
# Zoom factor for rendering (2 = 2x, better OCR quality)
ZOOM = 2
def ocr_page(pixmap, page_num=0) -> str:
"""Run tesseract on a pixmap, return text. Retry on timeout with explicit cleanup."""
# Save pixmap to temp file, close before OCR
fd, img_path = tempfile.mkstemp(suffix=".png")
os.close(fd)
pixmap.save(img_path)
for attempt in range(3):
proc = None
try:
proc = subprocess.Popen(
["tesseract", img_path, "stdout", "-l", OCR_LANG],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
stdout, _ = proc.communicate(timeout=60)
if proc.returncode != 0 and proc.returncode is not None:
raise RuntimeError(f"tesseract exit {proc.returncode}")
os.unlink(img_path)
return stdout.strip()
except subprocess.TimeoutExpired:
if proc:
proc.kill()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
if attempt < 2:
print(f" p{page_num}: timeout, retry {attempt+2}/3...", flush=True)
else:
print(f" p{page_num}: SKIP (timeout after 3 retries)", flush=True)
os.unlink(img_path)
return ""
except Exception as e:
if proc:
proc.kill()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
os.unlink(img_path)
print(f" p{page_num}: ERROR {e}", flush=True)
return ""
def is_section_header(line: str) -> bool:
"""
Heuristic: detect section/chapter headers.
Examples:
- "ГЛАВА 1. ОБЩИЕ СВЕДЕНИЯ"
- "1. ТЕХНИЧЕСКОЕ ОБСЛУЖИВАНИЕ"
- "ДВИГАТЕЛЬ"
"""
line = line.strip()
if not line or len(line) < 3:
return False
# Numbered chapter: "1.", "1.2", "ГЛАВА 1"
if re.match(r"^(ГЛАВА|РАЗДЕЛ|ЧАСТЬ)\s+\d+", line, re.IGNORECASE):
return True
if re.match(r"^\d+\.\s+[А-ЯA-Z]", line):
return True
if re.match(r"^\d+\.\d+\.?\s+[А-ЯA-Z]", line):
return True
# ALL CAPS, 3+ words, reasonable length (not a full sentence)
if line.isupper() and len(line.split()) >= 2 and 5 < len(line) < 100:
return True
return False
def parse_pdf(start_page=0, max_pages=None):
"""OCR all pages and structure into sections."""
doc = pymupdf.open(PDF_PATH)
total_pages = doc.page_count
end_page = min(total_pages, start_page + max_pages) if max_pages else total_pages
print(f"PDF: {total_pages} pages, processing {start_page}{end_page-1}", flush=True)
sections = []
current_section = {"title": "Начало", "pages": [], "text": ""}
mat = pymupdf.Matrix(ZOOM, ZOOM)
for i in range(start_page, end_page):
page = doc[i]
pix = page.get_pixmap(matrix=mat)
text = ocr_page(pix, page_num=i)
if not text:
print(f" p{i}: [empty]")
continue
# Try to detect section header on this page
lines = text.split("\n")
header_found = False
for line in lines[:5]: # check first 5 lines
if is_section_header(line):
# Save previous section
if current_section["text"].strip():
sections.append(current_section)
current_section = {"title": line.strip(), "pages": [], "text": ""}
header_found = True
break
current_section["pages"].append(i)
current_section["text"] += text + "\n"
if (i - start_page) % 20 == 0:
print(f" p{i}: {len(text)} chars, sections so far: {len(sections)}", flush=True)
# Save last section
if current_section["text"].strip():
sections.append(current_section)
doc.close()
return {
"source": PDF_PATH,
"total_pages": total_pages,
"pages_processed": end_page - start_page,
"sections": sections,
}
def main():
parser = argparse.ArgumentParser(description="OCR KIA PDF → JSON")
parser.add_argument("--pages", type=int, default=None, help="Max pages to process")
parser.add_argument("--start", type=int, default=0, help="Start page")
args = parser.parse_args()
OUT_DIR.mkdir(parents=True, exist_ok=True)
data = parse_pdf(start_page=args.start, max_pages=args.pages)
print(f"\nSections: {len(data['sections'])}")
total_chars = sum(len(s["text"]) for s in data["sections"])
print(f"Total chars: {total_chars:,}")
with open(OUT_JSON, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"Saved: {OUT_JSON} ({os.path.getsize(OUT_JSON)/1024/1024:.1f} MB)")
print("Done!")
if __name__ == "__main__":
main()
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Парсер en-GB HTM файлов ElsaWin: hs2 + www"""
import os, re, json, sys
from pathlib import Path
def parse_htm_file(path):
"""Парсить один HTM-файл в UTF-16LE."""
with open(path, "rb") as f:
raw = f.read()
# Проверяем кодировку
if raw[:2] == b"\xff\xfe":
text = raw.decode("utf-16-le", errors="replace")
elif raw[:2] == b"\xfe\xff":
text = raw.decode("utf-16-be", errors="replace")
else:
text = raw.decode("ascii", errors="replace")
# Title
m = re.search(r"<title>(.*?)</title>", text, re.IGNORECASE | re.DOTALL)
title = m.group(1).strip() if m else ""
# Body text
body = re.sub(r"<script[^>]*>.*?</script>", " ", text, flags=re.IGNORECASE | re.DOTALL)
body = re.sub(r"<style[^>]*>.*?</style>", " ", body, flags=re.IGNORECASE | re.DOTALL)
body = re.sub(r"<[^>]+>", " ", body)
body = re.sub(r"&nbsp;", " ", body)
body = re.sub(r"&\w+;", " ", body)
body = re.sub(r"\s+", " ", body).strip()
return {"title": title, "text": body}
def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <elsa_docs_dir> <output_dir>")
sys.exit(1)
elsa_docs = os.path.abspath(sys.argv[1])
out_dir = os.path.abspath(sys.argv[2])
os.makedirs(out_dir, exist_ok=True)
en_gb_dirs = [
# hs2 — in nested structure: hs2/V/en-GB/src/XX/XXXXXX/master.htm
os.path.join(elsa_docs, "hs2", "V", "en-GB"),
# www — various paths under www/.../en-GB/
]
# Find all en-GB HTM
all_files = []
for root, dirs, files in os.walk(elsa_docs):
if "/en-GB/" in root and not "/slp/" in root:
for f in files:
if f.endswith(".htm") or f.endswith(".html"):
all_files.append(os.path.join(root, f))
# Skip already-known-malformed (slp — machine code, already excluded above)
# Only au, hs2, www, igg
good_files = [f for f in all_files if any(d in f for d in ["/www/", "/hs2/", "/au/"])]
print(f"Found {len(good_files)} en-GB HTM files (excluding slp)")
# Split by source
by_source = {}
for f in good_files:
if "/au/" in f:
src = "au"
elif "/hs2/" in f:
src = "hs2"
elif "/www/" in f:
src = "www"
else:
src = "other"
by_source.setdefault(src, []).append(f)
for src, files in by_source.items():
print(f" {src}: {len(files)} files")
# Parse and save
total = 0
part_size = 5000
part_idx = 0
buf = []
out_file = None
for src, files in by_source.items():
for fpath in sorted(files):
try:
doc = parse_htm_file(fpath)
rel = os.path.relpath(fpath, elsa_docs)
record = {
"source": f"elsa_{src}",
"file": "/docs/" + rel,
"title": doc["title"],
"text": doc["text"],
}
buf.append(record)
total += 1
if len(buf) >= part_size:
out_file = os.path.join(out_dir, f"part_{part_idx:04d}.jsonl")
with open(out_file, "w", encoding="utf-8") as f:
for r in buf:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f" Part {part_idx:04d}: {len(buf)} records → {out_file}")
buf = []
part_idx += 1
except Exception as e:
print(f" ERROR {fpath}: {e}", file=sys.stderr)
# Flush remaining
if buf:
out_file = os.path.join(out_dir, f"part_{part_idx:04d}.jsonl")
with open(out_file, "w", encoding="utf-8") as f:
for r in buf:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f" Part {part_idx:04d}: {len(buf)} records → {out_file}")
part_idx += 1
print(f"\nTotal: {total} documents in {part_idx} parts → {out_dir}")
if __name__ == "__main__":
main()
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""
Парсер MDB-баз ElsaWin → JSONL для RAG.
Использует mdbtools (mdb-export) для выгрузки таблиц.
Использование:
python3 parse_elsa_mdb.py <путь_к_mdb> <выходная_директория>
Пример:
python3 parse_elsa_mdb.py ~/nubes/data/elsa/data/rldal.V.en-GB.mdb ~/nubes/data/elsa_mdb_jsonl/
"""
import csv
import io
import json
import os
import subprocess
import sys
from pathlib import Path
def mdb_tables(mdb_path: str) -> list[str]:
"""Получить список таблиц из MDB через mdb-tables."""
result = subprocess.run(
["mdb-tables", mdb_path],
capture_output=True, text=True, check=True
)
return result.stdout.strip().split()
def mdb_export(mdb_path: str, table: str) -> str:
"""Экспорт таблицы в CSV через mdb-export."""
result = subprocess.run(
["mdb-export", mdb_path, table],
capture_output=True, text=True, check=True
)
return result.stdout
def parse_csv(csv_text: str) -> list[dict]:
"""Распарсить CSV в список словарей."""
reader = csv.DictReader(io.StringIO(csv_text))
return [row for row in reader]
def export_table_to_jsonl(mdb_path: str, table: str, output_dir: str) -> str:
"""Экспорт одной таблицы в JSONL."""
print(f" {table}...", end=" ", flush=True)
csv_text = mdb_export(mdb_path, table)
rows = parse_csv(csv_text)
out_file = os.path.join(output_dir, f"{table}.jsonl")
with open(out_file, "w", encoding="utf-8") as f:
for row in rows:
# Очистка значений: убираем лишние пробелы
cleaned = {k: v.strip() if v else "" for k, v in row.items()}
f.write(json.dumps(cleaned, ensure_ascii=False) + "\n")
print(f"{len(rows)} rows → {out_file}")
return out_file
def build_wi_dokument_docs(mdb_path: str, output_dir: str):
"""
Построить документы из wi_dokument с полной иерархией.
Каждый документ = одна строка JSONL с полным путём разделов.
"""
print("\n=== Построение документов из wi_dokument ===")
csv_text = mdb_export(mdb_path, "wi_dokument")
rows = parse_csv(csv_text)
docs = []
for row in rows:
# Собираем полный путь иерархии
hierarchy_parts = []
for key in ["og_bez", "bg_bez", "rg_bez", "hkap_bez", "kap_bez", "ukap_bez"]:
val = row.get(key, "").strip()
if val:
hierarchy_parts.append(val)
full_path = "".join(hierarchy_parts) if hierarchy_parts else ""
doc = {
"dokument_id": int(row.get("dokument_id", 0)),
"spk": row.get("spk", "").strip(),
"redsystyp": int(row.get("redsystyp", 0)),
"name": row.get("name", "").strip(),
"hierarchy": {
"og_id": int(row.get("og_id", 0)) if row.get("og_id", "").strip() else None,
"bg_id": int(row.get("bg_id", 0)) if row.get("bg_id", "").strip() else None,
"rg_id": row.get("rg_id", "").strip(),
"hkap_id": int(row.get("hkap_id", 0)) if row.get("hkap_id", "").strip() else None,
"kap_id": int(row.get("kap_id", 0)) if row.get("kap_id", "").strip() else None,
"ukap_id": int(row.get("ukap_id", 0)) if row.get("ukap_id", "").strip() else None,
},
"titles": {
"og": row.get("og_bez", "").strip(),
"bg": row.get("bg_bez", "").strip(),
"rg": row.get("rg_bez", "").strip(),
"hkap": row.get("hkap_bez", "").strip(),
"kap": row.get("kap_bez", "").strip(),
"ukap": row.get("ukap_bez", "").strip(),
},
"full_title": full_path,
"parent_dokument_id": int(row.get("parent_dokument_id", 0)) if row.get("parent_dokument_id", "").strip() else None,
"ext_id": row.get("ext_id", "").strip(),
"source": "elsa",
"type": "wi_dokument",
}
docs.append(doc)
out_file = os.path.join(output_dir, "wi_dokument_enriched.jsonl")
with open(out_file, "w", encoding="utf-8") as f:
for doc in docs:
f.write(json.dumps(doc, ensure_ascii=False) + "\n")
print(f"Построено {len(docs)} документов → {out_file}")
# Статистика по иерархии
with_titles = sum(1 for d in docs if d["full_title"])
print(f" Из них с иерархией: {with_titles}")
print(f" Без иерархии: {len(docs) - with_titles}")
return out_file
def build_vehicle_lookup(mdb_path: str, output_dir: str):
"""
Построить справочник привязки документов к автомобилям.
"""
print("\n=== Построение привязки документов к автомобилям ===")
# wi_dokument_fzg
csv_text = mdb_export(mdb_path, "wi_dokument_fzg")
rows = parse_csv(csv_text)
# Группируем по dokument_id
from collections import defaultdict
by_doc = defaultdict(list)
for row in rows:
entry = {
"vtyp": row.get("vtyp", "").strip(),
"marke": row.get("marke", "").strip(),
"mkb": row.get("mkb", "").strip(),
"gkb": row.get("gkb", "").strip(),
"gtyp": row.get("gtyp", "").strip(),
"mj_von": int(row.get("mj_von", 0)) if row.get("mj_von", "").strip() else None,
"mj_bis": int(row.get("mj_bis", 0)) if row.get("mj_bis", "").strip() else None,
}
by_doc[int(row["dokument_id"])].append(entry)
out_file = os.path.join(output_dir, "wi_dokument_fzg_grouped.jsonl")
with open(out_file, "w", encoding="utf-8") as f:
for doc_id, vehicles in by_doc.items():
f.write(json.dumps({
"dokument_id": doc_id,
"vehicles": vehicles,
"count": len(vehicles),
}, ensure_ascii=False) + "\n")
print(f"{len(by_doc)} документов с привязкой к авто → {out_file}")
return out_file
def build_hierarchy_reference(mdb_path: str, output_dir: str):
"""
Построить справочники иерархии: og, bg, rg с vehicle-привязкой.
"""
print("\n=== Построение справочников иерархии ===")
for tbl, name in [("wi_og_fzg", "og"), ("wi_bg_fzg", "bg"), ("wi_rg_fzg", "rg")]:
csv_text = mdb_export(mdb_path, tbl)
rows = parse_csv(csv_text)
out_file = os.path.join(output_dir, f"{tbl}.jsonl")
with open(out_file, "w", encoding="utf-8") as f:
for row in rows:
cleaned = {k: v.strip() if v else "" for k, v in row.items()}
f.write(json.dumps(cleaned, ensure_ascii=False) + "\n")
print(f" {tbl}: {len(rows)} rows → {out_file}")
def main():
if len(sys.argv) < 3:
print(__doc__)
sys.exit(1)
mdb_path = os.path.abspath(sys.argv[1])
output_dir = os.path.abspath(sys.argv[2])
if not os.path.exists(mdb_path):
print(f"ОШИБКА: MDB файл не найден: {mdb_path}")
sys.exit(1)
os.makedirs(output_dir, exist_ok=True)
print(f"MDB: {mdb_path}")
print(f"Выход: {output_dir}")
# 1. Список таблиц
tables = mdb_tables(mdb_path)
print(f"\nТаблицы ({len(tables)}): {', '.join(tables)}")
# 2. Экспорт каждой таблицы в сырой JSONL
print("\n=== Экспорт таблиц ===")
for table in tables:
try:
export_table_to_jsonl(mdb_path, table, output_dir)
except subprocess.CalledProcessError as e:
print(f"ОШИБКА экспорта {table}: {e}")
# 3. Построение обогащённых документов (rldal-specific)
if "wi_dokument" in tables:
build_wi_dokument_docs(mdb_path, output_dir)
build_vehicle_lookup(mdb_path, output_dir)
build_hierarchy_reference(mdb_path, output_dir)
else:
print("\n=== Пропускаем обогащение (нет wi_dokument таблицы) ===")
print(f"\n✅ Готово. Результат: {output_dir}")
if __name__ == "__main__":
main()
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Парсер WI-файлов ElsaWin (OLE2 → XML → JSONL)"""
import os, re, json, sys
import olefile
def parse_wi_file(path):
"""Извлечь XML-потоки из WI (OLE2) файла."""
docs = []
with olefile.OleFileIO(path) as ole:
for stream in ole.listdir():
stream_name = "/".join(stream)
if not stream_name.endswith(".xml"):
continue
data = ole.openstream(stream).read()
text = data.decode("utf-8", errors="replace")
# Extract key elements
title_parts = []
m = re.search(r"<marke>(.*?)</marke>", text, re.IGNORECASE)
marke = m.group(1) if m else ""
m = re.search(r"<modell>(.*?)</modell>", text, re.IGNORECASE)
modell = m.group(1) if m else ""
m = re.search(r"<obergrup>(.*?)</obergrup>", text, re.IGNORECASE)
obergrup = m.group(1) if m else ""
m = re.search(r"<baugrup>(.*?)</baugrup>", text, re.IGNORECASE)
baugrup = m.group(1) if m else ""
# Название документа
m = re.search(r"<h-kap[^>]*?typen-bez=\"([^\"]+)\"", text)
typen = m.group(1) if m else ""
# Title from vorspann
title = " ".join(p for p in [obergrup, baugrup] if p)
if marke:
title = f"{marke} {modell}: {title}" if modell else f"{marke}: {title}"
# Clean body
body = re.sub(r"<[^>]+>", " ", text)
body = re.sub(r"\s+", " ", body).strip()
docs.append({
"title": title,
"marke": marke,
"modell": modell,
"obergrup": obergrup,
"baugrup": baugrup,
"text": body,
})
return docs
def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <elsa_docs_dir> <output_dir>")
sys.exit(1)
elsa_docs = os.path.abspath(sys.argv[1])
out_dir = os.path.abspath(sys.argv[2])
os.makedirs(out_dir, exist_ok=True)
# Collect en-GB WI files
wi_files = []
for root, dirs, files in os.walk(elsa_docs):
for f in files:
if f.endswith(".wi") and "en-GB" in f:
wi_files.append(os.path.join(root, f))
print(f"Found {len(wi_files)} en-GB WI files")
# By category
by_cat = {}
for f in wi_files:
if "/rl/" in f:
cat = "rl"
elif "/igg/" in f:
cat = "igg"
elif "/ki/" in f:
cat = "ki"
else:
cat = "other"
by_cat.setdefault(cat, []).append(f)
for cat, files in by_cat.items():
print(f" {cat}: {len(files)} files")
# Parse
total_docs = 0
part_size = 5000
part_idx = 0
buf = []
for cat, files in by_cat.items():
for fpath in sorted(files):
try:
docs = parse_wi_file(fpath)
for doc in docs:
doc["source"] = f"elsa_wi_{cat}"
doc["file"] = os.path.relpath(fpath, elsa_docs)
buf.append(doc)
total_docs += 1
except Exception as e:
print(f" ERROR {fpath}: {e}", file=sys.stderr)
if len(buf) >= part_size:
out_file = os.path.join(out_dir, f"part_{part_idx:04d}.jsonl")
with open(out_file, "w", encoding="utf-8") as f:
for r in buf:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f" Part {part_idx:04d}: {len(buf)}{out_file}")
buf = []
part_idx += 1
if buf:
out_file = os.path.join(out_dir, f"part_{part_idx:04d}.jsonl")
with open(out_file, "w", encoding="utf-8") as f:
for r in buf:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f" Part {part_idx:04d}: {len(buf)}{out_file}")
print(f"\nTotal: {total_docs} XML documents from {len(wi_files)} WI files → {out_dir}")
if __name__ == "__main__":
main()
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Ingest JSONL into ChromaDB — uses llama.cpp /embedding endpoint."""
import json, os, sys, glob, urllib.request, time
import chromadb
LLAMA_URL = "http://localhost:8081/embedding"
def llama_embed(texts):
"""Get embeddings from nomic-embed server with retry."""
data = json.dumps({"content": texts}).encode()
req = urllib.request.Request(LLAMA_URL, data=data,
headers={"Content-Type": "application/json"})
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=120) as resp:
body = json.loads(resp.read())
if isinstance(body, list):
return [item["embedding"][0] for item in body]
return body.get("embedding", [])
except Exception as e:
if attempt < 2:
print(f"[retry {attempt+1}] {e}")
time.sleep(5)
else:
raise
class LlamaEmbedFn:
"""Custom embedding function using llama.cpp."""
def __call__(self, input):
all_embs = []
for i in range(0, len(input), 32):
batch = input[i:i+32]
embs = llama_embed(batch)
if isinstance(embs[0], float):
all_embs.append(embs)
else:
all_embs.extend(embs)
return all_embs
def main():
jsonl_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/nubes/data/elsa_jsonl_wi_en")
db_dir = sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser("~/nubes/chroma_db")
collection_name = sys.argv[3] if len(sys.argv) > 3 else "elsa_docs"
ef = LlamaEmbedFn()
client = chromadb.PersistentClient(path=db_dir)
try:
client.delete_collection(collection_name)
except:
pass
collection = client.create_collection(
name=collection_name,
embedding_function=ef,
metadata={"hnsw:space": "cosine"}
)
jsonl_files = sorted(glob.glob(os.path.join(jsonl_dir, "*.jsonl")))
print(f"Found {len(jsonl_files)} JSONL files")
print(f"Using llama.cpp at {LLAMA_URL}")
total = 0
for jf in jsonl_files:
ids, docs, metas = [], [], []
with open(jf, "r", encoding="utf-8") as f:
for line in f:
try:
d = json.loads(line)
title = d.get("title", "").strip()
text = d.get("text", "").strip()
ft = d.get("full_title", "").strip()
if ft and text:
doc = f"{ft}\n{text}"
elif title and text:
doc = f"{title}\n{text}"
else:
doc = text or title or ft
if not doc or len(doc) < 20:
continue
# Split long docs into chunks of ~1000 chars
chunks = [doc[i:i+1000] for i in range(0, len(doc), 1000)]
for chunk in chunks:
if len(chunk) < 20:
continue
ids.append(str(total))
docs.append(chunk)
metas.append({
"source": d.get("source", ""),
"file": d.get("file", ""),
"title": title or ft or doc[:100],
})
total += 1
except:
continue
if ids:
# Send in smaller batches of 500
for i in range(0, len(ids), 500):
batch_ids = ids[i:i+500]
batch_docs = docs[i:i+500]
batch_metas = metas[i:i+500]
for attempt in range(3):
try:
collection.add(ids=batch_ids, documents=batch_docs, metadatas=batch_metas)
break
except Exception as e:
if attempt < 2:
print(f" [retry {os.path.basename(jf)} batch {attempt+1}] {e}")
time.sleep(10)
else:
print(f" [SKIP batch] {e}")
print(f" {os.path.basename(jf)}: +{len(ids)} (total {total})")
print(f"\nDone. {total} documents → {db_dir}/{collection_name}")
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Полная индексация через sentence-transformers (CPU, стабильно)."""
import json, os, sys, time, glob, shutil
from chromadb import PersistentClient
from chromadb.utils import embedding_functions
ALL_DIRS = [
"~/nubes/data/elsa_jsonl_wi_en",
"~/nubes/data/elsa_mdb_jsonl",
"~/nubes/data/elsa_mdb_jsonl/ipsvrap",
"~/nubes/data/elsa_jsonl_htm_en",
"~/nubes/data/elsa_jsonl",
"~/nubes/data/elsa_mdb_jsonl/dbsvrfi",
"~/nubes/data/elsa_mdb_jsonl/dbsvrfz",
]
DB_DIR = os.path.expanduser("~/nubes/chroma_db")
MODEL = "all-MiniLM-L6-v2" # 90 MB, CPU
ef = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=MODEL)
if os.path.exists(DB_DIR):
shutil.rmtree(DB_DIR)
print("Old chroma_db deleted")
client = PersistentClient(path=DB_DIR)
collection = client.create_collection("elsa_docs", embedding_function=ef)
all_files = []
for d in ALL_DIRS:
path = os.path.expanduser(d)
files = sorted(glob.glob(os.path.join(path, "*.jsonl")))
all_files.extend(files)
print(f"{d}: {len(files)} files")
print(f"\nTotal: {len(all_files)} JSONL files")
total = 0
for fpath in all_files:
docs, ids, metas = [], [], []
prefix = os.path.basename(os.path.dirname(fpath)) + "_"
try:
with open(fpath) as fh:
for line in fh:
try:
d = json.loads(line)
title = d.get("title", "") or d.get("full_title", "")
text = d.get("text", "")
doc = (title + "\n" + text).strip()[:1000]
if len(doc) < 20:
continue
docs.append(doc)
ids.append(f"{prefix}{os.path.basename(fpath)}_{len(docs)}")
metas.append({"source": prefix.strip("_")})
except:
continue
except:
continue
if not docs:
continue
print(f"{os.path.basename(fpath)}: {len(docs)} docs", end="", flush=True)
for j in range(0, len(docs), 500):
batch = docs[j:j+500]
batch_ids = ids[j:j+500]
for retry in range(3):
try:
collection.add(ids=batch_ids, documents=batch,
metadatas=metas[j:j+500])
total += len(batch)
print(".", end="", flush=True)
break
except Exception as e:
if retry < 2:
print(f"R{retry}", end="", flush=True)
time.sleep(5)
else:
print(f"X", end="", flush=True)
print(f" total={collection.count()}")
print(f"\n✅ Done. Total: {collection.count()} docs")
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Полная переиндексация ВСЕХ данных в ChromaDB. Работает до победного."""
import json, os, sys, time, urllib.request, glob
from chromadb import PersistentClient
EF_URL = "http://localhost:8081/embedding"
DB_DIR = os.path.expanduser("~/nubes/chroma_db")
ALL_DIRS = [
"~/nubes/data/elsa_jsonl_wi_en", # 244k WI XML
"~/nubes/data/elsa_mdb_jsonl", # 191k rldal
"~/nubes/data/elsa_mdb_jsonl/ipsvrap", # 2.5M parts
"~/nubes/data/elsa_jsonl_htm_en", # 7k hs2+www
"~/nubes/data/elsa_jsonl", # 24k HTM
"~/nubes/data/elsa_mdb_jsonl/dbsvrfi", # car ref
"~/nubes/data/elsa_mdb_jsonl/dbsvrfz", # PR codes
]
def embed(texts):
for attempt in range(5):
try:
data = json.dumps({"content": texts}).encode()
req = urllib.request.Request(EF_URL, data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as resp:
body = json.loads(resp.read())
if isinstance(body, list):
return [item["embedding"][0] for item in body]
return body.get("embedding", [])
except Exception as e:
print(f"\n [embed retry {attempt+1}/5] {e}")
time.sleep(15)
raise Exception("Embedding failed after 5 retries")
def main():
# Удалить старую БД и создать новую
import shutil
if os.path.exists(DB_DIR):
shutil.rmtree(DB_DIR)
print("Старая ChromaDB удалена")
client = PersistentClient(path=DB_DIR)
collection = client.create_collection("elsa_docs")
# Собираем все JSONL
all_files = []
for d in ALL_DIRS:
path = os.path.expanduser(d)
files = sorted(glob.glob(os.path.join(path, "*.jsonl")))
all_files.extend(files)
print(f"{d}: {len(files)} files")
print(f"\nВсего {len(all_files)} JSONL файлов")
total = 0
errors = 0
for fpath in all_files:
docs, ids = [], []
prefix = os.path.basename(os.path.dirname(fpath)) + "_"
try:
with open(fpath) as fh:
for line in fh:
try:
d = json.loads(line)
title = d.get("title", "") or d.get("full_title", "")
text = d.get("text", "")
doc = (title + "\n" + text).strip()[:1000]
if len(doc) < 20:
continue
docs.append(doc)
ids.append(f"{prefix}{os.path.basename(fpath)}_{len(docs)}")
except:
continue
except Exception as e:
print(f" [SKIP {os.path.basename(fpath)}] read error: {e}")
errors += 1
continue
if not docs:
print(f" {os.path.basename(fpath)}: empty")
continue
print(f" {os.path.basename(fpath)}: {len(docs)} docs", end="", flush=True)
# Бачами по 50
for j in range(0, len(docs), 50):
batch_docs = docs[j:j+50]
batch_ids = ids[j:j+50]
for retry in range(5):
try:
embs = embed(batch_docs)
collection.add(ids=batch_ids, documents=batch_docs,
embeddings=embs, metadatas=[{"source": prefix.strip("_")}] * len(batch_docs))
total += len(batch_docs)
print(".", end="", flush=True)
break
except Exception as e:
if retry < 4:
print(f"R{retry}", end="", flush=True)
time.sleep(20)
else:
print(f"X", end="", flush=True)
errors += len(batch_docs)
time.sleep(0.2)
print(f" total={collection.count()}")
print(f"\n✅ Done. Total: {collection.count()} docs. Errors: {errors}")
if __name__ == "__main__":
main()
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
Ingest JSONL into ChromaDB with resume support.
- Прерывается по Ctrl+C безопасно
- При перезапуске продолжает с места останова
- Не удаляет ничего без спроса
"""
import json, os, sys, time, urllib.request, glob, signal
from chromadb import PersistentClient
EF_URL = "http://localhost:8081/embedding"
DB_DIR = os.path.expanduser("~/nubes/chroma_db")
STATE_FILE = os.path.join(DB_DIR, ".ingest_state.txt")
ALL_DIRS = [
"~/nubes/data/elsa_jsonl_wi_en",
"~/nubes/data/elsa_mdb_jsonl",
"~/nubes/data/elsa_mdb_jsonl/ipsvrap",
"~/nubes/data/elsa_jsonl_htm_en",
"~/nubes/data/elsa_jsonl",
"~/nubes/data/elsa_mdb_jsonl/dbsvrfi",
"~/nubes/data/elsa_mdb_jsonl/dbsvrfz",
]
running = True
def handle_sigint(sig, frame):
global running
print("\n⏳ Graceful stop after current file...")
running = False
def embed(texts):
for attempt in range(5):
try:
data = json.dumps({"content": texts}).encode()
req = urllib.request.Request(EF_URL, data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as resp:
body = json.loads(resp.read())
if isinstance(body, list):
return [item["embedding"][0] for item in body]
return body.get("embedding", [])
except Exception as e:
if attempt < 4:
sys.stdout.write(f"R{attempt}")
sys.stdout.flush()
time.sleep(15)
else:
raise
def load_state():
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
return set(line.strip() for line in f if line.strip())
return set()
def save_state(fpath):
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "a") as f:
f.write(fpath + "\n")
def main():
signal.signal(signal.SIGINT, handle_sigint)
processed = load_state()
# Подключаемся к БД
client = PersistentClient(path=DB_DIR)
existing = os.path.exists(os.path.join(DB_DIR, "chroma.sqlite3"))
if existing:
try:
collection = client.get_collection("elsa_docs")
print(f"DB exists: {collection.count()} docs")
except:
collection = client.create_collection("elsa_docs")
print("Created new collection")
else:
collection = client.create_collection("elsa_docs")
print("Created new ChromaDB")
# Собираем все файлы
all_files = []
for d in ALL_DIRS:
path = os.path.expanduser(d)
files = sorted(glob.glob(os.path.join(path, "*.jsonl")))
all_files.extend(files)
pending = [f for f in all_files if f not in processed]
print(f"Files: total={len(all_files)}, done={len(processed)}, pending={len(pending)}")
if not pending:
print("All done.")
return
total = collection.count()
errors = 0
for fpath in pending:
if not running:
print("Stopped by user")
break
docs, ids = [], []
prefix = os.path.basename(os.path.dirname(fpath)) + "_"
fname = os.path.basename(fpath)
try:
with open(fpath) as fh:
for line in fh:
try:
d = json.loads(line)
title = d.get("title", "") or d.get("full_title", "")
text = d.get("text", "")
doc = (title + "\n" + text).strip()[:1000]
if len(doc) < 20:
continue
docs.append(doc)
ids.append(f"{prefix}{fname}_{len(docs)}")
except:
continue
except Exception as e:
print(f" [ERR] {fname}: {e}")
errors += 1
save_state(fpath)
continue
if not docs:
save_state(fpath)
print(f" {fname}: empty")
continue
sys.stdout.write(f" {fname}: {len(docs)} docs")
sys.stdout.flush()
ok = True
for j in range(0, len(docs), 50):
if not running:
ok = False
break
batch_docs = docs[j:j+50]
batch_ids = ids[j:j+50]
for retry in range(5):
try:
embs = embed(batch_docs)
collection.add(ids=batch_ids, documents=batch_docs,
embeddings=embs, metadatas=[{"source": prefix.strip("_")}] * len(batch_docs))
total += len(batch_docs)
sys.stdout.write(".")
sys.stdout.flush()
break
except Exception as e:
if retry < 4:
sys.stdout.write(f"R{retry}")
sys.stdout.flush()
time.sleep(20)
else:
sys.stdout.write("X")
sys.stdout.flush()
errors += len(batch_docs)
time.sleep(0.2)
if ok:
save_state(fpath)
print(f" total={collection.count()}")
print(f"\nTotal: {collection.count()} docs. Errors: {errors}")
if pending and not running:
print("Restart to continue from where stopped.")
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Ingest WI JSONL into ChromaDB — one file at a time, batches of 50."""
import json, os, sys, time, urllib.request, glob
from chromadb import PersistentClient
EF_URL = "http://localhost:8081/embedding"
DB_DIR = os.path.expanduser("~/nubes/chroma_db")
# Default: ingest ALL JSONL dirs
ALL_DIRS = [
os.path.expanduser("~/nubes/data/elsa_jsonl_wi_en"), # 244k WI XML
os.path.expanduser("~/nubes/data/elsa_mdb_jsonl"), # 191k rldal
os.path.expanduser("~/nubes/data/elsa_mdb_jsonl/ipsvrap"), # 2.5M parts
os.path.expanduser("~/nubes/data/elsa_jsonl_htm_en"), # 7k hs2+www
os.path.expanduser("~/nubes/data/elsa_jsonl"), # 24k HTM
os.path.expanduser("~/nubes/data/elsa_mdb_jsonl/dbsvrfi"), # car ref
os.path.expanduser("~/nubes/data/elsa_mdb_jsonl/dbsvrfz"), # PR codes
]
DATA_DIR = sys.argv[1] if len(sys.argv) > 1 else None
COLLECTION_NAME = "elsa_docs"
# Connect to existing ChromaDB
client = PersistentClient(path=DB_DIR)
try:
collection = client.get_or_create_collection(COLLECTION_NAME)
except:
collection = client.create_collection(COLLECTION_NAME)
existing = collection.count()
print(f"Collection has {existing} docs already")
# Find remaining files
if DATA_DIR:
files = sorted(glob.glob(os.path.join(DATA_DIR, "*.jsonl")))
else:
files = sorted(glob.glob(os.path.join(ALL_DIRS[0], "*.jsonl")))
for d in ALL_DIRS[1:]:
files.extend(sorted(glob.glob(os.path.join(d, "*.jsonl"))))
print(f"Processing {len(files)} JSONL files")
for fpath in files:
docs, ids = [], []
prefix = os.path.basename(os.path.dirname(fpath)) + "_"
with open(fpath) as fh:
for line in fh:
d = json.loads(line)
title = d.get("title", "") or d.get("full_title", "")
text = d.get("text", "")
doc = (title + "\n" + text).strip()[:1000]
if len(doc) < 20:
continue
docs.append(doc)
ids.append(f"{prefix}{os.path.basename(fpath)}_{len(docs)}")
if not docs:
print(f"{os.path.basename(fpath)}: empty, skip")
continue
print(f"{os.path.basename(fpath)}: {len(docs)} docs", end="")
for j in range(0, len(docs), 50):
batch_docs = docs[j:j+50]
batch_ids = ids[j:j+50]
data = json.dumps({"content": batch_docs}).encode()
for retry in range(3):
try:
req = urllib.request.Request(EF_URL, data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as resp:
body = json.loads(resp.read())
if isinstance(body, list):
embs = [item["embedding"][0] for item in body]
else:
embs = body.get("embedding", [])
collection.add(ids=batch_ids, documents=batch_docs, embeddings=embs)
print(".", end="", flush=True)
break
except Exception as e:
if retry < 2:
print(f"R{retry}", end="", flush=True)
time.sleep(10)
else:
print(f"X({e})", end="", flush=True)
time.sleep(0.3)
print(f" total={collection.count()}")
print(f"\nDone. Total: {collection.count()} documents")
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""RAG Query — search ChromaDB + answer via llama.cpp."""
import json, os, sys, urllib.request, time
import chromadb
LLAMA_URL = "http://localhost:8080/completion"
def llama_embed(texts):
data = json.dumps({"content": texts}).encode()
req = urllib.request.Request("http://localhost:8081/embedding", data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as resp:
body = json.loads(resp.read())
if isinstance(body, list):
return [item["embedding"][0] for item in body]
return body.get("embedding", [])
class LlamaEmbedFn:
def __call__(self, input):
all_embs = []
for i in range(0, len(input), 32):
batch = input[i:i+32]
embs = llama_embed(batch)
all_embs.extend(embs if isinstance(embs[0], list) else [embs])
return all_embs
def search(collection, query, n=5):
results = collection.query(query_texts=[query], n_results=n)
docs = []
for i in range(len(results["ids"][0])):
docs.append({
"id": results["ids"][0][i],
"doc": results["documents"][0][i],
"meta": results["metadatas"][0][i],
"distance": results["distances"][0][i],
})
return docs
def ask_llama(prompt, max_tokens=256):
data = json.dumps({
"prompt": prompt, "temperature": 0.1,
"n_predict": max_tokens,
"stop": ["\n\n", "Documentation:", "Question:"],
}).encode()
req = urllib.request.Request(LLAMA_URL, data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as resp:
return json.loads(resp.read()).get("content", "")
def main():
db_dir = os.path.expanduser("~/nubes/chroma_db")
collection_name = "elsa_docs"
ef = LlamaEmbedFn()
client = chromadb.PersistentClient(path=db_dir)
collection = client.get_collection(collection_name, embedding_function=ef)
print(f"Collection '{collection_name}': {collection.count()} documents")
while True:
try:
query = input("\n>>> ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not query:
continue
if query.lower() in ("exit", "quit", "q"):
break
# 1. Поиск
results = search(collection, query, n_results=5)
# 2. Формируем контекст
context_parts = []
for r in results:
title = r["meta"].get("title", "")[:120]
ctx = f"[{title}] {r['doc'][:1000]}"
context_parts.append(ctx)
context = "\n\n---\n\n".join(context_parts)
# 3. Промпт
prompt = f"""You are an expert VAG (VW/Audi/Skoda/SEAT) automotive diagnostician.
Answer the user's question using ONLY the provided repair documentation below.
If the answer is not in the documentation, say "Not found in documentation."
Answer in Russian.
DOCUMENTATION:
{context}
Question: {query}
Answer:"""
print(f"\n[Found {len(results)} matches, sending to LLM...]")
answer = ask_llama(prompt)
print(f"\n{answer}")
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""RAG test - embed query, search, answer via Qwen."""
import json, os, chromadb, urllib.request
def embed(texts):
data = json.dumps({"content": texts}).encode()
req = urllib.request.Request("http://localhost:8081/embedding", data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read())
if isinstance(body, list):
return [item["embedding"][0] for item in body]
return body.get("embedding", [])
os.chdir(os.path.expanduser("~/nubes"))
client = chromadb.PersistentClient(path=os.path.expanduser("~/nubes/chroma_db"))
collection = client.get_collection("elsa_docs")
# 1. Embed query
query = "What is the tightening torque for fuel filter on VW Phaeton 3.2 VR6 2004?"
print(f"Query: {query}")
q_emb = embed([query])[0]
# 2. Search
results = collection.query(query_embeddings=[q_emb], n_results=3)
print(f"Matches: {len(results['ids'][0])}")
for i in range(len(results['ids'][0])):
meta = results['metadatas'][0][i] or {}
dist = results['distances'][0][i]
print(f"\n[{i}] dist={dist:.3f}")
print(f" source: {meta.get('source','')}")
print(f" title: {meta.get('title','')[:120]}")
print(f" text: {results['documents'][0][i][:300]}...")
# 3. Build prompt for Qwen
context = "\n\n".join([f"[{m.get('title','')[:80] if m else ''}]\n{d[:1500]}"
for d, m in zip(results['documents'][0], results['metadatas'][0])])
prompt = f"""You are a VAG automotive diagnostician. Answer using ONLY the documentation below.
DOCUMENTATION:
{context}
Question: {query}
Answer:"""
data = json.dumps({"prompt": prompt, "temperature": 0.1, "n_predict": 300,
"stop": ["\n\n", "Answer:", "\nThe answer", "\nDocumentation"]}).encode()
req = urllib.request.Request("http://localhost:8080/completion", data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as resp:
answer = json.loads(resp.read()).get("content", "")
print(f"\n\n=== LLM Answer ===\n{answer}")
+29 -4
View File
@@ -2,13 +2,15 @@
Использование: Использование:
python -m vwts_scraper https://vwts.ru/forum/vag/benzinovye-dvigateli/ python -m vwts_scraper https://vwts.ru/forum/vag/benzinovye-dvigateli/
python -m vwts_scraper URL --workers 8 --no-delay
python -m vwts_scraper URL --workers 1 # однопоточно (как раньше)
Пауза: Ctrl+C → состояние сохраняется. Пауза: Ctrl+C → состояние сохраняется.
Продолжить: та же команда. Продолжить: та же команда.
""" """
import sys, logging import sys, logging
from .config import OUTPUT_DIR from .config import OUTPUT_DIR, WORKERS
OUTPUT_DIR.mkdir(parents=True, exist_ok=True) OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
@@ -23,14 +25,37 @@ logging.basicConfig(
) )
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) < 2 or "vwts.ru" not in sys.argv[1]: # ── Парсинг аргументов ──────────────────────────────────────────
args = sys.argv[1:]
if len(args) < 1 or "vwts.ru" not in args[0]:
print("❌ Укажи URL раздела vwts.ru. Пример:") print("❌ Укажи URL раздела vwts.ru. Пример:")
print(" python -m vwts_scraper " print(" python -m vwts_scraper "
"https://vwts.ru/forum/vag/benzinovye-dvigateli/") "https://vwts.ru/forum/vag/benzinovye-dvigateli/")
print("\n⚙️ Опции:")
print(" --workers N количество потоков (по умолчанию 4)")
print(" --no-delay без координации задержек")
print(" --no-ban-test не проверять бан")
sys.exit(1) sys.exit(1)
section_url = args[0].rstrip("/") + "/"
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 from .run import run
section_url = sys.argv[1].rstrip("/") + "/"
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.info("=" * 60) log.info("=" * 60)
@@ -40,7 +65,7 @@ if __name__ == "__main__":
log.info("=" * 60) log.info("=" * 60)
try: try:
run(section_url) run(section_url, workers=workers, no_delay=no_delay, ban_test=ban_test)
except KeyboardInterrupt: except KeyboardInterrupt:
log.info("\n🛑 ПАУЗА. Продолжить: та же команда.") log.info("\n🛑 ПАУЗА. Продолжить: та же команда.")
sys.exit(0) sys.exit(0)
+2
View File
@@ -10,6 +10,8 @@ OUTPUT_DIR = Path("vwts_data") # куда сохраняем данны
DELAY = 1.5 # секунд между запросами DELAY = 1.5 # секунд между запросами
TIMEOUT = (10, 30) # (connect, read) TIMEOUT = (10, 30) # (connect, read)
TOPICS_PER_FILE = 100 TOPICS_PER_FILE = 100
WORKERS = 4 # потоков по умолчанию
BAN_TEST = 5 # запросов для автоопределения бана
HEADERS = { HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
} }
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
set -e
WORKERS=4
if [ "$1" = "--1" ]; then
WORKERS=1
echo "Режим: 1 поток"
else
echo "Режим: $WORKERS потока"
fi
echo ""
SECTIONS=(
"https://vwts.ru/forum/vag/sistema-ohlazhdeniya-otoplenie-konditsionirovanie/"
"https://vwts.ru/forum/vag/sistema-smazki/"
"https://vwts.ru/forum/vag/sistemy-vpryska-i-zazhiganiya/"
"https://vwts.ru/forum/vag/podveska/"
"https://vwts.ru/forum/vag/rulevoe-upravlenie/"
"https://vwts.ru/forum/vag/tormoznaya-sistema/"
"https://vwts.ru/forum/vag/korobki-peredach-stseplenie/"
"https://vwts.ru/forum/vag/dokumentatsiya-po-volkswagen/"
"https://vwts.ru/forum/vag/baza-znaniy-diagnostika-i-neispravnosti/"
"https://vwts.ru/forum/vag/baza-znaniy-benzinovye-dvigateli/"
"https://vwts.ru/forum/vag/baza-znaniy-dizelnye-dvigateli/"
"https://vwts.ru/forum/vag/baza-znaniy-sistema-ohlazhdeniya/"
"https://vwts.ru/forum/vag/baza-znaniy-sistema-smazki/"
"https://vwts.ru/forum/vag/baza-znaniy-sistemy-vpryska-i-zazhiganiya/"
"https://vwts.ru/forum/vag/baza-znaniy-toplivnaya-sistema/"
"https://vwts.ru/forum/vag/baza-znaniy-vypusknaya-sistema/"
"https://vwts.ru/forum/vag/baza-znaniy-podveska/"
"https://vwts.ru/forum/vag/baza-znaniy-rulevoe-upravlenie/"
"https://vwts.ru/forum/vag/baza-znaniy-tormoznaya-sistema/"
"https://vwts.ru/forum/vag/baza-znaniy-elektrooborudovanie/"
"https://vwts.ru/forum/vag/elektronnye-sistemy-bloki-upravleniya-vag/"
"https://vwts.ru/forum/vag/baza-znaniy-korobki-peredach-stsepleniya/"
)
BANNED=false
TOTAL=${#SECTIONS[@]}
CURRENT=0
for URL in "${SECTIONS[@]}"; do
CURRENT=$((CURRENT + 1))
SLUG=$(echo "$URL" | sed 's|/$||' | awk -F/ '{print $NF}')
echo ""
echo "========================================"
echo "[$CURRENT/$TOTAL] Проверка бана для: $SLUG"
echo "========================================"
python3 << 'PYEOF'
import sys, logging, requests
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S')
log = logging.getLogger()
url = "'"$URL"'"
log.info('Проверка: 5 запросов...')
s = requests.Session()
s.headers.update({'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'})
ban = False
for i in range(5):
r = s.get(url, timeout=(10, 30))
log.info(' [%d/5] HTTP %d' % (i+1, r.status_code))
if r.status_code in (403, 429):
ban = True
break
s.close()
sys.exit(1 if ban else 0)
PYEOF
EXIT=$?
if [ $EXIT -eq 1 ]; then
echo ""
echo "=== БАН! Многопоточный режим не работает. ==="
echo "Перезапусти с --1 для 1 потока"
BANNED=true
break
fi
echo ""
echo "========================================"
echo "[$CURRENT/$TOTAL] Загрузка: $SLUG"
echo "========================================"
echo ""
python3 -m vwts_scraper "$URL" --workers $WORKERS
echo ""
echo "OK [$CURRENT/$TOTAL] $SLUG готов"
echo ""
done
if [ "$BANNED" = false ]; then
echo "=== ВСЕ РАЗДЕЛЫ ЗАГРУЖЕНЫ ==="
fi
+24 -5
View File
@@ -7,13 +7,32 @@ from .config import DELAY, TIMEOUT, HEADERS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
session = requests.Session() # Глобальный throttle — устанавливается из run()
session.headers.update(HEADERS) throttle = None
def get(url: str) -> BeautifulSoup | None: def make_session() -> requests.Session:
"""GET + ретраи (до 3). 404 — без ретрая, сразу None.""" """Создать новую сессию (thread-safe)."""
time.sleep(DELAY) s = requests.Session()
s.headers.update(HEADERS)
return s
def get(url: str, session: requests.Session = None) -> BeautifulSoup | None:
"""GET + ретраи (до 3). 404 — без ретрая, сразу None.
Args:
url: URL для загрузки
session: опциональная сессия (если None — создаётся временная)
"""
if session is None:
session = make_session()
if throttle:
throttle.wait()
else:
time.sleep(DELAY)
for n in range(3): for n in range(3):
try: try:
r = session.get(url, timeout=TIMEOUT) r = session.get(url, timeout=TIMEOUT)
+120
View File
@@ -0,0 +1,120 @@
# Список разделов vwts.ru/forum
## ✅ Уже скачано
- [x] **Бензиновые двигатели** — 4 316 / 4 560 тем
- [x] **Дизельные двигатели** — 3 703 / 3 857 тем
- [x] **Топливная система** — ~200+ / 326 тем
- [x] **Диагностика** — ~2 300+ / 2 323 темы
- [x] **Электрооборудование** — 9 936 / 13 715 тем
- [x] **Автомобильная электроника и аксессуары** — 35 / 36 тем
## ⬜ Не скачано — отметь что загружать
### Ремонт и обслуживание
- [X] **Система охлаждения, отопление, кондиционирование** — 6 133 темы
`https://vwts.ru/forum/vag/sistema-ohlazhdeniya-otoplenie-konditsionirovanie/`
- [X] **Система смазки** — 258 тем
`https://vwts.ru/forum/vag/sistema-smazki/`
- [X] **Системы впрыска и зажигания** — 3 435 тем
`https://vwts.ru/forum/vag/sistemy-vpryska-i-zazhiganiya/`
- [ ] **Выпускная система** — 1 064 темы
`https://vwts.ru/forum/vag/vypusknaya-sistema/`
- [X] **Подвеска** — 4 304 темы
`https://vwts.ru/forum/vag/podveska/`
- [X] **Рулевое управление** — 2 383 темы
`https://vwts.ru/forum/vag/rulevoe-upravlenie/`
- [X] **Тормозная система** — 2 736 тем
`https://vwts.ru/forum/vag/tormoznaya-sistema/`
- [X] **Коробки передач, сцепление** — 6 165 тем
`https://vwts.ru/forum/vag/korobki-peredach-stseplenie/`
- [ ] **Кузов** — 5 480 тем
`https://vwts.ru/forum/vag/kuzov/`
- [ ] **Шины / Диски** — 1 262 темы
`https://vwts.ru/forum/vag/shiny-diski/`
- [ ] **Горюче-смазочные материалы** — 606 тем
`https://vwts.ru/forum/vag/goryuche-smazochnye-materialy/`
- [ ] **Разное** — 3 359 тем
`https://vwts.ru/forum/vag/raznoe/`
- [ ] **Гараж / инструменты** — 471 тема
`https://vwts.ru/forum/vag/garazh-instrumenty/`
- [ ] **Автосигнализации и защита от угона** — 1 431 тема
`https://vwts.ru/forum/vag/avtosignalizatsii-i-zaschita-ot-ugona/`
- [ ] **Сервисы, официальные дилеры** — 1 474 темы
`https://vwts.ru/forum/vag/servisy-ofitsialnye-dilery/`
- [ ] **Тюнинг** — 1 695 тем
`https://vwts.ru/forum/vag/tyuning/`
- [ ] **Автозвук** — 2 514 тем
`https://vwts.ru/forum/vag/avtozvuk/`
- [ ] **Наши машины** — 460 тем
`https://vwts.ru/forum/vag/nashi-mashiny/`
- [ ] **Оперативные вопросы (технические)** — 1 677 тем
`https://vwts.ru/forum/vag/operativnye-voprosy-tehnicheskie/`
- [X] **Документация по Volkswagen** — 1 413 тем
`https://vwts.ru/forum/vag/dokumentatsiya-po-volkswagen/`
### Модели VAG
- [ ] **Volkswagen** — 41 тема
`https://vwts.ru/forum/vag/volkswagen/`
### Базы знаний
- [X] **База знаний "Диагностика"**
`https://vwts.ru/forum/vag/baza-znaniy-diagnostika-i-neispravnosti/`
- [X] **База знаний "Бензиновые двигатели"**
`https://vwts.ru/forum/vag/baza-znaniy-benzinovye-dvigateli/`
- [X] **База знаний "Дизельные двигатели"**
`https://vwts.ru/forum/vag/baza-znaniy-dizelnye-dvigateli/`
- [X] **База знаний "Система охлаждения"**
`https://vwts.ru/forum/vag/baza-znaniy-sistema-ohlazhdeniya/`
- [X] **База знаний "Система смазки"**
`https://vwts.ru/forum/vag/baza-znaniy-sistema-smazki/`
- [X] **База знаний "Системы впрыска и зажигания"**
`https://vwts.ru/forum/vag/baza-znaniy-sistemy-vpryska-i-zazhiganiya/`
- [X] **База знаний "Топливная система"**
`https://vwts.ru/forum/vag/baza-znaniy-toplivnaya-sistema/`
- [X] **База знаний "Выпускная система"**
`https://vwts.ru/forum/vag/baza-znaniy-vypusknaya-sistema/`
- [X] **База знаний "Подвеска"**
`https://vwts.ru/forum/vag/baza-znaniy-podveska/`
- [X] **База знаний "Рулевое управление"**
`https://vwts.ru/forum/vag/baza-znaniy-rulevoe-upravlenie/`
- [X] **База знаний "Тормозная система"**
`https://vwts.ru/forum/vag/baza-znaniy-tormoznaya-sistema/`
- [X] **База знаний "Электрооборудование"**
`https://vwts.ru/forum/vag/baza-znaniy-elektrooborudovanie/`
- [X] **Электронные системы, блоки управления VAG**
`https://vwts.ru/forum/vag/elektronnye-sistemy-bloki-upravleniya-vag/`
- [X] **База знаний "Коробки передач, сцепления"**
`https://vwts.ru/forum/vag/baza-znaniy-korobki-peredach-stsepleniya/`
- [ ] **База знаний "Кузов"**
`https://vwts.ru/forum/vag/baza-znaniy-kuzov/`
- [ ] **База знаний "Разное"**
`https://vwts.ru/forum/vag/baza-znaniy-raznoe/`
- [ ] **База знаний "Гараж / Инструменты"**
`https://vwts.ru/forum/vag/baza-znaniy-garazh--instrumenty/`
- [ ] **База знаний "Тюнинг"**
`https://vwts.ru/forum/vag/baza-znaniy-tyuning/`
- [ ] **База знаний "Автозвук"**
`https://vwts.ru/forum/vag/baza-znaniy-avtozvuk/`
+10 -3
View File
@@ -2,14 +2,21 @@
import json, logging import json, logging
from datetime import datetime from datetime import datetime
from pathlib import Path
from .config import TOPICS_PER_FILE, OUTPUT_DIR from .config import TOPICS_PER_FILE, OUTPUT_DIR
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def save_topic(section_slug: str, section_name: str, topic: dict, def save_topic(section_slug: str, section_name: str, topic: dict,
posts: list, tags: list, state: dict): posts: list, tags: list, state: dict,
"""Записать тему в part_XXXX.jsonl. Ротирует файл каждые N тем.""" worker_dir: Path = None):
"""Записать тему в part_XXXX.jsonl. Ротирует файл каждые N тем.
Args:
worker_dir: если задано — писать в эту папку (для многопоточности),
иначе в OUTPUT_DIR / section_slug
"""
st = state st = state
# ротация # ротация
@@ -27,7 +34,7 @@ def save_topic(section_slug: str, section_name: str, topic: dict,
"scraped_at": datetime.now().isoformat(), "scraped_at": datetime.now().isoformat(),
} }
d = OUTPUT_DIR / section_slug d = worker_dir if worker_dir else (OUTPUT_DIR / section_slug)
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
fpath = d / f"part_{st['file_index']:04d}.jsonl" fpath = d / f"part_{st['file_index']:04d}.jsonl"
+159 -80
View File
@@ -1,35 +1,148 @@
"""Основной цикл: обход страниц раздела → темы → посты → сохранение.""" """Основной цикл: ThreadPoolExecutor + диапазоны страниц → мерж."""
import sys, logging import sys, logging, shutil, json
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from .config import OUTPUT_DIR from .config import OUTPUT_DIR, WORKERS, BAN_TEST, DELAY
from .fetch import get, session from .fetch import get, make_session
from .paginate import count, verify_last from .paginate import count, verify_last
from .parse import parse_topics, parse_posts, parse_tags from .parse import parse_topics, parse_posts, parse_tags
from .state import load, save from .state import load, save, merge_states
from .output import save_topic from .output import save_topic
from .throttle import Throttle, BanDetector
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def run(section_url: str): # ── Глобальный throttle (устанавливается в run()) ─────────────────────
"""Главный цикл скрейпера. _throttle = None
import fetch as _fetch
def _worker(section_url: str, section_slug: str, section_name: str,
pages: range, worker_id: int):
"""Один поток: обходит свой диапазон страниц, пишет в worker_N/."""
session = make_session()
st = {"topics_done": {}, "pages_done": [], "file_index": 0, "topic_count": 0}
base = f"{urlparse(section_url).scheme}://{urlparse(section_url).netloc}"
consecutive_404 = 0
# Папка воркера
worker_dir = OUTPUT_DIR / section_slug / f"worker_{worker_id}"
worker_dir.mkdir(parents=True, exist_ok=True)
for pg in pages:
url = section_url if pg == 1 else f"{section_url}page-{pg}"
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
log.info(f"[W{worker_id}] [{i}/{len(topics)}] #{tid}: {tp['title'][:80]}")
topic_soup = get(tp["url"], session)
if not topic_soup:
st["topics_done"][str(tid)] = tp["title"]
continue
topic_pages = count(topic_soup)
posts, tags = [], parse_tags(topic_soup)
for tpp in range(1, topic_pages + 1):
if tpp == 1:
posts += parse_posts(topic_soup)
else:
tp2 = get(f"{base}/forum/topic/{tid}/page-{tpp}", session)
if tp2:
posts += parse_posts(tp2)
log.info(f"[W{worker_id}] {len(posts)} постов ({topic_pages} стр.), теги: {tags}")
save_topic(section_slug, section_name, tp, posts, tags, 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(section_slug: str):
"""Собрать part_*.jsonl из всех worker_N/ в корень раздела с единой нумерацией."""
root = OUTPUT_DIR / section_slug
# Собираем все part_*.jsonl из worker_N/
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))
# Удаляем пустые worker-папки
for wdir in root.glob("worker_*"):
if wdir.is_dir():
try:
wdir.rmdir()
except OSError:
pass
# Пишем объединённый state.json
merge_states(section_slug)
log.info(f"✅ Мерж: {len(all_parts)} файлов → {root}")
def run(section_url: str, workers: int = WORKERS,
no_delay: bool = False, ban_test: bool = True):
"""Главный цикл с многопоточностью.
Args: Args:
section_url: полный URL раздела, напр. section_url: полный URL раздела
https://vwts.ru/forum/vag/benzinovye-dvigateli/ workers: количество потоков
no_delay: True = без координации задержек
ban_test: True = проверить бан перед стартом
""" """
section_slug = section_url.rstrip("/").split("/")[-1] section_slug = section_url.rstrip("/").split("/")[-1]
# загружаем состояние (если было прервано — продолжим) # ── Автоопределение бана ──────────────────────────────────────────
st = load(section_slug) throttle_enabled = not no_delay
if st["topics_done"]: if ban_test and throttle_enabled and workers > 1:
log.info(f"🔄 Продолжаем: {len(st['topics_done'])} тем, " if not BanDetector.test(section_url, BAN_TEST):
f"part_{st['file_index']:04d}.jsonl") throttle_enabled = False
# ── Страница 1: название раздела + количество страниц ──────────────── global _throttle
soup = get(section_url) _throttle = Throttle(DELAY, enabled=throttle_enabled)
_fetch.throttle = _throttle
log.info(f"⚙️ Потоков: {workers}, координация: {'вкл' if throttle_enabled else 'выкл'}")
# ── Страница 1: название + количество страниц ─────────────────────
soup = get(section_url, make_session())
if not soup: if not soup:
log.error("❌ Сайт недоступен") log.error("❌ Сайт недоступен")
sys.exit(1) sys.exit(1)
@@ -38,78 +151,44 @@ def run(section_url: str):
section_name = section_name.text.strip() if section_name else section_slug section_name = section_name.text.strip() if section_name else section_slug
total_pages = count(soup) total_pages = count(soup)
# Проверяем: последняя страница реально существует? total_pages = verify_last(section_url, total_pages, make_session())
total_pages = verify_last(section_url, total_pages, session)
log.info(f"📋 {section_name} | страниц: {total_pages}") log.info(f"📋 {section_name} | страниц: {total_pages}")
# ── Обход страниц раздела ─────────────────────────────────────────── # ── Диапазоны страниц ─────────────────────────────────────────────
consecutive_404 = 0 if workers > total_pages:
workers = total_pages
for pg in range(1, total_pages + 1): base_size = total_pages // workers
if pg in st["pages_done"]: remainder = total_pages % workers
consecutive_404 = 0 ranges = []
continue start = 1
for w in range(workers):
size = base_size + (1 if w < remainder else 0)
ranges.append(range(start, start + size))
start += size
# page-1 = базовый URL (чтобы не было лишнего редиректа) log.info(f"📦 Диапазоны: {[f'{r.start}-{r[-1]}' for r in ranges]}")
url = section_url if pg == 1 else f"{section_url}page-{pg}"
log.info(f"📄 [{pg}/{total_pages}]")
page_soup = get(url) # ── Запуск потоков ────────────────────────────────────────────────
if not page_soup: total_topics = 0
consecutive_404 += 1 with ThreadPoolExecutor(max_workers=workers) as pool:
# 3 пустых страницы подряд = раздел закончился раньше futures = [
if consecutive_404 >= 3: pool.submit(_worker, section_url, section_slug, section_name,
log.info(f" ⏹️ {consecutive_404} пустых подряд — завершаем") rng, i)
break for i, rng in enumerate(ranges)
log.warning(f" ⚠️ Пропущена стр.{pg} " ]
f"(пустых подряд: {consecutive_404})") for fut in as_completed(futures):
continue wid, cnt = fut.result()
consecutive_404 = 0 total_topics += cnt
log.info(f"[W{wid}] ✅ завершён: {cnt} тем")
topics = parse_topics(page_soup) # ── Мерж ──────────────────────────────────────────────────────────
log.info(f" Тем: {len(topics)}") _merge_workers(section_slug)
# ── Обход тем на странице ────────────────────────────────────── # ── Итог ──────────────────────────────────────────────────────────
for i, tp in enumerate(topics, 1):
tid = tp["id"]
if str(tid) in st["topics_done"]:
continue
log.info(f" [{i}/{len(topics)}] #{tid}: {tp['title'][:80]}")
topic_soup = get(tp["url"])
if not topic_soup:
# не удалось — помечаем чтобы не ретраить бесконечно
st["topics_done"][str(tid)] = tp["title"]
save(section_slug, st)
continue
topic_pages = count(topic_soup)
posts, tags = [], parse_tags(topic_soup)
# ── Обход страниц темы ────────────────────────────────────
base = f"{urlparse(section_url).scheme}://{urlparse(section_url).netloc}"
for tpp in range(1, topic_pages + 1):
if tpp == 1:
posts += parse_posts(topic_soup)
else:
tp2 = get(f"{base}/forum/topic/{tid}/page-{tpp}")
if tp2:
posts += parse_posts(tp2)
log.info(f" {len(posts)} постов ({topic_pages} стр.), "
f"теги: {tags}")
save_topic(section_slug, section_name, tp, posts, tags, st)
st["topics_done"][str(tid)] = tp["title"]
save(section_slug, st)
st["pages_done"].append(pg)
save(section_slug, st)
# ── Итог ────────────────────────────────────────────────────────────
log.info("=" * 60) log.info("=" * 60)
log.info(f"'{section_name}'{st['topic_count']} тем " log.info(f"'{section_name}'{total_topics} тем "
f"в {st['file_index']+1} частях") f"в {len(list((OUTPUT_DIR/section_slug).glob('part_*.jsonl')))} частях")
for f in sorted((OUTPUT_DIR / section_slug).glob("part_*.jsonl")): for f in sorted((OUTPUT_DIR / section_slug).glob("part_*.jsonl")):
log.info(f" 📄 {f.name} ({f.stat().st_size/1024/1024:.1f} MB)") log.info(f" 📄 {f.name} ({f.stat().st_size/1024/1024:.1f} MB)")
log.info("=" * 60) log.info("=" * 60)
+27 -1
View File
@@ -1,8 +1,11 @@
"""Управление состоянием (state.json в папке раздела).""" """Управление состоянием (state.json в папке раздела)."""
import json import json, logging
from pathlib import Path
from .config import OUTPUT_DIR from .config import OUTPUT_DIR
log = logging.getLogger(__name__)
def _state_file(section_slug: str) -> Path: def _state_file(section_slug: str) -> Path:
d = OUTPUT_DIR / section_slug d = OUTPUT_DIR / section_slug
@@ -20,3 +23,26 @@ def load(section_slug: str) -> dict:
def save(section_slug: str, st: dict): def save(section_slug: str, st: dict):
_state_file(section_slug).write_text( _state_file(section_slug).write_text(
json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8") 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")
+78
View File
@@ -0,0 +1,78 @@
"""Общая координация задержек и автоопределение бана."""
import time, threading, logging
import requests
from .config import DELAY, TIMEOUT, HEADERS
log = logging.getLogger(__name__)
class Throttle:
"""Thread-safe координатор задержек.
Все потоки ждут своей очереди — максимум 1 запрос в DELAY секунд.
"""
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):
"""Ждать своей очереди. Без координации — просто sleep(delay)."""
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:
"""Шлёт count запросов без задержки. Возвращает True если банят.
Args:
base_url: любой URL того же хоста (напр. главная раздела)
count: сколько запросов подряд
Returns:
True — банит (есть 403/429), False — не банит
"""
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