scraper: новая архитектура — core/ + forums/ (BaseAdapter, XenForo, phpBB)
- core/ — общий слой: fetch, throttle, output, state, runner - forums/base.py — абстрактный BaseAdapter - forums/xenforo/ — адаптер для XenForo (vwts.ru) - forums/phpbb/ — адаптер для phpBB (нива-лада.рф) - __main__.py — точка входа CLI - history/001-initial-structure.md — журнал изменений Также на ВМ (не в этом коммите): - lada_scraper/parse.py — пропуск sticky-тем при многопоточном парсинге
This commit is contained in:
@@ -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_*"))))
|
||||
Reference in New Issue
Block a user