Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab90aa312 | ||
|
|
ecb96761e7 | ||
|
|
84ae6cfa9d | ||
|
|
89d2301fdf | ||
|
|
99e6fad861 | ||
|
|
78505d733e | ||
|
|
5c142096de | ||
|
|
673bfe5e2a | ||
|
|
eca5e3cda6 | ||
|
|
928c979509 | ||
|
|
c3211f0602 |
@@ -13,3 +13,4 @@ __pycache__/
|
|||||||
VWTS/
|
VWTS/
|
||||||
vwts_data/
|
vwts_data/
|
||||||
vwts_scraper.py
|
vwts_scraper.py
|
||||||
|
lada_data/
|
||||||
|
|||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
# Парсеры автомобильных форумов для RAG
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Выкачать техническую информацию с автомобильных форумов для создания RAG-базы знаний по ремонту автомобилей.
|
||||||
|
|
||||||
|
## Источники данных
|
||||||
|
|
||||||
|
### 1. vwts.ru — VAG (Volkswagen/Audi/Škoda/SEAT)
|
||||||
|
|
||||||
|
- **Движок:** XenForo
|
||||||
|
- **~60 000+ тем, ~1.2 млн сообщений**
|
||||||
|
- **Парсер:** `vwts_scraper/`
|
||||||
|
- Структура URL:
|
||||||
|
- Раздел: /forum/vag/{slug}/ (пагинация: page-N)
|
||||||
|
- Тема: /forum/topic/{id}/ (пагинация: page-N)
|
||||||
|
- Поиск: /forum/search/
|
||||||
|
- Теги: /forum/tags/
|
||||||
|
- RSS: /forum/vag/-/index.rss
|
||||||
|
|
||||||
|
### 2. нива-лада.рф — Lada Niva / Chevrolet Niva / ВАЗ
|
||||||
|
|
||||||
|
- **Движок:** phpBB (subsilver2) + Board3 Portal
|
||||||
|
- **~20 технических разделов, активный, без CloudFlare**
|
||||||
|
- **Парсер:** `lada_scraper/`
|
||||||
|
- Структура URL:
|
||||||
|
- Раздел: /n/viewforum.php?f=ID (пагинация: ?start=N)
|
||||||
|
- Тема: /n/viewtopic.php?f=ID&t=ID (пагинация: ?start=N)
|
||||||
|
- RSS: /n/feed.php
|
||||||
|
|
||||||
|
### 3. Статьи vwts.ru
|
||||||
|
- ~500+ статей по ремонту VAG
|
||||||
|
- Sitemap: https://vwts.ru/sitemap.xml
|
||||||
|
|
||||||
|
## Разделы форума (18 шт)
|
||||||
|
|
||||||
|
| Раздел | Slug | Тем | Постов |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Диагностика | diagnostika | 2 322 | 36 052 |
|
||||||
|
| Бензиновые двигатели | benzinovye-dvigateli | 4 559 | 98 158 |
|
||||||
|
| Дизельные двигатели | dizelnye-dvigateli | 3 857 | 101 174 |
|
||||||
|
| Система охлаждения/отопление/кондиц. | sistema-ohlazhdeniya-otoplenie-konditsionirovanie | 6 133 | 90 689 |
|
||||||
|
| Система смазки | sistema-smazki | 258 | 11 723 |
|
||||||
|
| Системы впрыска и зажигания | sistemy-vpryska-i-zazhiganiya | 3 435 | 87 636 |
|
||||||
|
| Топливная система | toplivnaya-sistema | 326 | 4 916 |
|
||||||
|
| Выпускная система | vypusknaya-sistema | 1 064 | 16 171 |
|
||||||
|
| Подвеска | podveska | 4 304 | 56 200 |
|
||||||
|
| Рулевое управление | rulevoe-upravlenie | 2 383 | 27 212 |
|
||||||
|
| Тормозная система | tormoznaya-sistema | 2 736 | 39 673 |
|
||||||
|
| Электрооборудование | elektrooborudovanie | 13 715 | 146 883 |
|
||||||
|
| Коробки передач, сцепление | korobki-peredach-stseplenie | 6 165 | 67 286 |
|
||||||
|
| Кузов | kuzov | 5 480 | 57 564 |
|
||||||
|
| Шины / Диски | shiny-diski | 1 262 | 18 866 |
|
||||||
|
| ГСМ | goryuche-smazochnye-materialy | 606 | 15 286 |
|
||||||
|
| Разное | raznoe | 3 359 | 35 918 |
|
||||||
|
| Гараж / инструменты | garazh-instrumenty | 471 | 9 630 |
|
||||||
|
| Автосигнализации | avtosignalizatsii-i-zaschita-ot-ugona | 1 431 | 13 763 |
|
||||||
|
| Сервисы, дилеры | servisy-ofitsialnye-dilery | 1 474 | 13 244 |
|
||||||
|
| Тюнинг | tyuning | 1 695 | 41 043 |
|
||||||
|
| Автозвук | avtozvuk | 2 514 | 46 462 |
|
||||||
|
| Автоэлектроника | avtomobilnaya-elektronika-i-aksessuary | 36 | 1 740 |
|
||||||
|
| Наши машины | nashi-mashiny | 460 | 66 809 |
|
||||||
|
| Оперативные вопросы | operativnye-voprosy-tehnicheskie | 1 677 | 31 579 |
|
||||||
|
| Документация по VW | dokumentatsiya-po-volkswagen | 1 413 | 9 396 |
|
||||||
|
| Модели VW | volkswagen | 41 | 248 |
|
||||||
|
| Skoda | skoda | 17 | 59 |
|
||||||
|
| Электромобили VAG | electric-cars | 21 | 108 |
|
||||||
|
| Отзывы, выбор, покупка | otzyvy-vybor-pokupka-avtomobilya | 2 190 | 56 837 |
|
||||||
|
| Покупка/продажа авто | pokupka-i-prodazha-avtomobiley | 89 | 1 978 |
|
||||||
|
| Отчёты об эксплуатации | otchety-o-ekspluatatsii-avtomobiley | 38 | 1 298 |
|
||||||
|
| Покупка/продажа запчастей | pokupka--prodazha-zapchastey | 122 | 1 348 |
|
||||||
|
| Отзывы о магазинах | otzyvy-o-magazinah-zapchastey-razborkah | 427 | 4 261 |
|
||||||
|
| Барахолка | baraholka | 18 | 38 |
|
||||||
|
| ГАИ и законодательство | gai-i-zakonodatelstvo | 1 564 | 24 634 |
|
||||||
|
| Дороги, поездки | dorogi-poezdki-nashi-puteshestviya | 454 | 11 895 |
|
||||||
|
|
||||||
|
## Скрипт (пакет Python)
|
||||||
|
|
||||||
|
### Структура
|
||||||
|
```
|
||||||
|
vwts_scraper/
|
||||||
|
├── __init__.py # package
|
||||||
|
├── __main__.py # CLI entry point
|
||||||
|
├── config.py # константы (DELAY, TIMEOUT, HEADERS)
|
||||||
|
├── fetch.py # HTTP GET с ретраями
|
||||||
|
├── paginate.py # page_count + HEAD-проверка границ
|
||||||
|
├── parse.py # парсинг HTML (темы, посты, теги)
|
||||||
|
├── state.py # state.json (per-section)
|
||||||
|
├── output.py # JSONL с ротацией
|
||||||
|
└── run.py # главный цикл обхода
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск
|
||||||
|
```bash
|
||||||
|
python -m vwts_scraper https://vwts.ru/forum/vag/benzinovye-dvigateli/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пауза / продолжение
|
||||||
|
- Ctrl+C — пауза, состояние сохраняется в `vwts_data/{slug}/state.json`
|
||||||
|
- Повторить команду — продолжит с места остановки
|
||||||
|
- Разные разделы не конфликтуют (у каждого своя папка + state.json)
|
||||||
|
|
||||||
|
### Вывод
|
||||||
|
- `vwts_data/{slug}/part_0001.jsonl`, `part_0002.jsonl`... — по 100 тем в файле
|
||||||
|
- Формат строки JSONL:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"section": "Диагностика",
|
||||||
|
"section_slug": "diagnostika",
|
||||||
|
"topic_id": 253735,
|
||||||
|
"topic_title": "ошибка 00513. Заглох Passat B3 VR6",
|
||||||
|
"topic_url": "https://vwts.ru/forum/topic/253735/",
|
||||||
|
"total_posts": 98,
|
||||||
|
"posts": [
|
||||||
|
{"author": "Wizard13", "date": "2026-05-27T10:00:00", "num": "#1", "text": "...", "tags": ["passat b3", "vr6"]},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"scraped_at": "2026-06-03T20:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Оценка времени
|
||||||
|
- Задержка между запросами: 1.5 сек
|
||||||
|
- В среднем 2 запроса на тему (список + посты)
|
||||||
|
- ~120 000 запросов → ~50 часов чистого времени
|
||||||
|
- Реально с учётом ошибок/повторов: ~3-5 дней
|
||||||
|
|
||||||
|
## Resume (продолжение с места остановки)
|
||||||
|
- state.json хранит какие разделы завершены и какие темы обработаны
|
||||||
|
- При перезапуске пропускает готовые темы и разделы
|
||||||
|
- Данные дописываются в существующие JSONL-файлы (append)
|
||||||
|
|
||||||
|
## Для RAG
|
||||||
|
Каждый пост — отдельный chunk с метаданными:
|
||||||
|
- `section` — раздел форума
|
||||||
|
- `topic_title` — название темы
|
||||||
|
- `tags` — теги темы
|
||||||
|
- `author` — автор
|
||||||
|
- `date` — дата
|
||||||
|
- `text` — содержимое поста
|
||||||
|
|
||||||
|
Можно индексировать в любую векторную БД (Chroma, Qdrant и т.д.).
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Анализ зарубежных VAG-форумов для парсинга
|
||||||
|
|
||||||
|
Дата: 2026-06-04
|
||||||
|
|
||||||
|
## Проверенные форумы
|
||||||
|
|
||||||
|
### 1. vwforum.com (VW общий)
|
||||||
|
- Адрес: https://www.vwforum.com/
|
||||||
|
- Движок: XenForo (кастомная сборка XenForo Cloud)
|
||||||
|
- Постов: ~465K, участников: ~84K
|
||||||
|
- Доступ из РФ: ✅ (открывается, но нестабильно)
|
||||||
|
|
||||||
|
**Проблемы:**
|
||||||
|
- ❌ Пагинация разделов отсутствует — показываются только последние темы
|
||||||
|
- ❌ HTTP 406 (Not Acceptable) на `page-2` — пагинация отключена на сервере
|
||||||
|
- ❌ После 2–3 запросов — Read timeout (защита от парсинга)
|
||||||
|
- ❌ Невозможно получить список ВСЕХ тем раздела
|
||||||
|
|
||||||
|
### 2. audiforum.us (Audi)
|
||||||
|
- Адрес: https://www.audiforum.us/
|
||||||
|
- Движок: XenForo (та же кастомная сборка, что и vwforum.com)
|
||||||
|
- Постов: ~105K, участников: ~15K
|
||||||
|
- Доступ из РФ: ✅
|
||||||
|
|
||||||
|
**Проблемы:** те же, что у vwforum.com (оба на одной платформе)
|
||||||
|
|
||||||
|
### 3. Другие (недоступны из РФ)
|
||||||
|
|
||||||
|
| Форум | Причина |
|
||||||
|
|---|---|
|
||||||
|
| vwvortex.com | Cloudflare |
|
||||||
|
| forums.tdiclub.com | Cloudflare |
|
||||||
|
| briskoda.net | Cloudflare |
|
||||||
|
| vwaudiforum.co.uk | DNS не резолвится |
|
||||||
|
| volkswagenforum.co.uk | Таймаут |
|
||||||
|
| passatworld.com | Таймаут |
|
||||||
|
|
||||||
|
## Выводы
|
||||||
|
|
||||||
|
Совместимость с нашим скриптом определяется по двум критериям:
|
||||||
|
|
||||||
|
| Критерий | vwts.ru | vwforum.com / audiforum.us |
|
||||||
|
|---|---|---|
|
||||||
|
| Пагинация разделов (`div.pageNav`) | ✅ | ❌ (нет пагинации) |
|
||||||
|
| Доступность (не блокирует) | ✅ | ❌ (таямауты после 2-3 запросов) |
|
||||||
|
| `page_count()` работает | ✅ | ❌ |
|
||||||
|
| Полнота данных (все темы) | ✅ | ❌ (только последние) |
|
||||||
|
| Формат URL page-N | ✅ | ❌ (HTTP 406) |
|
||||||
|
|
||||||
|
**Рекомендация:** искать форумы с:
|
||||||
|
1. Классическим XenForo (не XenForo Cloud)
|
||||||
|
2. Полноценной пагинацией `div.pageNav`
|
||||||
|
3. Доступом без Cloudflare
|
||||||
|
4. Возможностью парсинга через requests (без JS)
|
||||||
|
5. Для phpBB — пагинация через `?start=N` (subsilver2 стиль)
|
||||||
|
|
||||||
|
Пример подходящих: vwts.ru (XenForo), нива-лада.рф (phpBB).
|
||||||
|
|
||||||
|
### Проверка немецких Mercedes-форумов (2026-06-04, с германской ВМ 95.179.252.111)
|
||||||
|
|
||||||
|
| Форум | Движок | Статус | Причина |
|
||||||
|
|-------|--------|--------|---------|
|
||||||
|
| benzworld.org | XenForo | ❌ 409 | `xf_is_suspected_bot=1` — бот-детекция, даже с DE IP |
|
||||||
|
| mercedes-benz-forum.com | — | ❌ | DNS не резолвится |
|
||||||
|
| mercedes-treff.de | — | ❌ | Таймаут (SSL handshake доходит, ответа нет) |
|
||||||
|
| motor-talk.de | React SPA | ❌ | CloudFlare + контент только через JS |
|
||||||
|
| mercedes-fans.de | Symfony | ❌ | Не форум, а онлайн-журнал |
|
||||||
|
| mercedesforum.de, mb-forum.de, forum.mb-passion.com и др. | — | ❌ | Не отвечают / DNS не резолвится |
|
||||||
|
|
||||||
|
**Итог:** доступных немецких форумов по Mercedes для парсинга не найдено.
|
||||||
|
benzworld.org — единственный крупный XenForo (7.9M постов), но блокирует даже с немецкого IP.
|
||||||
|
|
||||||
|
## Тесты page_count
|
||||||
|
|
||||||
|
| Тест | Результат |
|
||||||
|
|---|---|
|
||||||
|
| 50 страниц раздела (стр.1…113) | 0 ошибок ✅ |
|
||||||
|
| 50 тем (1–48 страниц) | 0 ошибок ✅ |
|
||||||
|
| Проблемная #138549 (139 стр) | Определена правильно |
|
||||||
|
| Проблемная #253565 (VCDS шнурок) | 1 стр (правильно) |
|
||||||
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||||
|
QyNTUxOQAAACD0fEtOzJ5LtVZVUf5auoQij6PXzcyfuxNA6BO25L8uKQAAAJhREf6nURH+
|
||||||
|
pwAAAAtzc2gtZWQyNTUxOQAAACD0fEtOzJ5LtVZVUf5auoQij6PXzcyfuxNA6BO25L8uKQ
|
||||||
|
AAAED0yPACVVsOjsZeobKiX6F1QX93DMvXpToTI4P47c6l5/R8S07Mnku1VlVR/lq6hCKP
|
||||||
|
o9fNzJ+7E0DoE7bkvy4pAAAADnRhemV0QG5hcm9kLnJ1AQIDBAUGBw==
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPR8S07Mnku1VlVR/lq6hCKPo9fNzJ+7E0DoE7bkvy4p tazet@narod.ru
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
|||||||
|
# Парсер phpBB-форума Нива Лада Клуб
|
||||||
|
|
||||||
|
Парсер для форума [нива-лада.рф](https://нива-лада.рф/n/) (phpBB, subsilver2).
|
||||||
|
Предназначен для создания RAG-базы знаний по ремонту Lada Niva / Chevrolet Niva / ВАЗ.
|
||||||
|
|
||||||
|
## Источник
|
||||||
|
|
||||||
|
- **Сайт:** https://нива-лада.рф/n/
|
||||||
|
- **Движок:** phpBB + Board3 Portal
|
||||||
|
- **Стиль:** subsilver2 (табличная вёрстка)
|
||||||
|
- **Разделы:** ~20 технических разделов (двигатель, ходовая, электрика, кузов и т.д.)
|
||||||
|
- **Доступ:** ✅ открыт, без CloudFlare
|
||||||
|
|
||||||
|
## Разделы форума
|
||||||
|
|
||||||
|
| ID | Название | Описание |
|
||||||
|
|----|----------|---------|
|
||||||
|
| 21 | Двигатель | Категория с подразделами |
|
||||||
|
| 22 | Двигатель: механика и ЭСУД | ~1849 тем, ~84K сообщений |
|
||||||
|
| 23 | Система смазки | |
|
||||||
|
| 24 | Система охлаждения | |
|
||||||
|
| 25 | Система питания | |
|
||||||
|
| 26 | Система выпуска | |
|
||||||
|
| 27 | Система зажигания | |
|
||||||
|
| 28 | Трансмиссия | |
|
||||||
|
| 29 | Коробка передач | |
|
||||||
|
| 30 | Ходовая часть | |
|
||||||
|
| 31 | Тормозная система | |
|
||||||
|
| 32 | Рулевое управление | |
|
||||||
|
| 33 | Кузов и салон | |
|
||||||
|
| 34 | Электрооборудование | |
|
||||||
|
| 35 | Отопление и кондиционирование | |
|
||||||
|
| 150 | Ремонт и ТО | Категория с подразделами |
|
||||||
|
| 104 | Флуд | |
|
||||||
|
| 114 | Ура! Купил Ниву | ~1151 страница |
|
||||||
|
| 17 | Дилеры и сервисы | |
|
||||||
|
|
||||||
|
## Структура пакета
|
||||||
|
|
||||||
|
```
|
||||||
|
lada_scraper/
|
||||||
|
├── __init__.py # package
|
||||||
|
├── __main__.py # CLI entry point
|
||||||
|
├── config.py # константы, карта разделов, URL форума
|
||||||
|
├── fetch.py # HTTP GET с ретраями
|
||||||
|
├── paginate.py # phpBB пагинация (?start=N)
|
||||||
|
├── parse.py # парсинг HTML subsilver2 (темы, посты)
|
||||||
|
├── state.py # state.json (пауза/продолжение)
|
||||||
|
├── output.py # JSONL с ротацией (100 тем/файл)
|
||||||
|
├── throttle.py # координация задержек, автоопределение бана
|
||||||
|
├── run.py # главный цикл (ThreadPoolExecutor)
|
||||||
|
└── README.md # этот файл
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Список разделов
|
||||||
|
python3 -m lada_scraper --list
|
||||||
|
|
||||||
|
# Запуск парсинга раздела
|
||||||
|
python3 -m lada_scraper 22 # Двигатель: механика и ЭСУД
|
||||||
|
python3 -m lada_scraper 150 --workers 8 # Ремонт и ТО, 8 потоков
|
||||||
|
python3 -m lada_scraper 23 --no-delay # без задержек (если не банит)
|
||||||
|
|
||||||
|
# Пауза: Ctrl+C → состояние сохраняется
|
||||||
|
# Продолжить: та же команда
|
||||||
|
```
|
||||||
|
|
||||||
|
## Формат вывода
|
||||||
|
|
||||||
|
Данные сохраняются в `lada_data/f<ID>/part_*.jsonl`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"section": "Система смазки",
|
||||||
|
"section_id": 23,
|
||||||
|
"topic_id": 12345,
|
||||||
|
"topic_title": "Замена масла в двигателе",
|
||||||
|
"topic_url": "https://xn----7sbbagpx1an.xn--p1ai/n/viewtopic.php?f=23&t=12345",
|
||||||
|
"total_posts": 15,
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"author": "Glu777",
|
||||||
|
"date": "12 авг 2025, 20:00",
|
||||||
|
"num": "#1",
|
||||||
|
"text": "текст сообщения..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scraped_at": "2026-06-04T11:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Особенности парсинга
|
||||||
|
|
||||||
|
- **Пагинация:** phpBB использует `?start=N` где N = (page-1) * items_per_page
|
||||||
|
- **Темы:** 25 на страницу раздела
|
||||||
|
- **Посты:** 10 на страницу темы
|
||||||
|
- **HTML:** subsilver2 (табличная вёрстка) — `b.postauthor`, `div.postbody`
|
||||||
|
- **Ротация:** 100 тем на один JSONL-файл
|
||||||
|
|
||||||
|
## Resume
|
||||||
|
|
||||||
|
- `state.json` хранит какие темы обработаны
|
||||||
|
- При перезапуске пропускает готовые темы
|
||||||
|
- Данные дописываются в существующие JSONL-файлы (append)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Парсер phpBB-форума Нива Лада Клуб (нива-лада.рф)."""
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Точка входа для парсера Нива Лада Клуб (phpBB).
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python -m lada_scraper 22 # раздел "Двигатель: механика и ЭСУД"
|
||||||
|
python -m lada_scraper 150 # "Ремонт и ТО"
|
||||||
|
python -m lada_scraper --list # список доступных разделов
|
||||||
|
python -m lada_scraper 22 --workers 8
|
||||||
|
python -m lada_scraper 22 --no-delay
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys, logging
|
||||||
|
from .config import OUTPUT_DIR, WORKERS, SECTIONS
|
||||||
|
|
||||||
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
logging.FileHandler(OUTPUT_DIR / "scraper.log", encoding="utf-8"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = sys.argv[1:]
|
||||||
|
|
||||||
|
# ── Список разделов ───────────────────────────────────────────────
|
||||||
|
if "--list" in args:
|
||||||
|
print("📋 Доступные разделы форума Нива Лада Клуб (phpBB):")
|
||||||
|
print(f" {'ID':>4} {'Название':<45} {'Тем':>8}")
|
||||||
|
print(f" {'─'*4} {'─'*45} {'─'*8}")
|
||||||
|
# Пока что выводим из конфига
|
||||||
|
for fid in sorted(SECTIONS):
|
||||||
|
print(f" {fid:>4} {SECTIONS[fid]:<45}")
|
||||||
|
print(f"\nПример: python -m lada_scraper 22")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not args or args[0].startswith("-"):
|
||||||
|
print("❌ Укажи ID раздела. Пример:")
|
||||||
|
print(" python -m lada_scraper 22")
|
||||||
|
print(" python -m lada_scraper --list")
|
||||||
|
print("\n⚙️ Опции:")
|
||||||
|
print(" --workers N количество потоков (по умолчанию 4)")
|
||||||
|
print(" --no-delay без координации задержек")
|
||||||
|
print(" --no-ban-test не проверять бан")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
forum_id = int(args[0])
|
||||||
|
except ValueError:
|
||||||
|
print(f"❌ ID раздела должен быть числом: {args[0]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if forum_id not in SECTIONS:
|
||||||
|
print(f"⚠️ Раздел f={forum_id} не найден в конфиге, но попробуем спарсить.")
|
||||||
|
print(f" SECTIONS известные: {sorted(SECTIONS.keys())}")
|
||||||
|
|
||||||
|
workers = WORKERS
|
||||||
|
no_delay = False
|
||||||
|
ban_test = True
|
||||||
|
|
||||||
|
for a in args[1:]:
|
||||||
|
if a == "--no-delay":
|
||||||
|
no_delay = True
|
||||||
|
elif a == "--no-ban-test":
|
||||||
|
ban_test = False
|
||||||
|
elif a == "--workers" or a == "-w":
|
||||||
|
idx = args.index(a)
|
||||||
|
if idx + 1 < len(args):
|
||||||
|
try:
|
||||||
|
workers = int(args[idx + 1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from .run import run
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info(f"🚀 Нива Лада Клуб — раздел f={forum_id}")
|
||||||
|
section_name = SECTIONS.get(forum_id, f"Форум #{forum_id}")
|
||||||
|
log.info(f"📋 {section_name}")
|
||||||
|
log.info(f"📂 {OUTPUT_DIR}/f{forum_id}/part_*.jsonl")
|
||||||
|
log.info("🛑 Ctrl+C = пауза | Повторить команду = продолжить")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(forum_id, workers=workers, no_delay=no_delay, ban_test=ban_test)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log.info("\n🛑 ПАУЗА. Продолжить: та же команда.")
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Константы для парсера phpBB."""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
socket.setdefaulttimeout(30)
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path("lada_data")
|
||||||
|
DELAY = 1.5
|
||||||
|
TIMEOUT = (10, 30)
|
||||||
|
TOPICS_PER_FILE = 100
|
||||||
|
WORKERS = 4
|
||||||
|
BAN_TEST = 5
|
||||||
|
TOPICS_PER_PAGE = 25 # phpBB: тем на страницу раздела
|
||||||
|
POSTS_PER_PAGE = 10 # phpBB: постов на страницу темы
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/125.0.0.0 Safari/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Карта разделов форума: f_id → название
|
||||||
|
# Получена со страницы https://нива-лада.рф/n/index.php
|
||||||
|
SECTIONS = {
|
||||||
|
# ── Технический раздел (f=149) ──
|
||||||
|
21: "Двигатель",
|
||||||
|
22: "Двигатель: механика и ЭСУД",
|
||||||
|
23: "Система смазки",
|
||||||
|
24: "Система охлаждения",
|
||||||
|
25: "Система питания",
|
||||||
|
26: "Система выпуска",
|
||||||
|
27: "Система зажигания",
|
||||||
|
28: "Трансмиссия",
|
||||||
|
29: "Коробка передач",
|
||||||
|
30: "Ходовая часть",
|
||||||
|
31: "Тормозная система",
|
||||||
|
32: "Рулевое управление",
|
||||||
|
33: "Кузов и салон",
|
||||||
|
34: "Электрооборудование",
|
||||||
|
35: "Отопление и кондиционирование",
|
||||||
|
150: "Ремонт и ТО",
|
||||||
|
|
||||||
|
# ── О форуме, флуд ──
|
||||||
|
104: "Флуд",
|
||||||
|
114: "Ура! Купил Ниву",
|
||||||
|
17: "Дилеры и сервисы",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Базовый URL форума
|
||||||
|
FORUM_BASE = "https://xn----7sbbagpx1an.xn--p1ai/n/"
|
||||||
|
FORUM_DOMAIN = "нива-лада.рф"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""HTTP-запросы: GET с ретраями."""
|
||||||
|
|
||||||
|
import time, logging
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import requests
|
||||||
|
from .config import DELAY, TIMEOUT, HEADERS
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
throttle = None
|
||||||
|
|
||||||
|
|
||||||
|
def make_session() -> requests.Session:
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers.update(HEADERS)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def get(url: str, session: requests.Session = None) -> BeautifulSoup | None:
|
||||||
|
"""GET + ретраи (до 3)."""
|
||||||
|
if session is None:
|
||||||
|
session = make_session()
|
||||||
|
if throttle:
|
||||||
|
throttle.wait()
|
||||||
|
else:
|
||||||
|
time.sleep(DELAY)
|
||||||
|
|
||||||
|
for n in range(3):
|
||||||
|
try:
|
||||||
|
r = session.get(url, timeout=TIMEOUT)
|
||||||
|
if r.status_code == 404:
|
||||||
|
log.warning(f" ⏭️ 404: {url[:80]}")
|
||||||
|
return None
|
||||||
|
if r.status_code in (403, 429, 503):
|
||||||
|
log.warning(f"⚠️ HTTP {r.status_code} — жду {10*(n+1)}с")
|
||||||
|
time.sleep(10 * (n + 1))
|
||||||
|
continue
|
||||||
|
r.raise_for_status()
|
||||||
|
return BeautifulSoup(r.text, "lxml")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"⚠️ Попытка {n+1}/3: {e}")
|
||||||
|
time.sleep(5)
|
||||||
|
log.error(f"❌ Не загружено: {url[:80]}")
|
||||||
|
return None
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Сохранение темы в JSONL. Ротация каждые TOPICS_PER_FILE тем."""
|
||||||
|
|
||||||
|
import json, logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from .config import TOPICS_PER_FILE, OUTPUT_DIR
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def save_topic(section_slug: str, section_name: str, topic: dict,
|
||||||
|
posts: list, state: dict, worker_dir: Path = None):
|
||||||
|
"""Записать тему в part_XXXX.jsonl."""
|
||||||
|
st = state
|
||||||
|
|
||||||
|
if st["topic_count"] > 0 and st["topic_count"] % TOPICS_PER_FILE == 0:
|
||||||
|
st["file_index"] += 1
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"section": section_name,
|
||||||
|
"section_id": topic.get("forum_id"),
|
||||||
|
"topic_id": topic["id"],
|
||||||
|
"topic_title": topic["title"],
|
||||||
|
"topic_url": topic["url"],
|
||||||
|
"total_posts": len(posts),
|
||||||
|
"posts": posts,
|
||||||
|
"scraped_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
d = worker_dir if worker_dir else (OUTPUT_DIR / section_slug)
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
fpath = d / f"part_{st['file_index']:04d}.jsonl"
|
||||||
|
|
||||||
|
with open(fpath, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
st["topic_count"] += 1
|
||||||
|
|
||||||
|
if st["topic_count"] % 500 == 0:
|
||||||
|
try:
|
||||||
|
fpath.stat()
|
||||||
|
except OSError as e:
|
||||||
|
log.critical(f"❌ Ошибка диска: {e}")
|
||||||
|
raise
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Пагинация phpBB.
|
||||||
|
|
||||||
|
phpBB: пагинация через параметр start=N, где N = (page-1) * items_per_page.
|
||||||
|
|
||||||
|
На странице раздела:
|
||||||
|
<a href="./viewforum.php?f=22&start=25">2</a>
|
||||||
|
<a href="./viewforum.php?f=22&start=50">3</a>
|
||||||
|
...
|
||||||
|
<a href="./viewforum.php?f=22&start=1800">72</a>
|
||||||
|
|
||||||
|
На странице темы:
|
||||||
|
<a href="./viewtopic.php?f=22&t=123&start=10">2</a>
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re, logging
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def count(soup: BeautifulSoup) -> int:
|
||||||
|
"""Сколько страниц в пагинации. Если нет — 1."""
|
||||||
|
for cls in ("pagination", "page-nav"):
|
||||||
|
nav = soup.find("ul", class_=cls)
|
||||||
|
if nav:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
nums = set()
|
||||||
|
for a in nav.select("a"):
|
||||||
|
href = a.get("href", "")
|
||||||
|
text = a.text.strip()
|
||||||
|
|
||||||
|
# start=N из href
|
||||||
|
m = re.search(r"[?&]start=(\d+)", href)
|
||||||
|
if m:
|
||||||
|
per_page = int(m.group(1))
|
||||||
|
# Определяем items_per_page по первому start
|
||||||
|
if hasattr(count, "_per_page"):
|
||||||
|
pass
|
||||||
|
nums.add(per_page)
|
||||||
|
|
||||||
|
# Числовой текст ссылки
|
||||||
|
try:
|
||||||
|
nums.add(int(text))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not nums:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Находим максимальный номер страницы из текста ссылок
|
||||||
|
max_page_num = max((int(a.text.strip()) for a in nav.select("a")
|
||||||
|
if a.text.strip().isdigit()), default=1)
|
||||||
|
|
||||||
|
# Находим max start
|
||||||
|
max_start = max(nums)
|
||||||
|
|
||||||
|
# Определяем per_page из наименьшего start > 0
|
||||||
|
positive_starts = [s for s in nums if s > 0]
|
||||||
|
if positive_starts:
|
||||||
|
per_page = min(positive_starts)
|
||||||
|
else:
|
||||||
|
# Не можем определить — возвращаем по тексту ссылок
|
||||||
|
return max_page_num
|
||||||
|
|
||||||
|
# Вычисляем: страниц = (max_start / per_page) + 1
|
||||||
|
pages = (max_start // per_page) + 1
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def page_url(base_url: str, page: int, per_page: int) -> str:
|
||||||
|
"""Сформировать URL для страницы пагинации.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: URL без start= (напр. viewforum.php?f=22)
|
||||||
|
page: номер страницы (1-based)
|
||||||
|
per_page: элементов на странице
|
||||||
|
"""
|
||||||
|
if page <= 1:
|
||||||
|
return base_url
|
||||||
|
start = (page - 1) * per_page
|
||||||
|
sep = "&" if "?" in base_url else "?"
|
||||||
|
return f"{base_url}{sep}start={start}"
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Парсинг HTML phpBB: темы и посты.
|
||||||
|
|
||||||
|
Структура phpBB (subsilver2, нива-лада.рф):
|
||||||
|
|
||||||
|
Список тем в разделе:
|
||||||
|
<a href="./viewtopic.php?f=22&t=123" class="topictitle">...</a>
|
||||||
|
|
||||||
|
Посты в теме (табличная вёрстка subsilver2):
|
||||||
|
<tr>
|
||||||
|
<td class="profile" rowspan="2">
|
||||||
|
<strong><a href="./memberlist.php?...">AuthorName</a></strong><br />
|
||||||
|
<span class="postdetails">Role</span>
|
||||||
|
...
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="postbody">текст поста</div>
|
||||||
|
...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="gensmall" style="float: left;">
|
||||||
|
<a href="./memberlist.php?...">AuthorName</a> » Дата
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re, logging
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_topics(soup: BeautifulSoup) -> list:
|
||||||
|
"""Список тем со страницы раздела."""
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for a in soup.select("a.topictitle"):
|
||||||
|
href = a.get("href", "")
|
||||||
|
m = re.search(r"[?&]t=(\d+)", href)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
topic_id = int(m.group(1))
|
||||||
|
f_match = re.search(r"[?&]f=(\d+)", href)
|
||||||
|
forum_id = int(f_match.group(1)) if f_match else 0
|
||||||
|
items.append({
|
||||||
|
"id": topic_id,
|
||||||
|
"forum_id": forum_id,
|
||||||
|
"title": a.text.strip(),
|
||||||
|
"url": href if href.startswith("http") else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def parse_posts(soup: BeautifulSoup) -> list:
|
||||||
|
"""Посты со страницы темы (subsilver2, нива-лада.рф).
|
||||||
|
|
||||||
|
Структура одного поста (3 <tr> в <table class="tablebg">):
|
||||||
|
|
||||||
|
<tr class="row1"> ← шапка: автор + дата
|
||||||
|
<td><b class="postauthor">Author</b></td>
|
||||||
|
<td>
|
||||||
|
<div style="float: right;">
|
||||||
|
<b>Добавлено:</b> 12 авг 2025, 20:00
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="row1"> ← тело: аватар + текст
|
||||||
|
<td class="profile">...</td>
|
||||||
|
<td><div class="postbody">текст</div></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="row1"> ← подвал
|
||||||
|
<td class="profile">Вернуться к началу</td>
|
||||||
|
<td><div class="gensmall">...</div></td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
|
||||||
|
# Ищем все блоки: b.postauthor = автор, затем ищем postbody
|
||||||
|
for author_b in soup.select("b.postauthor"):
|
||||||
|
author = author_b.text.strip()
|
||||||
|
|
||||||
|
# Дата: ищем "Добавлено:" в том же tr
|
||||||
|
date = ""
|
||||||
|
tr_header = author_b.find_parent("tr")
|
||||||
|
if tr_header:
|
||||||
|
date_td = tr_header.select_one("td[width='100%']")
|
||||||
|
if date_td:
|
||||||
|
dt_text = date_td.get_text(" ", strip=True)
|
||||||
|
m = re.search(r"Добавлено:\s*(.+)", dt_text)
|
||||||
|
if m:
|
||||||
|
date = m.group(1).strip()
|
||||||
|
|
||||||
|
# Текст: ищем div.postbody в следующем tr после tr_header
|
||||||
|
text = ""
|
||||||
|
if tr_header:
|
||||||
|
tr_body = tr_header.find_next_sibling("tr")
|
||||||
|
if tr_body:
|
||||||
|
pb = tr_body.select_one("div.postbody")
|
||||||
|
if pb:
|
||||||
|
for h in pb.select("div[style*='none'], span[style*='none'], "
|
||||||
|
"blockquote cite, dl.codebox, div.codebox"):
|
||||||
|
h.decompose()
|
||||||
|
text = pb.get_text("\n", strip=True)
|
||||||
|
|
||||||
|
num = str(len(out) + 1)
|
||||||
|
|
||||||
|
out.append({
|
||||||
|
"author": author,
|
||||||
|
"date": date,
|
||||||
|
"num": f"#{num}",
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
|
||||||
|
return out
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Главный цикл: обход разделов phpBB-форума."""
|
||||||
|
|
||||||
|
import sys, logging, shutil, json, re
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from .config import (
|
||||||
|
OUTPUT_DIR, WORKERS, BAN_TEST, DELAY, SECTIONS,
|
||||||
|
FORUM_BASE, TOPICS_PER_PAGE, POSTS_PER_PAGE,
|
||||||
|
)
|
||||||
|
from .fetch import get, make_session
|
||||||
|
from .paginate import count
|
||||||
|
from .parse import parse_topics, parse_posts
|
||||||
|
from .state import load, save, merge_states
|
||||||
|
from .output import save_topic
|
||||||
|
from .throttle import Throttle, BanDetector
|
||||||
|
from . import fetch as _fetch
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_throttle = None
|
||||||
|
|
||||||
|
|
||||||
|
def _url(path: str) -> str:
|
||||||
|
"""Полный URL относительно FORUM_BASE."""
|
||||||
|
if path.startswith("http"):
|
||||||
|
# Убираем sid из URL для чистоты
|
||||||
|
path = re.sub(r"[?&]sid=[^&]+", "", path)
|
||||||
|
# Заменяем http на https
|
||||||
|
path = path.replace("http://", "https://")
|
||||||
|
return path
|
||||||
|
path = re.sub(r"[?&]sid=[^&]+", "", path)
|
||||||
|
return urljoin(FORUM_BASE, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _worker(forum_id: int, section_name: str, pages: range, worker_id: int):
|
||||||
|
"""Один поток: обходит свой диапазон страниц раздела."""
|
||||||
|
session = make_session()
|
||||||
|
st = {"topics_done": {}, "pages_done": [], "file_index": 0, "topic_count": 0}
|
||||||
|
slug = f"f{forum_id}"
|
||||||
|
consecutive_404 = 0
|
||||||
|
|
||||||
|
worker_dir = OUTPUT_DIR / slug / f"worker_{worker_id}"
|
||||||
|
worker_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
base_url = f"{FORUM_BASE}viewforum.php?f={forum_id}"
|
||||||
|
|
||||||
|
for pg in pages:
|
||||||
|
url = base_url if pg == 1 else f"{base_url}&start={(pg-1)*TOPICS_PER_PAGE}"
|
||||||
|
log.info(f"[W{worker_id}] 📄 стр.{pg}")
|
||||||
|
|
||||||
|
page_soup = get(url, session)
|
||||||
|
if not page_soup:
|
||||||
|
consecutive_404 += 1
|
||||||
|
if consecutive_404 >= 3:
|
||||||
|
log.info(f"[W{worker_id}] ⏹️ 3 пустых — завершаем")
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
consecutive_404 = 0
|
||||||
|
|
||||||
|
topics = parse_topics(page_soup)
|
||||||
|
log.info(f"[W{worker_id}] Тем: {len(topics)}")
|
||||||
|
|
||||||
|
for i, tp in enumerate(topics, 1):
|
||||||
|
tid = tp["id"]
|
||||||
|
if str(tid) in st["topics_done"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Собираем полный URL темы
|
||||||
|
topic_url = _url(tp["url"] or f"./viewtopic.php?f={forum_id}&t={tid}")
|
||||||
|
tp["url"] = topic_url
|
||||||
|
|
||||||
|
log.info(f"[W{worker_id}] [{i}/{len(topics)}] #{tid}: {tp['title'][:80]}")
|
||||||
|
topic_soup = get(topic_url, session)
|
||||||
|
if not topic_soup:
|
||||||
|
st["topics_done"][str(tid)] = tp["title"]
|
||||||
|
continue
|
||||||
|
|
||||||
|
topic_pages = count(topic_soup)
|
||||||
|
posts = []
|
||||||
|
|
||||||
|
for tpp in range(1, topic_pages + 1):
|
||||||
|
if tpp == 1:
|
||||||
|
posts += parse_posts(topic_soup)
|
||||||
|
else:
|
||||||
|
tp_url = f"{topic_url}&start={(tpp-1)*POSTS_PER_PAGE}"
|
||||||
|
tp2 = get(tp_url, session)
|
||||||
|
if tp2:
|
||||||
|
posts += parse_posts(tp2)
|
||||||
|
|
||||||
|
log.info(f"[W{worker_id}] {len(posts)} постов ({topic_pages} стр.)")
|
||||||
|
save_topic(slug, section_name, tp, posts, st, worker_dir=worker_dir)
|
||||||
|
st["topics_done"][str(tid)] = tp["title"]
|
||||||
|
|
||||||
|
st["pages_done"].append(pg)
|
||||||
|
|
||||||
|
_state_file = worker_dir / "state.json"
|
||||||
|
_state_file.write_text(json.dumps(st, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8")
|
||||||
|
session.close()
|
||||||
|
return worker_id, st["topic_count"]
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_workers(slug: str):
|
||||||
|
"""Собрать part_*.jsonl из всех worker_N/ в корень."""
|
||||||
|
root = OUTPUT_DIR / slug
|
||||||
|
|
||||||
|
all_parts = []
|
||||||
|
for wdir in sorted(root.glob("worker_*")):
|
||||||
|
if wdir.is_dir():
|
||||||
|
for f in sorted(wdir.glob("part_*.jsonl")):
|
||||||
|
all_parts.append(f)
|
||||||
|
|
||||||
|
log.info(f"🔗 Мерж {len(all_parts)} файлов из {len(list(root.glob('worker_*')))} воркеров")
|
||||||
|
|
||||||
|
for idx, src in enumerate(all_parts):
|
||||||
|
dst = root / f"part_{idx:04d}.jsonl"
|
||||||
|
shutil.move(str(src), str(dst))
|
||||||
|
|
||||||
|
for wdir in root.glob("worker_*"):
|
||||||
|
if wdir.is_dir():
|
||||||
|
try:
|
||||||
|
wdir.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
merge_states(slug)
|
||||||
|
log.info(f"✅ Мерж: {len(all_parts)} файлов → {root}")
|
||||||
|
|
||||||
|
|
||||||
|
def run(forum_id: int, workers: int = WORKERS,
|
||||||
|
no_delay: bool = False, ban_test: bool = True):
|
||||||
|
"""Главный цикл.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
forum_id: ID раздела на форуме (напр. 22)
|
||||||
|
workers: количество потоков
|
||||||
|
no_delay: без координации задержек
|
||||||
|
ban_test: проверить бан перед стартом
|
||||||
|
"""
|
||||||
|
slug = f"f{forum_id}"
|
||||||
|
section_name = SECTIONS.get(forum_id, f"Форум #{forum_id}")
|
||||||
|
|
||||||
|
throttle_enabled = not no_delay
|
||||||
|
if ban_test and throttle_enabled and workers > 1:
|
||||||
|
if not BanDetector.test(f"{FORUM_BASE}viewforum.php?f={forum_id}", BAN_TEST):
|
||||||
|
throttle_enabled = False
|
||||||
|
|
||||||
|
global _throttle
|
||||||
|
_throttle = Throttle(DELAY, enabled=throttle_enabled)
|
||||||
|
_fetch.throttle = _throttle
|
||||||
|
|
||||||
|
log.info(f"⚙️ Потоков: {workers}, координация: {'вкл' if throttle_enabled else 'выкл'}")
|
||||||
|
|
||||||
|
# ── Страница 1: определяем количество страниц ─────────────────────
|
||||||
|
first_url = f"{FORUM_BASE}viewforum.php?f={forum_id}"
|
||||||
|
soup = get(first_url)
|
||||||
|
if not soup:
|
||||||
|
log.error(f"❌ Раздел f={forum_id} недоступен")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
total_pages = count(soup)
|
||||||
|
log.info(f"📋 '{section_name}' (f={forum_id}) | страниц: {total_pages}")
|
||||||
|
|
||||||
|
if total_pages <= 1:
|
||||||
|
log.info("ℹ️ Всего одна страница — проверяем, есть ли темы...")
|
||||||
|
topics = parse_topics(soup)
|
||||||
|
if not topics:
|
||||||
|
log.warning(f"⚠️ В разделе f={forum_id} нет тем (возможно, это категория с подразделами)")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# ── Диапазоны страниц ─────────────────────────────────────────────
|
||||||
|
if workers > total_pages:
|
||||||
|
workers = total_pages
|
||||||
|
|
||||||
|
base_size = total_pages // workers
|
||||||
|
remainder = total_pages % workers
|
||||||
|
ranges = []
|
||||||
|
start = 1
|
||||||
|
for w in range(workers):
|
||||||
|
size = base_size + (1 if w < remainder else 0)
|
||||||
|
ranges.append(range(start, start + size))
|
||||||
|
start += size
|
||||||
|
|
||||||
|
log.info(f"📦 Диапазоны: {[f'{r.start}-{r[-1]}' for r in ranges]}")
|
||||||
|
|
||||||
|
# ── Запуск потоков ────────────────────────────────────────────────
|
||||||
|
total_topics = 0
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||||
|
futures = [
|
||||||
|
pool.submit(_worker, forum_id, section_name, rng, i)
|
||||||
|
for i, rng in enumerate(ranges)
|
||||||
|
]
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
wid, cnt = fut.result()
|
||||||
|
total_topics += cnt
|
||||||
|
log.info(f"[W{wid}] ✅ завершён: {cnt} тем")
|
||||||
|
|
||||||
|
# ── Мерж ──────────────────────────────────────────────────────────
|
||||||
|
_merge_workers(slug)
|
||||||
|
|
||||||
|
# ── Итог ──────────────────────────────────────────────────────────
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info(f"✅ '{section_name}' (f={forum_id}) — {total_topics} тем")
|
||||||
|
for f in sorted((OUTPUT_DIR / slug).glob("part_*.jsonl")):
|
||||||
|
log.info(f" 📄 {f.name} ({f.stat().st_size/1024/1024:.1f} MB)")
|
||||||
|
log.info("=" * 60)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Управление состоянием (state.json в папке раздела)."""
|
||||||
|
|
||||||
|
import json, logging
|
||||||
|
from pathlib import Path
|
||||||
|
from .config import OUTPUT_DIR
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _state_file(section_slug: str) -> Path:
|
||||||
|
d = OUTPUT_DIR / section_slug
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load(section_slug: str) -> dict:
|
||||||
|
f = _state_file(section_slug)
|
||||||
|
if f.exists():
|
||||||
|
return json.loads(f.read_text(encoding="utf-8"))
|
||||||
|
return {"topics_done": {}, "pages_done": [], "file_index": 0, "topic_count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def save(section_slug: str, st: dict):
|
||||||
|
_state_file(section_slug).write_text(
|
||||||
|
json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def merge_states(section_slug: str):
|
||||||
|
"""Собрать состояния всех воркеров в единый state.json."""
|
||||||
|
root = OUTPUT_DIR / section_slug
|
||||||
|
merged = {"topics_done": {}, "pages_done": [], "file_index": 0, "topic_count": 0}
|
||||||
|
|
||||||
|
for wdir in sorted(root.glob("worker_*/state.json")):
|
||||||
|
try:
|
||||||
|
wst = json.loads(wdir.read_text(encoding="utf-8"))
|
||||||
|
merged["topics_done"].update(wst.get("topics_done", {}))
|
||||||
|
merged["pages_done"].extend(wst.get("pages_done", []))
|
||||||
|
merged["topic_count"] += wst.get("topic_count", 0)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"⚠️ Ошибка чтения {wdir}: {e}")
|
||||||
|
|
||||||
|
merged["pages_done"] = sorted(set(merged["pages_done"]))
|
||||||
|
merged["file_index"] = len(list(root.glob("part_*.jsonl"))) - 1
|
||||||
|
if merged["file_index"] < 0:
|
||||||
|
merged["file_index"] = 0
|
||||||
|
|
||||||
|
_state_file(section_slug).write_text(
|
||||||
|
json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Общая координация задержек и автоопределение бана."""
|
||||||
|
|
||||||
|
import time, threading, logging
|
||||||
|
import requests
|
||||||
|
from .config import DELAY, TIMEOUT, HEADERS
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Throttle:
|
||||||
|
"""Thread-safe координатор задержек."""
|
||||||
|
|
||||||
|
def __init__(self, delay: float = DELAY, enabled: bool = True):
|
||||||
|
self._delay = delay
|
||||||
|
self._enabled = enabled
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._last = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
if not self._enabled:
|
||||||
|
time.sleep(self._delay)
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
elapsed = time.monotonic() - self._last
|
||||||
|
if elapsed < self._delay:
|
||||||
|
time.sleep(self._delay - elapsed)
|
||||||
|
self._last = time.monotonic()
|
||||||
|
|
||||||
|
def disable(self):
|
||||||
|
self._enabled = False
|
||||||
|
|
||||||
|
|
||||||
|
class BanDetector:
|
||||||
|
"""Проверяет, банит ли сервер."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def test(base_url: str, count: int = 5) -> bool:
|
||||||
|
log.info(f"🔍 Проверка бана: {count} запросов подряд...")
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers.update(HEADERS)
|
||||||
|
banned = False
|
||||||
|
for i in range(count):
|
||||||
|
try:
|
||||||
|
r = s.get(base_url, timeout=TIMEOUT)
|
||||||
|
log.info(f" [{i+1}/{count}] HTTP {r.status_code}")
|
||||||
|
if r.status_code in (403, 429):
|
||||||
|
banned = True
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f" [{i+1}/{count}] ошибка: {e}")
|
||||||
|
banned = True
|
||||||
|
break
|
||||||
|
if banned:
|
||||||
|
log.warning("⚠️ Сервер банит быстрые запросы — включаю координацию")
|
||||||
|
else:
|
||||||
|
log.info("✅ Бан не обнаружен")
|
||||||
|
s.close()
|
||||||
|
return banned
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# scraper.core — общий слой парсера
|
||||||
|
# Содержит модули, независимые от движка форума.
|
||||||
|
# См. fetch.py, throttle.py, output.py, state.py, runner.py.
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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_*"))))
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# scraper.forums — адаптеры под разные движки форумов
|
||||||
|
# Каждый пакет реализует BaseAdapter.
|
||||||
|
# См. base.py, xenforo/, phpbb/
|
||||||
@@ -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 []
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# scraper.forums.phpbb — адаптер для форумов на phpBB (нива-лада.рф и др.)
|
||||||
@@ -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)
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# scraper.forums.xenforo — адаптер для форумов на XenForo (vwts.ru)
|
||||||
@@ -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)
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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 отфильтровано).
|
||||||
|
|
||||||
|
---
|
||||||
@@ -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
|
||||||
|
""")
|
||||||
@@ -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()}")
|
||||||
@@ -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()
|
||||||
@@ -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" ", " ", 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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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}")
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+95
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user