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:
2026-06-04 10:48:57 +03:00
parent 22ca83adf8
commit c3211f0602
8 changed files with 361 additions and 93 deletions
+29 -4
View File
@@ -2,13 +2,15 @@
Использование:
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 → состояние сохраняется.
Продолжить: та же команда.
"""
import sys, logging
from .config import OUTPUT_DIR
from .config import OUTPUT_DIR, WORKERS
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
@@ -23,14 +25,37 @@ logging.basicConfig(
)
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(" python -m vwts_scraper "
"https://vwts.ru/forum/vag/benzinovye-dvigateli/")
print("\n⚙️ Опции:")
print(" --workers N количество потоков (по умолчанию 4)")
print(" --no-delay без координации задержек")
print(" --no-ban-test не проверять бан")
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
section_url = sys.argv[1].rstrip("/") + "/"
log = logging.getLogger(__name__)
log.info("=" * 60)
@@ -40,7 +65,7 @@ if __name__ == "__main__":
log.info("=" * 60)
try:
run(section_url)
run(section_url, workers=workers, no_delay=no_delay, ban_test=ban_test)
except KeyboardInterrupt:
log.info("\n🛑 ПАУЗА. Продолжить: та же команда.")
sys.exit(0)
+2
View File
@@ -10,6 +10,8 @@ OUTPUT_DIR = Path("vwts_data") # куда сохраняем данны
DELAY = 1.5 # секунд между запросами
TIMEOUT = (10, 30) # (connect, read)
TOPICS_PER_FILE = 100
WORKERS = 4 # потоков по умолчанию
BAN_TEST = 5 # запросов для автоопределения бана
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
+24 -5
View File
@@ -7,13 +7,32 @@ from .config import DELAY, TIMEOUT, HEADERS
log = logging.getLogger(__name__)
session = requests.Session()
session.headers.update(HEADERS)
# Глобальный throttle — устанавливается из run()
throttle = None
def get(url: str) -> BeautifulSoup | None:
"""GET + ретраи (до 3). 404 — без ретрая, сразу None."""
time.sleep(DELAY)
def make_session() -> requests.Session:
"""Создать новую сессию (thread-safe)."""
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):
try:
r = session.get(url, timeout=TIMEOUT)
+10 -3
View File
@@ -2,14 +2,21 @@
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, tags: list, state: dict):
"""Записать тему в part_XXXX.jsonl. Ротирует файл каждые N тем."""
posts: list, tags: list, state: dict,
worker_dir: Path = None):
"""Записать тему в part_XXXX.jsonl. Ротирует файл каждые N тем.
Args:
worker_dir: если задано — писать в эту папку (для многопоточности),
иначе в OUTPUT_DIR / section_slug
"""
st = state
# ротация
@@ -27,7 +34,7 @@ def save_topic(section_slug: str, section_name: str, topic: dict,
"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)
fpath = d / f"part_{st['file_index']:04d}.jsonl"
+159 -80
View File
@@ -1,35 +1,148 @@
"""Основной цикл: обход страниц раздела → темы → посты → сохранение."""
"""Основной цикл: ThreadPoolExecutor + диапазоны страниц → мерж."""
import sys, logging
import sys, logging, shutil, json
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from urllib.parse import urlparse
from .config import OUTPUT_DIR
from .fetch import get, session
from .config import OUTPUT_DIR, WORKERS, BAN_TEST, DELAY
from .fetch import get, make_session
from .paginate import count, verify_last
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 .throttle import Throttle, BanDetector
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:
section_url: полный URL раздела, напр.
https://vwts.ru/forum/vag/benzinovye-dvigateli/
section_url: полный URL раздела
workers: количество потоков
no_delay: True = без координации задержек
ban_test: True = проверить бан перед стартом
"""
section_slug = section_url.rstrip("/").split("/")[-1]
# загружаем состояние (если было прервано — продолжим)
st = load(section_slug)
if st["topics_done"]:
log.info(f"🔄 Продолжаем: {len(st['topics_done'])} тем, "
f"part_{st['file_index']:04d}.jsonl")
# ── Автоопределение бана ──────────────────────────────────────────
throttle_enabled = not no_delay
if ban_test and throttle_enabled and workers > 1:
if not BanDetector.test(section_url, BAN_TEST):
throttle_enabled = False
# ── Страница 1: название раздела + количество страниц ────────────────
soup = get(section_url)
global _throttle
_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:
log.error("❌ Сайт недоступен")
sys.exit(1)
@@ -38,78 +151,44 @@ def run(section_url: str):
section_name = section_name.text.strip() if section_name else section_slug
total_pages = count(soup)
# Проверяем: последняя страница реально существует?
total_pages = verify_last(section_url, total_pages, session)
total_pages = verify_last(section_url, total_pages, make_session())
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):
if pg in st["pages_done"]:
consecutive_404 = 0
continue
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
# page-1 = базовый URL (чтобы не было лишнего редиректа)
url = section_url if pg == 1 else f"{section_url}page-{pg}"
log.info(f"📄 [{pg}/{total_pages}]")
log.info(f"📦 Диапазоны: {[f'{r.start}-{r[-1]}' for r in ranges]}")
page_soup = get(url)
if not page_soup:
consecutive_404 += 1
# 3 пустых страницы подряд = раздел закончился раньше
if consecutive_404 >= 3:
log.info(f" ⏹️ {consecutive_404} пустых подряд — завершаем")
break
log.warning(f" ⚠️ Пропущена стр.{pg} "
f"(пустых подряд: {consecutive_404})")
continue
consecutive_404 = 0
# ── Запуск потоков ────────────────────────────────────────────────
total_topics = 0
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [
pool.submit(_worker, section_url, section_slug, 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} тем")
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(f"'{section_name}'{st['topic_count']} тем "
f"в {st['file_index']+1} частях")
log.info(f"'{section_name}'{total_topics} тем "
f"в {len(list((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("=" * 60)
+27 -1
View File
@@ -1,8 +1,11 @@
"""Управление состоянием (state.json в папке раздела)."""
import 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
@@ -20,3 +23,26 @@ def load(section_slug: str) -> dict:
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")
+78
View File
@@ -0,0 +1,78 @@
"""Общая координация задержек и автоопределение бана."""
import time, threading, logging
import requests
from .config import DELAY, TIMEOUT, HEADERS
log = logging.getLogger(__name__)
class Throttle:
"""Thread-safe координатор задержек.
Все потоки ждут своей очереди — максимум 1 запрос в DELAY секунд.
"""
def __init__(self, delay: float = DELAY, enabled: bool = True):
self._delay = delay
self._enabled = enabled
self._lock = threading.Lock()
self._last = 0.0 # монотонное время последнего запроса
@property
def enabled(self) -> bool:
return self._enabled
def wait(self):
"""Ждать своей очереди. Без координации — просто sleep(delay)."""
if not self._enabled:
time.sleep(self._delay)
return
with self._lock:
elapsed = time.monotonic() - self._last
if elapsed < self._delay:
time.sleep(self._delay - elapsed)
self._last = time.monotonic()
def disable(self):
self._enabled = False
class BanDetector:
"""Проверяет, банит ли сервер за быстрые запросы."""
@staticmethod
def test(base_url: str, count: int = 5) -> bool:
"""Шлёт count запросов без задержки. Возвращает True если банят.
Args:
base_url: любой URL того же хоста (напр. главная раздела)
count: сколько запросов подряд
Returns:
True — банит (есть 403/429), False — не банит
"""
log.info(f"🔍 Проверка бана: {count} запросов подряд...")
s = requests.Session()
s.headers.update(HEADERS)
banned = False
for i in range(count):
try:
r = s.get(base_url, timeout=TIMEOUT)
log.info(f" [{i+1}/{count}] HTTP {r.status_code}")
if r.status_code in (403, 429):
banned = True
break
except Exception as e:
log.warning(f" [{i+1}/{count}] ошибка: {e}")
banned = True
break
if banned:
log.warning("⚠️ Сервер банит быстрые запросы — включаю координацию")
else:
log.info("✅ Бан не обнаружен — без координации")
s.close()
return banned