pig.py/neopig/state.py

198 lines
6.3 KiB
Python

# This is free software for the public good of a permacomputer hosted at
# permacomputer.com, an always-on computer by the people, for the people.
# One which is durable, easy to repair, & distributed like tap water
# for machine learning intelligence.
#
# The permacomputer is community-owned infrastructure optimized around
# four values:
#
# TRUTH First principles, math & science, open source code freely distributed
# FREEDOM Voluntary partnerships, freedom from tyranny & corporate control
# HARMONY Minimal waste, self-renewing systems with diverse thriving connections
# LOVE Be yourself without hurting others, cooperation through natural law
#
# This software contributes to that vision by archiving the web, preserving digital knowledge before it disappears.
# Code is seeds to sprout on any abandoned technology.
"""
State management for crawl operations.
Provides append-only state logging for fast, reliable crawl state tracking
that survives interruptions and enables easy resume.
"""
import json
import logging
from pathlib import Path
from typing import Dict, Any, Optional, List
logger = logging.getLogger(__name__)
class AppendOnlyStateLog:
"""Append-only state log for fast crawl state tracking.
Format (one record per line):
P <url> # page seen
M <md5_hash> <url> # media downloaded
S <url> # screenshot taken
D <domain> # skip domain
X <key> <json> # stats checkpoint
Benefits:
- No JSON parsing on write (just append)
- No locking needed for single writer
- Fast resume by scanning lines
- Works with tail -f for monitoring
"""
def __init__(self, path: Path):
self.path = path
self._file = None
def open(self):
"""Open log file for appending."""
self.path.parent.mkdir(parents=True, exist_ok=True)
self._file = open(self.path, 'a', encoding='utf-8', buffering=1) # line buffered
def close(self):
"""Close log file."""
if self._file:
self._file.close()
self._file = None
def __enter__(self):
self.open()
return self
def __exit__(self, *args):
self.close()
def page(self, url: str):
"""Record page as seen."""
if self._file:
self._file.write(f"P {url}\n")
def media(self, md5_hash: str, url: str):
"""Record media as downloaded."""
if self._file:
self._file.write(f"M {md5_hash} {url}\n")
def screenshot(self, url: str):
"""Record screenshot as taken."""
if self._file:
self._file.write(f"S {url}\n")
def skip_domain(self, domain: str):
"""Record domain to skip."""
if self._file:
self._file.write(f"D {domain}\n")
def stats(self, stats_dict: Dict[str, Any]):
"""Record stats checkpoint."""
if self._file:
self._file.write(f"X stats {json.dumps(stats_dict)}\n")
def load(self) -> Dict[str, Any]:
"""Load state from log file.
Returns dict with:
- seen_pages: set of URLs
- seen_media: dict of url -> md5_hash
- seen_screenshots: set of URLs
- skip_domains: set of domains
- stats: last stats checkpoint (or empty dict)
"""
result = {
'seen_pages': set(),
'seen_media': {},
'seen_screenshots': set(),
'skip_domains': set(),
'stats': {},
}
if not self.path.exists():
return result
with open(self.path, 'r', encoding='utf-8') as f:
for line in f:
line = line.rstrip('\n')
if not line:
continue
parts = line.split(' ', 2)
if len(parts) < 2:
continue
record_type = parts[0]
if record_type == 'P':
result['seen_pages'].add(parts[1])
elif record_type == 'M' and len(parts) >= 3:
result['seen_media'][parts[2]] = parts[1] # url -> hash
elif record_type == 'S':
result['seen_screenshots'].add(parts[1])
elif record_type == 'D':
result['skip_domains'].add(parts[1])
elif record_type == 'X' and len(parts) >= 3:
try:
result['stats'] = json.loads(parts[2])
except json.JSONDecodeError:
pass
return result
def get_state_log_path(domain: str) -> Path:
"""Get append-only state log path for a domain."""
safe_domain = domain.replace('://', '-').replace('/', '-').replace('.', '-')
return Path(f"data/{safe_domain}.log")
def get_state_file_path(domain: str) -> Path:
"""Get unified state file path for a domain.
Args:
domain: Domain name (e.g., 'example.com')
Returns:
Path like data/{domain}.state
"""
safe_domain = domain.replace('://', '-').replace('/', '-').replace('.', '-')
return Path(f"data/{safe_domain}.state")
def rotate_state_file(state_path: Path, preserve_keys: List[str] = None) -> Optional[Path]:
"""Rotate state file with optional selective preservation.
Args:
state_path: Path to state file
preserve_keys: If provided, rotate then copy back these keys from rotated file.
If None, just rotate (full fresh start).
Returns:
Path to rotated file, or None if no rotation needed.
"""
if not state_path.exists():
return None
# Find next available rotation number
i = 1
while Path(f"{state_path}.{i}").exists():
i += 1
rotated = Path(f"{state_path}.{i}")
state_path.rename(rotated)
logger.info(f"Rotated state file to {rotated}")
# If preserve_keys specified, copy back those keys from rotated file
if preserve_keys:
try:
old_state = json.loads(rotated.read_text())
new_state = {k: v for k, v in old_state.items() if k in preserve_keys}
if new_state:
state_path.write_text(json.dumps(new_state, indent=2))
logger.info(f"Preserved keys: {list(new_state.keys())}")
except Exception as e:
logger.warning(f"Could not preserve state keys: {e}")
return rotated