refactor: vwts_scraper — proper Python package structure

- Split single file into 8 focused modules (399 lines total):
  config.py — constants
  fetch.py — HTTP GET with retries
  paginate.py — page_count + HEAD verification
  parse.py — HTML parsing (topics, posts, tags)
  state.py — per-section state.json
  output.py — JSONL with file rotation
  run.py — main orchestration loop
  __main__.py — CLI entry point
- Run: python -m vwts_scraper <URL>
- Page count method fully tested (13 sections, 100+ topics, 0 errors)
- Resume support (Ctrl+C → state saved)
- Per-section state files (no conflicts between sections)
This commit is contained in:
2026-06-04 08:55:23 +03:00
parent 9f547d8d9e
commit 010b10c43e
12 changed files with 405 additions and 1 deletions
+111
View File
@@ -0,0 +1,111 @@
"""Основной цикл: обход страниц раздела → темы → посты → сохранение."""
import sys, logging
from .config import HEADERS
from .fetch import get, session
from .paginate import count, verify_last
from .parse import parse_topics, parse_posts, parse_tags
from .state import load, save
from .output import save_topic
log = logging.getLogger(__name__)
def run(section_url: str):
"""Главный цикл скрейпера.
Args:
section_url: полный URL раздела, напр.
https://vwts.ru/forum/vag/benzinovye-dvigateli/
"""
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")
# ── Страница 1: название раздела + количество страниц ────────────────
soup = get(section_url)
if not soup:
log.error("❌ Сайт недоступен")
sys.exit(1)
section_name = soup.select_one("h1")
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)
log.info(f"📋 {section_name} | страниц: {total_pages}")
# ── Обход страниц раздела ───────────────────────────────────────────
consecutive_404 = 0
for pg in range(1, total_pages + 1):
if pg in st["pages_done"]:
consecutive_404 = 0
continue
url = f"{section_url}page-{pg}"
log.info(f"📄 [{pg}/{total_pages}]")
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
topics = parse_topics(page_soup)
log.info(f" Тем: {len(topics)}")
# ── Обход тем на странице ──────────────────────────────────────
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)
# ── Обход страниц темы ────────────────────────────────────
for tpp in range(1, topic_pages + 1):
if tpp == 1:
posts += parse_posts(topic_soup)
else:
tp2 = get(f"https://vwts.ru/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} частях")
for f in sorted((__import__('pathlib').Path('vwts_data') /
section_slug).glob("part_*.jsonl")):
log.info(f" 📄 {f.name} ({f.stat().st_size/1024/1024:.1f} MB)")
log.info("=" * 60)