Add collector engine: base, custom source, profile loader, wikileaks helpers
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .base import BaseCollector
|
||||
from .custom import CustomSource
|
||||
from .profile_loader import load_profile, list_profiles
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user