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:
+6
-1
@@ -1,2 +1,7 @@
|
|||||||
doc/
|
doc/
|
||||||
token*.*
|
token*.*
|
||||||
|
|
||||||
|
# vwts.ru scraper
|
||||||
|
VWTS/
|
||||||
|
vwts_data/
|
||||||
|
vwts_scraper.py
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""vwts.ru scraper — парсинг форума Volkswagen Technical Site."""
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Точка входа.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python -m vwts_scraper https://vwts.ru/forum/vag/benzinovye-dvigateli/
|
||||||
|
|
||||||
|
Пауза: Ctrl+C → состояние сохраняется.
|
||||||
|
Продолжить: та же команда.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys, logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
OUTPUT_DIR = Path("vwts_data")
|
||||||
|
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__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("❌ Укажи URL раздела. Пример:")
|
||||||
|
print(" python -m vwts_scraper "
|
||||||
|
"https://vwts.ru/forum/vag/benzinovye-dvigateli/")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
from .run import run
|
||||||
|
section_url = sys.argv[1].rstrip("/") + "/"
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info(f"🚀 vwts.ru — раздел: {section_url.split('/')[-2]}")
|
||||||
|
log.info(f"📂 {OUTPUT_DIR}/{section_url.split('/')[-2]}/part_*.jsonl")
|
||||||
|
log.info("🛑 Ctrl+C = пауза | Повторить команду = продолжить")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(section_url)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log.info("\n🛑 ПАУЗА. Продолжить: та же команда.")
|
||||||
|
sys.exit(0)
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
"""Константы."""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
# Глобальный таймаут сокетов
|
||||||
|
socket.setdefaulttimeout(30)
|
||||||
|
|
||||||
|
DELAY = 1.5 # секунд между запросами
|
||||||
|
TIMEOUT = (10, 30) # (connect, read)
|
||||||
|
TOPICS_PER_FILE = 100
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""HTTP-запросы: GET с ретраями, 404 без ретрая."""
|
||||||
|
|
||||||
|
import time, logging
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import requests
|
||||||
|
from .config import DELAY, TIMEOUT, HEADERS
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers.update(HEADERS)
|
||||||
|
|
||||||
|
|
||||||
|
def get(url: str) -> BeautifulSoup | None:
|
||||||
|
"""GET + ретраи (до 3). 404 — без ретрая, сразу None."""
|
||||||
|
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,41 @@
|
|||||||
|
"""Сохранение темы в JSONL. Ротация каждые TOPICS_PER_FILE тем."""
|
||||||
|
|
||||||
|
import json, logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from .config import TOPICS_PER_FILE
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path("vwts_data")
|
||||||
|
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 тем."""
|
||||||
|
st = state
|
||||||
|
|
||||||
|
# ротация
|
||||||
|
if st["topic_count"] > 0 and st["topic_count"] % TOPICS_PER_FILE == 0:
|
||||||
|
st["file_index"] += 1
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"section": section_name,
|
||||||
|
"topic_id": topic["id"],
|
||||||
|
"topic_title": topic["title"],
|
||||||
|
"topic_url": topic["url"],
|
||||||
|
"total_posts": len(posts),
|
||||||
|
"posts": posts,
|
||||||
|
"tags": tags,
|
||||||
|
"scraped_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
d = 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
|
||||||
|
log.info(f" 💾 part_{st['file_index']:04d}.jsonl "
|
||||||
|
f"({fpath.stat().st_size/1024/1024:.1f} MB)")
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Определение количества страниц в пагинации XenForo.
|
||||||
|
|
||||||
|
Работает ТОЛЬКО внутри div.pageNav — не ловит числа из аватаров,
|
||||||
|
текста постов, путей к файлам (например /avatars/m/32/32165.jpg).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re, logging
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def count(soup: BeautifulSoup) -> int:
|
||||||
|
"""Сколько страниц. Если пагинации нет — 1.
|
||||||
|
|
||||||
|
Принцип:
|
||||||
|
<div class="pageNav">
|
||||||
|
<a>1</a> ← текущая (без page-N в href)
|
||||||
|
<a href="page-2">2</a> ← соседняя
|
||||||
|
<a>…</a> ← эллипсис → ValueError → пропускаем
|
||||||
|
<a href="page-218">218</a> ← последняя
|
||||||
|
</div>
|
||||||
|
|
||||||
|
ВСЕ номера страниц видны на любой странице.
|
||||||
|
Кнопки <button>Выполнить</button> не трогаем — ищем только <a>.
|
||||||
|
"""
|
||||||
|
nav = soup.select_one("div.pageNav")
|
||||||
|
if not nav:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
nums = set()
|
||||||
|
for a in nav.select("a"):
|
||||||
|
href = a.get("href", "")
|
||||||
|
text = a.text.strip()
|
||||||
|
|
||||||
|
# page-N из href
|
||||||
|
m = re.search(r"page-(\d+)", href)
|
||||||
|
if m:
|
||||||
|
nums.add(int(m.group(1)))
|
||||||
|
|
||||||
|
# текущая страница: число без page-N в href
|
||||||
|
try:
|
||||||
|
nums.add(int(text))
|
||||||
|
except ValueError:
|
||||||
|
pass # "…", "Назад", "Вперёд"
|
||||||
|
|
||||||
|
return max(nums) if nums else 1
|
||||||
|
|
||||||
|
|
||||||
|
def verify_last(section_url: str, claimed: int, session) -> int:
|
||||||
|
"""HEAD-запросами проверяет реальную последнюю страницу.
|
||||||
|
|
||||||
|
Если сайт показывает 218, но page-218 → 404 (админ потёр часть тем,
|
||||||
|
пагинацию не пересчитали) — бинарным поиском находит реальную границу.
|
||||||
|
"""
|
||||||
|
if claimed <= 1:
|
||||||
|
return claimed
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
r = session.head(f"{section_url}page-{claimed}",
|
||||||
|
timeout=(10, 30), allow_redirects=True)
|
||||||
|
if r.status_code < 400:
|
||||||
|
return claimed
|
||||||
|
|
||||||
|
# Бинарный поиск
|
||||||
|
lo, hi = 1, claimed
|
||||||
|
while lo < hi:
|
||||||
|
mid = (lo + hi + 1) // 2
|
||||||
|
hr = session.head(f"{section_url}page-{mid}",
|
||||||
|
timeout=(10, 30), allow_redirects=True)
|
||||||
|
if hr.status_code >= 400:
|
||||||
|
hi = mid - 1
|
||||||
|
else:
|
||||||
|
lo = mid
|
||||||
|
|
||||||
|
log.warning(f" Пагинация врала: {claimed} → реально {lo}")
|
||||||
|
return lo
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return claimed # не смогли — верим сайту
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Парсинг HTML: темы, посты, теги."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
def parse_topics(soup: BeautifulSoup) -> list:
|
||||||
|
"""Список тем со страницы раздела."""
|
||||||
|
items = []
|
||||||
|
for div in soup.select("div.structItem"):
|
||||||
|
a = div.select_one("div.structItem-title a")
|
||||||
|
if not a:
|
||||||
|
continue
|
||||||
|
m = re.search(r"/topic/(\d+)/", a.get("href", ""))
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
"id": int(m.group(1)),
|
||||||
|
"title": a.text.strip(),
|
||||||
|
"url": f"https://vwts.ru/forum/topic/{m.group(1)}/"
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def parse_posts(soup: BeautifulSoup) -> list:
|
||||||
|
"""Посты с одной страницы темы."""
|
||||||
|
out = []
|
||||||
|
for art in soup.select("article.message"):
|
||||||
|
aname = art.select_one("h4.message-name")
|
||||||
|
author = aname.get_text(strip=True) if aname else "?"
|
||||||
|
t = art.select_one("time.u-dt")
|
||||||
|
date = t.get("datetime", "") if t else ""
|
||||||
|
n = art.select_one("ul.message-attribution-opposite a")
|
||||||
|
num = n.text.strip() if n else ""
|
||||||
|
content = art.select_one("div.message-content")
|
||||||
|
text = ""
|
||||||
|
if content:
|
||||||
|
for h in content.select("div[style*='none'], span[style*='none'], details"):
|
||||||
|
h.decompose()
|
||||||
|
text = content.get_text("\n", strip=True)
|
||||||
|
out.append({"author": author, "date": date, "num": num, "text": text})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tags(soup: BeautifulSoup) -> list:
|
||||||
|
"""Теги из шапки темы."""
|
||||||
|
return [t.text.strip() for t in soup.select("li.tagItem a")]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Управление состоянием (state.json в папке раздела)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path("vwts_data")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
Reference in New Issue
Block a user