Add collector engine: base, custom source, profile loader, wikileaks helpers

This commit is contained in:
n0mad1k
2026-03-19 08:39:17 -04:00
parent 176156d4e9
commit 4f381436a4
5 changed files with 783 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from .base import BaseCollector
from .custom import CustomSource
from .profile_loader import load_profile, list_profiles
+292
View File
@@ -0,0 +1,292 @@
"""Abstract base collector for Mosaic document acquisition."""
import os
import tempfile
import hashlib
import logging
from abc import ABC, abstractmethod
from datetime import datetime
from pathlib import Path
from typing import Optional
import httpx
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
from storage.db import MosaicDB
from storage.models import Document
from utils.http_limiter import HTTPRateLimiter
from utils.cache import DownloadCache
from utils.sanitize import sanitize_filename, validate_content_type
logger = logging.getLogger(__name__)
class BaseCollector(ABC):
"""Abstract base for all document collectors."""
def __init__(self, db: MosaicDB, cache: DownloadCache,
rate_limiter: Optional[HTTPRateLimiter] = None,
config: Optional[dict] = None):
self.db = db
self.cache = cache
self.rate_limiter = rate_limiter or HTTPRateLimiter()
self.config = config or {}
self.user_agent = self.config.get('user_agent', 'Mozilla/5.0 (compatible; research-crawler/1.0)')
self.timeout = int(self.config.get('timeout', 30))
self.max_retries = int(self.config.get('max_retries', 3))
@abstractmethod
def discover(self, since: Optional[str] = None,
until: Optional[str] = None,
limit: Optional[int] = None) -> list[dict]:
"""Discover document URLs from the source.
Returns list of dicts: {'url': str, 'title': str, 'date': str|None, 'doc_type': str}
"""
pass
@abstractmethod
def get_name(self) -> str:
"""Return the source name."""
pass
def expected_count(self) -> int:
"""Minimum expected document count for validation."""
return 0
def fetch(self, url: str, collection: str) -> Optional[Document]:
"""Download a URL with atomic writes and hash verification.
Returns Document if successful, None if failed.
"""
# Check if already cached
existing = self.db.get_document_by_url(url)
if existing and existing.status == 'CACHED':
cached_path = self.cache.get_cached_path(
existing.content_hash, collection,
self._url_extension(url)
)
if cached_path:
logger.debug("Already cached: %s", url)
return existing
# Rate limit
self.rate_limiter.wait()
# Download to temp file
try:
with httpx.Client(timeout=self.timeout, follow_redirects=True) as client:
response = client.get(url, headers={'User-Agent': self.user_agent})
response.raise_for_status()
content = response.content
content_hash = hashlib.sha256(content).hexdigest()
content_length = len(content)
etag = response.headers.get('etag')
last_modified = response.headers.get('last-modified')
content_type = response.headers.get('content-type', '').split(';')[0].strip()
# Write to temp file
ext = self._url_extension(url) or self._mime_to_ext(content_type)
tmp_fd, tmp_path = tempfile.mkstemp(suffix=f'.{ext}.tmp', dir=str(self.cache.base_dir))
try:
os.write(tmp_fd, content)
os.close(tmp_fd)
# Validate content type by magic bytes
try:
detected_type = validate_content_type(tmp_path)
except ValueError:
detected_type = content_type
# Atomic store
final_path = self.cache.store_atomic(
tmp_path, content_hash, collection, ext, content_length
)
except Exception:
# Clean up temp file on failure
os.close(tmp_fd) if not os.path.exists(tmp_path) else None
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
# Build document
doc_type = self._detect_doc_type(detected_type)
doc = Document(
id=content_hash,
source=collection,
source_url=url,
doc_type=doc_type,
title=self._extract_title(url),
raw_path=final_path,
content_hash=content_hash,
fetch_date=datetime.utcnow().isoformat(),
etag=etag,
last_modified=last_modified,
char_count=len(content.decode('utf-8', errors='replace')),
status='CACHED',
parse_status='PENDING',
)
self.db.insert_document(doc)
return doc
except httpx.HTTPStatusError as e:
logger.error("HTTP %d for %s: %s", e.response.status_code, url, e)
except httpx.RequestError as e:
logger.error("Request error for %s: %s", url, e)
except Exception as e:
logger.error("Download failed for %s: %s", url, e)
# Record failure
if existing:
self.db.update_document_status(existing.id, status='DOWNLOAD_FAILED')
return None
def check_update(self, url: str) -> bool:
"""Check if a cached URL has been updated (HTTP HEAD with ETag/Last-Modified).
Returns True if the document has changed and should be re-downloaded.
"""
existing = self.db.get_document_by_url(url)
if not existing:
return True
try:
self.rate_limiter.wait()
with httpx.Client(timeout=self.timeout, follow_redirects=True) as client:
response = client.head(url, headers={'User-Agent': self.user_agent})
response.raise_for_status()
new_etag = response.headers.get('etag')
new_modified = response.headers.get('last-modified')
if existing.etag and new_etag:
return existing.etag != new_etag
if existing.last_modified and new_modified:
return existing.last_modified != new_modified
# Can't determine — assume changed
return True
except Exception as e:
logger.warning("HEAD check failed for %s: %s", url, e)
return False
def collect(self, since: Optional[str] = None, until: Optional[str] = None,
limit: Optional[int] = None, update: bool = False,
retry_failed: bool = False) -> dict:
"""Main collection entry point with progress tracking.
Returns dict with counts: {'discovered', 'downloaded', 'cached', 'failed', 'updated'}
"""
name = self.get_name()
stats = {'discovered': 0, 'downloaded': 0, 'cached': 0, 'failed': 0, 'updated': 0}
# Check disk space
if not self.cache.check_disk_space(required_mb=100):
logger.error("Insufficient disk space for collection")
return stats
# Discover URLs
urls = self.discover(since=since, until=until, limit=limit)
stats['discovered'] = len(urls)
# Validate against expected count
expected = self.expected_count()
if expected > 0 and len(urls) < expected * 0.1:
logger.warning(
"Discovered only %d URLs (expected >%d). "
"Site structure may have changed.",
len(urls), int(expected * 0.1)
)
# Filter for update mode
if update:
urls_to_process = []
for item in urls:
if self.check_update(item['url']):
urls_to_process.append(item)
stats['updated'] += 1
urls = urls_to_process
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
transient=True,
) as progress:
task = progress.add_task(f"Collecting {name}", total=len(urls))
for item in urls:
url = item['url']
# Skip already cached (unless update mode)
existing = self.db.get_document_by_url(url)
if existing and existing.status == 'CACHED' and not update:
stats['cached'] += 1
progress.advance(task)
continue
# Skip failed unless retry
if existing and existing.status == 'DOWNLOAD_FAILED' and not retry_failed:
stats['failed'] += 1
progress.advance(task)
continue
doc = self.fetch(url, name)
if doc:
# Update with discovered metadata
if item.get('title'):
doc.title = item['title']
if item.get('date'):
doc.date = item['date']
self.db.insert_document(doc)
stats['downloaded'] += 1
else:
stats['failed'] += 1
progress.advance(task)
return stats
def _url_extension(self, url: str) -> str:
"""Extract file extension from URL."""
from urllib.parse import urlparse
path = urlparse(url).path
ext = Path(path).suffix.lstrip('.')
if ext and len(ext) <= 10:
return sanitize_filename(ext).lower()
return 'html'
def _mime_to_ext(self, mime: str) -> str:
"""Convert MIME type to file extension."""
mapping = {
'text/html': 'html',
'application/pdf': 'pdf',
'text/plain': 'txt',
'message/rfc822': 'eml',
'application/json': 'json',
}
return mapping.get(mime, 'bin')
def _detect_doc_type(self, content_type: str) -> str:
"""Map content type to our doc_type enum."""
if 'html' in content_type:
return 'html'
elif 'pdf' in content_type:
return 'pdf'
elif 'email' in content_type or 'rfc822' in content_type:
return 'email'
elif 'text' in content_type:
return 'text'
return 'html' # default for web pages
def _extract_title(self, url: str) -> str:
"""Extract a title from URL path."""
from urllib.parse import urlparse, unquote
path = urlparse(url).path
parts = [p for p in path.split('/') if p]
if parts:
return unquote(parts[-1]).replace('-', ' ').replace('_', ' ')
return url
+217
View File
@@ -0,0 +1,217 @@
"""Universal source collector configured by YAML profiles."""
import re
import logging
from urllib.parse import urlparse, urljoin, unquote
from typing import Optional
from datetime import datetime
import httpx
from bs4 import BeautifulSoup
from .base import BaseCollector
from storage.db import MosaicDB
from utils.http_limiter import HTTPRateLimiter
from utils.cache import DownloadCache
logger = logging.getLogger(__name__)
class CustomSource(BaseCollector):
"""Universal collector that crawls any source based on profile configuration."""
def __init__(self, profile: dict, db: MosaicDB, cache: DownloadCache,
rate_limiter: Optional[HTTPRateLimiter] = None,
config: Optional[dict] = None):
# Set rate from profile
rate = profile.get('rate_limit', 1.0)
if rate_limiter is None:
rate_limiter = HTTPRateLimiter(rate=rate, jitter=0.5)
super().__init__(db, cache, rate_limiter, config)
self.profile = profile
self.name = profile['name']
self.base_url = profile['base_url']
self.domain_allowlist = set(profile.get('domain_allowlist', []))
self.crawl_rules = profile.get('crawl_rules', {})
self.content_types = set(profile.get('content_types', ['text/html', 'application/pdf']))
self.mirrors = profile.get('mirrors', [])
# Compile URL patterns
self.url_patterns = [
re.compile(self._glob_to_regex(p))
for p in self.crawl_rules.get('url_patterns', [])
]
self.exclude_patterns = [
re.compile(self._glob_to_regex(p))
for p in self.crawl_rules.get('exclude_patterns', [])
]
# Import wikileaks helpers if applicable
self._wikileaks_helpers = None
if any(d.endswith('wikileaks.org') for d in self.domain_allowlist):
try:
from . import wikileaks
self._wikileaks_helpers = wikileaks
logger.debug("WikiLeaks helpers loaded for %s", self.name)
except ImportError:
pass
def get_name(self) -> str:
return self.name
def expected_count(self) -> int:
return self.profile.get('expected_min_count', 0)
def discover(self, since: Optional[str] = None,
until: Optional[str] = None,
limit: Optional[int] = None) -> list[dict]:
"""Crawl source to discover document URLs."""
max_depth = self.crawl_rules.get('max_depth', 2)
max_docs = limit or self.crawl_rules.get('max_documents', 1000)
discovered = []
visited = set()
queue = [(self.base_url, 0)]
while queue and len(discovered) < max_docs:
url, depth = queue.pop(0)
if url in visited:
continue
visited.add(url)
if depth > max_depth:
continue
# Domain check
if not self._is_allowed_domain(url):
continue
# Exclude check
if self._is_excluded(url):
continue
logger.debug("Crawling: %s (depth %d)", url, depth)
self.rate_limiter.wait()
try:
with httpx.Client(timeout=self.timeout, follow_redirects=True) as client:
response = client.get(url, headers={'User-Agent': self.user_agent})
response.raise_for_status()
content_type = response.headers.get('content-type', '').split(';')[0].strip()
# If it's a document (not just a page to crawl), add it
if self._matches_url_pattern(url) or self._is_document_type(content_type):
item = {
'url': url,
'title': self._extract_title_from_url(url),
'date': None,
'doc_type': self._content_type_to_doc_type(content_type),
}
# Date filtering
if since and item.get('date') and item['date'] < since:
continue
if until and item.get('date') and item['date'] > until:
continue
discovered.append(item)
# Extract links if HTML and should follow links
if 'html' in content_type and self.crawl_rules.get('follow_links', True):
links = self._extract_links(response.text, url)
# Use wikileaks helpers if available
if self._wikileaks_helpers:
extra = self._wikileaks_helpers.extract_extra_links(
response.text, url, self.name
)
links.extend(extra)
for link in links:
if link not in visited and len(discovered) < max_docs:
queue.append((link, depth + 1))
except httpx.HTTPStatusError as e:
logger.warning("HTTP %d for %s", e.response.status_code, url)
except httpx.RequestError as e:
logger.warning("Request error for %s: %s", url, e)
except Exception as e:
logger.warning("Error crawling %s: %s", url, e)
logger.info("Discovered %d documents from %s", len(discovered), self.name)
return discovered[:max_docs]
def _is_allowed_domain(self, url: str) -> bool:
"""Check if URL domain is in the allowlist."""
if not self.domain_allowlist:
return True
parsed = urlparse(url)
return parsed.netloc in self.domain_allowlist
def _is_excluded(self, url: str) -> bool:
"""Check if URL matches any exclude pattern."""
for pattern in self.exclude_patterns:
if pattern.search(url):
return True
return False
def _matches_url_pattern(self, url: str) -> bool:
"""Check if URL matches any include pattern."""
if not self.url_patterns:
return True
for pattern in self.url_patterns:
if pattern.search(url):
return True
return False
def _is_document_type(self, content_type: str) -> bool:
"""Check if content type is one we want to collect."""
return content_type in self.content_types
def _extract_links(self, html: str, base_url: str) -> list[str]:
"""Extract absolute URLs from HTML page."""
links = []
try:
soup = BeautifulSoup(html, 'html.parser')
for tag in soup.find_all('a', href=True):
href = tag['href']
# Skip fragments, javascript, mailto
if href.startswith(('#', 'javascript:', 'mailto:')):
continue
absolute = urljoin(base_url, href)
# Strip fragments
absolute = absolute.split('#')[0]
if absolute and self._is_allowed_domain(absolute):
links.append(absolute)
except Exception as e:
logger.warning("Link extraction failed for %s: %s", base_url, e)
return links
def _extract_title_from_url(self, url: str) -> str:
"""Extract a title from URL path."""
path = urlparse(url).path
parts = [p for p in path.split('/') if p]
if parts:
return unquote(parts[-1]).replace('-', ' ').replace('_', ' ')
return url
def _content_type_to_doc_type(self, content_type: str) -> str:
"""Map content type to doc_type."""
if 'html' in content_type:
return 'html'
elif 'pdf' in content_type:
return 'pdf'
elif 'text' in content_type:
return 'text'
return 'html'
@staticmethod
def _glob_to_regex(pattern: str) -> str:
"""Convert glob pattern to regex."""
# Escape regex special chars except * and ?
pattern = re.escape(pattern)
# Convert glob wildcards
pattern = pattern.replace(r'\*', '.*')
pattern = pattern.replace(r'\?', '.')
return pattern
+129
View File
@@ -0,0 +1,129 @@
"""Load YAML source profiles for Mosaic collectors."""
import logging
from pathlib import Path
from typing import Optional
import yaml
logger = logging.getLogger(__name__)
PROFILES_DIR = Path(__file__).parent / 'profiles'
REQUIRED_FIELDS = ['name', 'base_url', 'domain_allowlist']
DEFAULT_CRAWL_RULES = {
'max_depth': 2,
'max_documents': 1000,
'follow_links': True,
'url_patterns': [],
'exclude_patterns': ['*.css', '*.js', '*.ico', '*.png', '*.jpg', '*.gif'],
}
def load_profile(name_or_path: str) -> dict:
"""Load a source profile by name or file path.
Args:
name_or_path: Profile name (e.g., 'vault7') or path to YAML file
Returns:
Profile dict with all fields populated (defaults filled in)
Raises:
FileNotFoundError: If profile not found
ValueError: If profile missing required fields
"""
path = Path(name_or_path)
# Check if it's a direct path
if path.exists() and path.suffix in ('.yaml', '.yml'):
profile_path = path
else:
# Look in profiles directory
profile_path = PROFILES_DIR / f"{name_or_path}.yaml"
if not profile_path.exists():
profile_path = PROFILES_DIR / f"{name_or_path}.yml"
if not profile_path.exists():
raise FileNotFoundError(f"Profile not found: {name_or_path}")
with open(profile_path, 'r') as f:
profile = yaml.safe_load(f)
if not profile:
raise ValueError(f"Empty profile: {profile_path}")
# Validate required fields
for field in REQUIRED_FIELDS:
if field not in profile:
raise ValueError(f"Profile missing required field: {field}")
# Fill defaults
profile.setdefault('description', '')
profile.setdefault('content_types', ['text/html', 'application/pdf'])
profile.setdefault('expected_min_count', 0)
profile.setdefault('rate_limit', 1.0)
profile.setdefault('mirrors', [])
crawl_rules = profile.get('crawl_rules', {})
for key, default in DEFAULT_CRAWL_RULES.items():
crawl_rules.setdefault(key, default)
profile['crawl_rules'] = crawl_rules
return profile
def create_adhoc_profile(url: str, name: str) -> dict:
"""Create an ad-hoc profile for a URL.
Args:
url: Base URL to crawl
name: Name for this source
Returns:
Profile dict
"""
from urllib.parse import urlparse
parsed = urlparse(url)
domain = parsed.netloc
return {
'name': name,
'description': f"Ad-hoc source: {url}",
'base_url': url,
'domain_allowlist': [domain],
'crawl_rules': dict(DEFAULT_CRAWL_RULES),
'content_types': ['text/html', 'application/pdf'],
'expected_min_count': 0,
'rate_limit': 1.0,
'mirrors': [],
}
def list_profiles() -> list[dict]:
"""List all available source profiles.
Returns:
List of dicts with 'name', 'description', 'path' for each profile
"""
profiles = []
if not PROFILES_DIR.exists():
return profiles
for path in sorted(PROFILES_DIR.glob('*.yaml')):
if path.stem == 'example':
continue
try:
with open(path, 'r') as f:
data = yaml.safe_load(f)
if data:
profiles.append({
'name': data.get('name', path.stem),
'description': data.get('description', ''),
'path': str(path),
'expected_count': data.get('expected_min_count', 0),
})
except Exception as e:
logger.warning("Failed to load profile %s: %s", path, e)
return profiles
+142
View File
@@ -0,0 +1,142 @@
"""WikiLeaks-specific crawl helpers for Mosaic collectors."""
import re
import logging
from urllib.parse import urljoin
from typing import Optional
logger = logging.getLogger(__name__)
def extract_extra_links(html: str, base_url: str, source_name: str) -> list[str]:
"""Extract additional links using WikiLeaks-specific patterns.
Different WikiLeaks collections have different page structures.
This function handles the quirks of each.
"""
links = []
if source_name == 'vault7':
links.extend(_vault7_links(html, base_url))
elif source_name == 'vault8':
links.extend(_vault8_links(html, base_url))
elif source_name == 'cablegate':
links.extend(_cablegate_links(html, base_url))
elif source_name == 'spyfiles':
links.extend(_spyfiles_links(html, base_url))
elif source_name == 'emails':
links.extend(_email_links(html, base_url))
return links
def _vault7_links(html: str, base_url: str) -> list[str]:
"""Extract Vault 7 project page links.
Vault 7 has a main index with project names that link to sub-pages.
Each project page has document links.
"""
links = []
# Match project links like /vault7/#Dark Matter
for match in re.finditer(r'href=["\'](/vault7/[^"\']+)["\']', html):
link = urljoin(base_url, match.group(1))
links.append(link)
# Match document/file links
for match in re.finditer(r'href=["\']([^"\']*(?:\.pdf|\.zip|\.tar\.gz)[^"\']*)["\']', html, re.IGNORECASE):
link = urljoin(base_url, match.group(1))
links.append(link)
return links
def _vault8_links(html: str, base_url: str) -> list[str]:
"""Extract Vault 8 source code repository links."""
links = []
for match in re.finditer(r'href=["\'](/vault8/[^"\']+)["\']', html):
link = urljoin(base_url, match.group(1))
links.append(link)
return links
def _cablegate_links(html: str, base_url: str) -> list[str]:
"""Extract Cablegate cable links with pagination support.
Cablegate uses /plusd/cables/ with date-based navigation.
Individual cables are at /plusd/cables/CABLE_ID
"""
links = []
# Individual cable links
for match in re.finditer(r'href=["\'](/plusd/cables/[0-9A-Z]+)["\']', html):
link = urljoin(base_url, match.group(1))
links.append(link)
# Pagination links
for match in re.finditer(r'href=["\'](/plusd/[^"\']*(?:page|offset|start)=[^"\']+)["\']', html):
link = urljoin(base_url, match.group(1))
links.append(link)
# Date-based navigation
for match in re.finditer(r'href=["\'](/plusd/cables/\?[^"\']+)["\']', html):
link = urljoin(base_url, match.group(1))
links.append(link)
return links
def _spyfiles_links(html: str, base_url: str) -> list[str]:
"""Extract Spy Files document links."""
links = []
for match in re.finditer(r'href=["\'](/spyfiles/[^"\']+)["\']', html):
link = urljoin(base_url, match.group(1))
links.append(link)
return links
def _email_links(html: str, base_url: str) -> list[str]:
"""Extract email dump links with ID-based navigation."""
links = []
# Email ID links
for match in re.finditer(r'href=["\']([^"\']*emailid/\d+)["\']', html):
link = urljoin(base_url, match.group(1))
links.append(link)
# Pagination
for match in re.finditer(r'href=["\']([^"\']*(?:page|offset)=\d+)["\']', html):
link = urljoin(base_url, match.group(1))
links.append(link)
return links
def parse_cable_date(cable_id: str) -> Optional[str]:
"""Extract date from cable ID format.
Cable IDs often start with a two-digit year and month:
e.g., 07PARIS1234 → 2007 (approximate)
"""
match = re.match(r'^(\d{2})[A-Z]', cable_id)
if match:
year = int(match.group(1))
# WikiLeaks cables span roughly 1966-2010
if year >= 66:
return f"19{year:02d}"
else:
return f"20{year:02d}"
return None
def detect_wikileaks_source(url: str) -> Optional[str]:
"""Detect which WikiLeaks collection a URL belongs to."""
if '/vault7/' in url:
return 'vault7'
elif '/vault8/' in url:
return 'vault8'
elif '/plusd/' in url or '/cablegate/' in url:
return 'cablegate'
elif '/spyfiles/' in url:
return 'spyfiles'
elif 'emailid/' in url or '/dnc-emails/' in url or '/podesta-emails/' in url:
return 'emails'
return None