198 lines
6.3 KiB
Python
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
|