feat: многопоточный парсинг + forum survey
- Throttle: Thread-safe координация задержек - BanDetector: автоопределение бана - fetch: thread-local сессии вместо глобальной - run: ThreadPoolExecutor + диапазоны страниц + мерж - output/state: поддержка worker-папок и merge_states() - __main__: --workers, --no-delay, --no-ban-test - VWTS/forums-survey.md: обзор 20+ автофорумов
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
# Обзор автомобильных форумов РФ и мира
|
||||||
|
|
||||||
|
Дата: 2026-06-04
|
||||||
|
Цель: найти живые автофорумы с доступом для парсинга (RAG-база знаний по ремонту).
|
||||||
|
|
||||||
|
## Результаты проверки
|
||||||
|
|
||||||
|
| Форум | Марка | Движок | Доступ | Примечание |
|
||||||
|
|-------|-------|--------|--------|------------|
|
||||||
|
| **vwts.ru** | VW/Audi/Skoda/SEAT | **XenForo** | ✅ | Отлично, наш основной источник |
|
||||||
|
| **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-like | 🟡 | 741K сообщений, но сабфорумы timeout |
|
||||||
|
| kianiroforum.com | Kia Niro (en) | XenForo-like | 🟡 | Малый, timeout |
|
||||||
|
| lada-* (все) | Lada | — | ❌ | Все домены мертвы |
|
||||||
|
| kia-rio.net | Kia (ru) | — | ❌ | CloudFlare |
|
||||||
|
| bmwclub.ru | BMW | — | ❌ | DDoS-Guard |
|
||||||
|
| motor-talk.de | VAG (de) | — | ❌ | CloudFlare |
|
||||||
|
| kia-forum.de | Kia (de) | — | ❌ | Недоступен |
|
||||||
|
| toyota-club.net | Toyota (ru) | — | ❌ | 403 |
|
||||||
|
| toyota-forum.de | Toyota (de) | — | ❌ | CloudFlare |
|
||||||
|
|
||||||
|
## Выводы
|
||||||
|
|
||||||
|
1. **XenForo с открытым доступом — только vwts.ru.** Остальные либо под защитой, либо мёртвы.
|
||||||
|
2. **Живые форумы на других движках** — потребуют отдельных парсеров:
|
||||||
|
- phpBB (`viewtopic.php`) — lexus-club.ru
|
||||||
|
- vBulletin (`forumdisplay.php`) — renault-club.ru
|
||||||
|
- Invision Power Board (`ipsPagination`) — ffclub.ru
|
||||||
|
3. **Англоязычные Kia-форумы** — крупные (kia-forums.com, 741K сообщений), но сабфорумы не грузятся с нашего IP (возможно гео- или CDN-ограничение).
|
||||||
|
4. **Российские автофорумы массово под CloudFlare/DDoS-Guard** — парсинг через requests невозможен.
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
+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