Add filevault system with async wrapper and convert sync ops to async
New files: - filevault.py: Hash-based file storage with use_pairs option (v1.1.0) - async_filevault.py: Async wrapper using asyncio.to_thread() - domain_vault.py: Triple vault system for web archival (HTML, Media, Linkpeek) - screenshot.py: Async screenshot capture using uri2png - tests/unit/: Comprehensive test suite (85 tests) Sync-to-async conversions: - storage.py: Wrap Path operations in asyncio.to_thread() - domain_vault.py: Wrap exists(), mkdir(), rglob(), os.walk() in asyncio.to_thread() - screenshot.py: Wrap read_bytes(), write_bytes(), unlink() in asyncio.to_thread() All sync filesystem operations now run in thread pool to avoid blocking async loop.
This commit is contained in:
parent
6dab26d6fa
commit
bc11c516b6
13 changed files with 3861 additions and 144 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -11,6 +11,9 @@ venv/
|
|||
vault/
|
||||
test_vault/
|
||||
|
||||
# Vendored dependencies
|
||||
vendor/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
|
|
|||
25
Makefile
25
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: venv install test crawl serp clean
|
||||
.PHONY: venv install test crawl serp clean vendor-uri2png vendor-install
|
||||
|
||||
VENV := .venv
|
||||
PYTHON := $(VENV)/bin/python
|
||||
|
|
@ -24,9 +24,30 @@ clean:
|
|||
rm -rf $(VENV) __pycache__ *.pyc
|
||||
rm -rf test_vault test_neopig.db
|
||||
|
||||
# Vendor dependencies
|
||||
vendor-uri2png:
|
||||
@echo "Fetching uri2png..."
|
||||
rm -rf vendor/uri2png
|
||||
mkdir -p vendor
|
||||
git clone --depth 1 https://github.com/russellballestrini/uri2png vendor/uri2png
|
||||
rm -rf vendor/uri2png/.git
|
||||
@echo "uri2png vendored at vendor/uri2png"
|
||||
|
||||
vendor-install: vendor-uri2png venv
|
||||
$(PIP) install -e vendor/uri2png/python
|
||||
$(PYTHON) -m playwright install
|
||||
@echo "uri2png Python package installed"
|
||||
|
||||
# Combined server (SERP + screenshot)
|
||||
server: vendor-install
|
||||
$(PYTHON) serp.py --host 0.0.0.0 --port 8000
|
||||
|
||||
# Examples:
|
||||
# make install - create venv and install deps
|
||||
# make test - run functional test
|
||||
# make serp - start SERP server
|
||||
# make serp - start basic SERP server
|
||||
# make server - start combined server (SERP + screenshot)
|
||||
# make crawl ARGS="https://example.com rick morty --mode images"
|
||||
# make clean - remove venv and test artifacts
|
||||
# make vendor-uri2png - fetch uri2png into vendor/
|
||||
# make vendor-install - install uri2png Python package with screenshot support
|
||||
|
|
|
|||
120
README.md
Normal file
120
README.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# neopig
|
||||
|
||||
*Neo Python Image Grabber*
|
||||
|
||||
Based on [pig.py](http://russell.ballestrini.net/python-image-grabber-pig-py/) by Russell Ballestrini
|
||||
|
||||
neopig is a full-domain async media crawler with:
|
||||
- **MD5 deduplication** - same content stored once, multiple sources tracked
|
||||
- **SQLite metadata index** - searchable page context, alt text, titles
|
||||
- **Content-addressed vault** - files stored by hash, not filename
|
||||
- **Web SERP interface** - search and browse crawled media
|
||||
- **Multi-target crawling** - crawl multiple domains concurrently
|
||||
- **Resume support** - restart crawls without re-downloading
|
||||
- **Page screenshots** - optional [uri2png](https://github.com/russellballestrini/uri2png) integration
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI Crawler
|
||||
|
||||
```bash
|
||||
# Crawl single target for images
|
||||
python neopig.py https://example.com --mode images
|
||||
|
||||
# Crawl multiple targets concurrently
|
||||
python neopig.py https://site1.com https://site2.com https://site3.com --mode images
|
||||
|
||||
# Crawl with keywords for tagging
|
||||
python neopig.py https://example.com -k "landscape" "nature" --mode images
|
||||
|
||||
# Crawl all media (images + videos + audio)
|
||||
python neopig.py https://example.com --mode media
|
||||
|
||||
# Limit crawl depth and pages
|
||||
python neopig.py https://example.com --depth 5 --max-pages 500 --mode images
|
||||
|
||||
# Index URLs without downloading
|
||||
python neopig.py https://example.com --no-download --mode images
|
||||
|
||||
# Enable page screenshots (requires uri2png)
|
||||
python neopig.py https://example.com --mode images --screenshot
|
||||
|
||||
# Screenshots with custom viewport
|
||||
python neopig.py https://example.com --mode images --screenshot --screenshot-width 1920 --screenshot-height 1080
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```
|
||||
positional arguments:
|
||||
targets Target URI(s) to crawl
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
-k, --keywords Keywords to tag media with
|
||||
-m, --mode Crawl mode: text, images, videos, media, all
|
||||
-d, --depth Crawl depth (-1 = unlimited, default: -1)
|
||||
-p, --max-pages Maximum pages to crawl (-1 = unlimited)
|
||||
--db Database path (default: neopig.db)
|
||||
--vault Vault storage path (default: vault)
|
||||
--no-download Don't download media, just index URLs
|
||||
-v, --verbose Verbose output
|
||||
|
||||
screenshot options (all off by default, requires uri2png):
|
||||
--screenshot Enable page screenshots
|
||||
--screenshot-width Viewport width in pixels (default: 1280)
|
||||
--screenshot-height Viewport height in pixels (default: 1024)
|
||||
--screenshot-delay Delay after page load in ms (default: 1000)
|
||||
```
|
||||
|
||||
### Web SERP (Search Engine Results Page)
|
||||
|
||||
```bash
|
||||
# Start the web interface
|
||||
python serp.py --host 0.0.0.0 --port 8000
|
||||
|
||||
# With custom db/vault paths
|
||||
python serp.py --port 8000 --db mydata.db --vault ./myfiles
|
||||
```
|
||||
|
||||
Then visit:
|
||||
- `http://localhost:8000/` - Search interface
|
||||
- `http://localhost:8000/live` - Live feed of recent media
|
||||
- `http://localhost:8000/crawl` - Start new crawls via web UI
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
neopig.py CLI crawler entry point
|
||||
serp.py FastAPI web interface (search, live feed, crawl UI)
|
||||
async_web_fetcher.py Async HTTP client with depth crawling
|
||||
database.py SQLite schema and queries
|
||||
storage.py Content-addressed vault (MD5 hash storage)
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
- **media** - Deduplicated content (md5_hash, type, mime, analysis status)
|
||||
- **media_sources** - All contexts where media was found (page URL, alt text, title)
|
||||
- **crawl_jobs** - Crawl history and statistics
|
||||
|
||||
## Output
|
||||
|
||||
Media is stored in a content-addressed vault:
|
||||
```
|
||||
vault/
|
||||
ab/
|
||||
abcd1234...5678.jpg
|
||||
cd/
|
||||
cdef5678...1234.png
|
||||
```
|
||||
|
||||
Files are named by their MD5 hash, organized in 2-character prefix directories.
|
||||
221
async_filevault.py
Normal file
221
async_filevault.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"""
|
||||
AsyncFileVault 1.1.0 - Async wrapper for FileVault
|
||||
|
||||
Provides async versions of FileVault I/O operations using asyncio.to_thread().
|
||||
CPU-bound operations (hash generation, path creation) remain synchronous.
|
||||
|
||||
Usage:
|
||||
vault = AsyncVault(vaultpath="vault", depth=9, use_pairs=True)
|
||||
|
||||
# Sync operations (fast, no I/O)
|
||||
filename = vault.create_filename("seed", ".json", absolute=True)
|
||||
|
||||
# Async operations (file I/O)
|
||||
await vault.atomic_write_json(filename, {"key": "value"})
|
||||
data = await vault.safe_read_json(filename)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from functools import partial
|
||||
from typing import Any, Optional, List
|
||||
|
||||
from filevault import Vault, ensure_bytes, create_vault
|
||||
|
||||
__version__ = "1.1.0"
|
||||
__author__ = "Russell Ballestrini"
|
||||
__license__ = "Public Domain"
|
||||
|
||||
|
||||
class AsyncVault(Vault):
|
||||
"""
|
||||
Async wrapper for Vault with non-blocking file I/O.
|
||||
|
||||
Inherits all sync methods from Vault but provides async versions
|
||||
of file operations that use asyncio.to_thread() to avoid blocking.
|
||||
|
||||
Sync methods (instant, no await needed):
|
||||
- create_filename()
|
||||
- create_random_filename()
|
||||
- _generate_filename()
|
||||
- mark_file_exists()
|
||||
- clear_existence_cache()
|
||||
- get_cache_stats()
|
||||
- purge_cache_for_path_pattern()
|
||||
|
||||
Async methods (await required):
|
||||
- atomic_write_json()
|
||||
- safe_read_json()
|
||||
- write_text_file()
|
||||
- read_text_file()
|
||||
- file_exists()
|
||||
- remove_file()
|
||||
- remove_file_if_exists()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vaultpath: str = "vault",
|
||||
depth: int = 3,
|
||||
salt: str = "changeme",
|
||||
enable_memory_cache: bool = False,
|
||||
use_pairs: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize AsyncVault with same parameters as Vault.
|
||||
|
||||
Args:
|
||||
vaultpath: Base path for vault storage
|
||||
depth: Directory tree depth
|
||||
salt: Salt for hash generation
|
||||
enable_memory_cache: Enable in-memory file existence cache
|
||||
use_pairs: Use hex pairs for directories (256 dirs/level vs 16)
|
||||
"""
|
||||
super().__init__(
|
||||
vaultpath=vaultpath,
|
||||
depth=depth,
|
||||
salt=salt,
|
||||
enable_memory_cache=enable_memory_cache,
|
||||
use_pairs=use_pairs
|
||||
)
|
||||
|
||||
async def atomic_write_json(
|
||||
self,
|
||||
file_path: str,
|
||||
data: Any,
|
||||
indent: int = 2,
|
||||
aliases: Optional[List[str]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Async version of atomic JSON write.
|
||||
|
||||
Args:
|
||||
file_path: Target file path
|
||||
data: Data to write as JSON
|
||||
indent: JSON indentation level
|
||||
aliases: Optional list of alias keys for symlinks
|
||||
"""
|
||||
await asyncio.to_thread(
|
||||
super().atomic_write_json,
|
||||
file_path,
|
||||
data,
|
||||
indent,
|
||||
aliases
|
||||
)
|
||||
|
||||
async def safe_read_json(
|
||||
self,
|
||||
file_path: str,
|
||||
default: Any = None
|
||||
) -> Any:
|
||||
"""
|
||||
Async version of safe JSON read.
|
||||
|
||||
Args:
|
||||
file_path: Path to JSON file
|
||||
default: Default value if file doesn't exist or is invalid
|
||||
|
||||
Returns:
|
||||
Parsed JSON data or default value
|
||||
"""
|
||||
return await asyncio.to_thread(
|
||||
super().safe_read_json,
|
||||
file_path,
|
||||
default
|
||||
)
|
||||
|
||||
async def write_text_file(
|
||||
self,
|
||||
file_path: str,
|
||||
content: str,
|
||||
mode: str = "w"
|
||||
) -> None:
|
||||
"""
|
||||
Async version of text file write.
|
||||
|
||||
Args:
|
||||
file_path: Target file path
|
||||
content: Text content to write
|
||||
mode: File open mode ('w', 'a', etc.)
|
||||
"""
|
||||
await asyncio.to_thread(
|
||||
super().write_text_file,
|
||||
file_path,
|
||||
content,
|
||||
mode
|
||||
)
|
||||
|
||||
async def read_text_file(
|
||||
self,
|
||||
file_path: str,
|
||||
default: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Async version of text file read.
|
||||
|
||||
Args:
|
||||
file_path: Path to text file
|
||||
default: Default value if file doesn't exist
|
||||
|
||||
Returns:
|
||||
File content or default value
|
||||
"""
|
||||
return await asyncio.to_thread(
|
||||
super().read_text_file,
|
||||
file_path,
|
||||
default
|
||||
)
|
||||
|
||||
async def file_exists(self, file_path: str) -> bool:
|
||||
"""
|
||||
Async version of file existence check.
|
||||
|
||||
Note: If memory cache is enabled and the path is cached,
|
||||
this returns immediately from cache without blocking.
|
||||
|
||||
Args:
|
||||
file_path: Path to check
|
||||
|
||||
Returns:
|
||||
True if file exists, False otherwise
|
||||
"""
|
||||
# Check cache first (no I/O needed)
|
||||
if self.enable_memory_cache and self._existence_cache is not None:
|
||||
if file_path in self._existence_cache:
|
||||
return self._existence_cache[file_path]
|
||||
|
||||
# Cache miss - need to hit filesystem
|
||||
return await asyncio.to_thread(super().file_exists, file_path)
|
||||
|
||||
async def remove_file(self, file_path: str) -> bool:
|
||||
"""
|
||||
Async version of file removal.
|
||||
|
||||
Args:
|
||||
file_path: Path to file to remove
|
||||
|
||||
Returns:
|
||||
True if file was removed, False if it didn't exist
|
||||
"""
|
||||
# Call parent's sync remove_file directly via Vault class
|
||||
return await asyncio.to_thread(Vault.remove_file, self, file_path)
|
||||
|
||||
async def remove_file_if_exists(self, file_path: str) -> bool:
|
||||
"""
|
||||
Async version of remove_file_if_exists.
|
||||
|
||||
Args:
|
||||
file_path: Path to file to remove
|
||||
|
||||
Returns:
|
||||
True if file was removed, False if it didn't exist
|
||||
"""
|
||||
try:
|
||||
return await asyncio.to_thread(Vault.remove_file, self, file_path)
|
||||
except OSError:
|
||||
self.mark_file_exists(file_path, False)
|
||||
return False
|
||||
|
||||
|
||||
def create_async_vault(*args, **kwargs) -> AsyncVault:
|
||||
"""Factory function for creating AsyncVault instances."""
|
||||
return AsyncVault(*args, **kwargs)
|
||||
|
|
@ -1356,7 +1356,8 @@ class AsyncWebFetcher:
|
|||
should_continue_callback = None, # Deprecated - kept for compatibility
|
||||
user_query: Optional[str] = None, # Deprecated - kept for compatibility
|
||||
mode: CrawlMode = CrawlMode.TEXT,
|
||||
media_callback = None # Callback for discovered media: async fn(media_item: Dict) -> None
|
||||
media_callback = None, # Callback for discovered media: async fn(media_item: Dict) -> None
|
||||
page_callback = None, # Callback for page HTML: async fn(url: str, html: str) -> None
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Intelligent keyword-driven crawl strategy with domain prioritization.
|
||||
|
|
@ -1465,6 +1466,13 @@ class AsyncWebFetcher:
|
|||
"links_from_page": [l['url'] if isinstance(l, dict) else l for l in links] # Store for CSV
|
||||
}
|
||||
|
||||
# Call page callback to archive raw HTML
|
||||
if page_callback and html:
|
||||
try:
|
||||
await page_callback(start_url, html)
|
||||
except Exception as e:
|
||||
logger.warning(f"Page callback failed for {start_url}: {e}")
|
||||
|
||||
# Extract media if mode requires it
|
||||
if mode in (CrawlMode.IMAGES, CrawlMode.VIDEOS, CrawlMode.MEDIA, CrawlMode.ALL):
|
||||
media_items = extract_media_from_html(html, start_url, mode)
|
||||
|
|
@ -1680,6 +1688,13 @@ class AsyncWebFetcher:
|
|||
"links_from_page": [l['url'] if isinstance(l, dict) else l for l in child_links]
|
||||
}
|
||||
|
||||
# Call page callback to archive raw HTML
|
||||
if page_callback and html:
|
||||
try:
|
||||
await page_callback(link_url, html)
|
||||
except Exception as e:
|
||||
logger.warning(f"Page callback failed for {link_url}: {e}")
|
||||
|
||||
# Extract media if mode requires it
|
||||
if mode in (CrawlMode.IMAGES, CrawlMode.VIDEOS, CrawlMode.MEDIA, CrawlMode.ALL):
|
||||
media_items = extract_media_from_html(html, link_url, mode)
|
||||
|
|
|
|||
792
domain_vault.py
Normal file
792
domain_vault.py
Normal file
|
|
@ -0,0 +1,792 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
domain_vault.py - Triple filevault system for web archival
|
||||
|
||||
Three separate 9-layer deep filevaults, each with git repos per domain.
|
||||
Directory names are salted hashes (NEOPIG_VAULT_SALT env var) for privacy.
|
||||
The same domain + salt produces identical hashes across all three vaults.
|
||||
|
||||
1. HTML Vault (html_vault/):
|
||||
html_vault/{9-layers}/{salted_hash}/
|
||||
.git/ # Git repository (SSH cloneable)
|
||||
{url_path}/index.html # Rewritten HTML with neopig media paths
|
||||
{url_path}/index.html.og # Original HTML with original URIs
|
||||
crawl_log.json # Crawl history
|
||||
|
||||
2. Media Vault (media_vault/):
|
||||
media_vault/{9-layers}/{salted_hash}/
|
||||
.git/ # Git repository with LFS (SSH cloneable)
|
||||
{url_path}/image.png # Media files mirroring original paths
|
||||
index.json # Media index
|
||||
|
||||
3. Linkpeek Vault (linkpeek_vault/):
|
||||
linkpeek_vault/{9-layers}/{salted_hash}/
|
||||
.git/ # Git repository with LFS (SSH cloneable)
|
||||
{url_path}/index.png # Page screenshots mirroring URL paths
|
||||
index.json # Screenshot index
|
||||
|
||||
Environment Variables:
|
||||
NEOPIG_VAULT_SALT: Secret salt for domain hashing (required for privacy)
|
||||
|
||||
Features:
|
||||
- 9-layer deep hash paths for filesystem distribution
|
||||
- Salted hash directory names (domain name not exposed)
|
||||
- Git versioning per domain (all vaults)
|
||||
- Git LFS for media and linkpeek vaults (large files)
|
||||
- SSH cloneable repos
|
||||
- Idempotent: only commits when content changes
|
||||
- .html.og preserves original, .html has rewritten media URIs
|
||||
- Tree structure mirrors original domain URL paths
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from urllib.parse import urlparse, urljoin
|
||||
import aiofiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_vault_salt() -> str:
|
||||
"""Get the vault salt from environment variable."""
|
||||
return os.environ.get('NEOPIG_VAULT_SALT', '')
|
||||
|
||||
|
||||
def domain_hash(domain: str, salted: bool = False) -> str:
|
||||
"""
|
||||
Generate MD5 hash of domain.
|
||||
|
||||
Args:
|
||||
domain: The domain name
|
||||
salted: If True, include NEOPIG_VAULT_SALT for directory naming
|
||||
"""
|
||||
data = domain.lower()
|
||||
if salted:
|
||||
salt = get_vault_salt()
|
||||
data = f"{salt}:{data}"
|
||||
return hashlib.md5(data.encode()).hexdigest()
|
||||
|
||||
|
||||
def content_hash(content: bytes) -> str:
|
||||
"""Generate MD5 hash of content for deduplication."""
|
||||
return hashlib.md5(content).hexdigest()
|
||||
|
||||
|
||||
def get_filevault_path(base_path: Path, domain: str) -> Path:
|
||||
"""
|
||||
Generate 9-layer deep path for filevault.
|
||||
|
||||
Uses salted hash for both path layers AND final directory name.
|
||||
The same domain + salt produces the same hash across all three vaults.
|
||||
|
||||
Example: example.com -> base/ab/cd/ef/12/34/56/78/9a/bc/abcdef1234567890abcdef1234567890/
|
||||
"""
|
||||
h = domain_hash(domain, salted=True)
|
||||
# 9 layers of 2-char directories
|
||||
layers = [h[i:i+2] for i in range(0, 18, 2)] # 9 pairs (18 chars)
|
||||
path = base_path
|
||||
for layer in layers:
|
||||
path = path / layer
|
||||
# Final directory is the full salted hash, not the domain name
|
||||
return path / h
|
||||
|
||||
|
||||
def url_to_filepath(url: str) -> str:
|
||||
"""
|
||||
Convert URL to filesystem path that mirrors the original structure.
|
||||
|
||||
Examples:
|
||||
https://example.com/ -> index.html
|
||||
https://example.com/about -> about/index.html
|
||||
https://example.com/blog/post.html -> blog/post.html
|
||||
https://example.com/images/logo.png -> images/logo.png
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path.strip('/')
|
||||
|
||||
if not path:
|
||||
return 'index.html'
|
||||
|
||||
# Check if path has an extension
|
||||
if '.' in path.split('/')[-1]:
|
||||
return path
|
||||
else:
|
||||
# Directory-style URL, add index.html
|
||||
return f"{path}/index.html"
|
||||
|
||||
|
||||
def extract_media_urls(html: str, base_url: str) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Extract media URLs from HTML.
|
||||
|
||||
Returns list of (absolute_url, original_reference) tuples.
|
||||
"""
|
||||
media_patterns = [
|
||||
# Images
|
||||
r'<img[^>]+src=["\']([^"\']+)["\']',
|
||||
r'<source[^>]+src=["\']([^"\']+)["\']',
|
||||
# CSS backgrounds
|
||||
r'url\(["\']?([^"\')\s]+)["\']?\)',
|
||||
# Video/Audio
|
||||
r'<video[^>]+src=["\']([^"\']+)["\']',
|
||||
r'<audio[^>]+src=["\']([^"\']+)["\']',
|
||||
# Links to media
|
||||
r'<link[^>]+href=["\']([^"\']+\.(?:css|ico|png|jpg|jpeg|gif|svg|woff2?|ttf|eot))["\']',
|
||||
]
|
||||
|
||||
found = []
|
||||
for pattern in media_patterns:
|
||||
for match in re.finditer(pattern, html, re.IGNORECASE):
|
||||
original = match.group(1)
|
||||
# Skip data URIs and anchors
|
||||
if original.startswith('data:') or original.startswith('#'):
|
||||
continue
|
||||
absolute = urljoin(base_url, original)
|
||||
found.append((absolute, original))
|
||||
|
||||
return found
|
||||
|
||||
|
||||
class GitRepo:
|
||||
"""Async git operations helper."""
|
||||
|
||||
def __init__(self, path: Path, use_lfs: bool = False):
|
||||
self.path = path
|
||||
self.use_lfs = use_lfs
|
||||
|
||||
async def init(self) -> bool:
|
||||
"""Initialize git repo. Returns True if newly created."""
|
||||
git_path = self.path / '.git'
|
||||
if await asyncio.to_thread(git_path.exists):
|
||||
return False
|
||||
|
||||
await asyncio.to_thread(self.path.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
await self._run('git', 'init')
|
||||
await self._run('git', 'config', 'user.email', 'neopig@localhost')
|
||||
await self._run('git', 'config', 'user.name', 'neopig')
|
||||
|
||||
if self.use_lfs:
|
||||
await self._init_lfs()
|
||||
|
||||
logger.info(f"Initialized git repo at {self.path}")
|
||||
return True
|
||||
|
||||
async def _init_lfs(self):
|
||||
"""Initialize git LFS for media files."""
|
||||
await self._run('git', 'lfs', 'install', '--local')
|
||||
# Track common media extensions
|
||||
extensions = [
|
||||
'*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.svg',
|
||||
'*.mp4', '*.webm', '*.mov', '*.avi',
|
||||
'*.mp3', '*.wav', '*.ogg', '*.flac',
|
||||
'*.pdf', '*.zip', '*.tar', '*.gz',
|
||||
'*.woff', '*.woff2', '*.ttf', '*.eot',
|
||||
]
|
||||
for ext in extensions:
|
||||
await self._run('git', 'lfs', 'track', ext)
|
||||
await self._run('git', 'add', '.gitattributes')
|
||||
logger.info(f"Initialized git LFS at {self.path}")
|
||||
|
||||
async def _run(self, *args) -> Tuple[bytes, bytes]:
|
||||
"""Run git command."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
cwd=str(self.path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
return await proc.communicate()
|
||||
|
||||
async def has_changes(self) -> bool:
|
||||
"""Check if there are uncommitted changes."""
|
||||
stdout, _ = await self._run('git', 'status', '--porcelain')
|
||||
return len(stdout.strip()) > 0
|
||||
|
||||
async def add_all(self):
|
||||
"""Stage all changes."""
|
||||
await self._run('git', 'add', '-A')
|
||||
|
||||
async def commit(self, message: str) -> Optional[str]:
|
||||
"""Commit if changes exist. Returns commit hash or None."""
|
||||
await self.add_all()
|
||||
if not await self.has_changes():
|
||||
return None
|
||||
|
||||
stdout, stderr = await self._run('git', 'commit', '-m', message)
|
||||
stdout, _ = await self._run('git', 'rev-parse', 'HEAD')
|
||||
commit_hash = stdout.decode().strip()
|
||||
logger.info(f"Committed {commit_hash[:8]}: {message}")
|
||||
return commit_hash
|
||||
|
||||
async def log(self, limit: int = 10) -> List[Dict[str, str]]:
|
||||
"""Get recent commit history."""
|
||||
stdout, _ = await self._run('git', 'log', f'-{limit}', '--format=%H|%s|%ci')
|
||||
commits = []
|
||||
for line in stdout.decode().strip().split('\n'):
|
||||
if '|' in line:
|
||||
parts = line.split('|', 2)
|
||||
if len(parts) == 3:
|
||||
commits.append({
|
||||
'hash': parts[0],
|
||||
'message': parts[1],
|
||||
'date': parts[2],
|
||||
})
|
||||
return commits
|
||||
|
||||
|
||||
class DomainHtmlVault:
|
||||
"""
|
||||
Git-tracked HTML vault for a single domain.
|
||||
|
||||
Stores pages in a tree structure mirroring the original domain:
|
||||
- {path}/index.html.og = Original HTML
|
||||
- {path}/index.html = Rewritten with neopig media paths
|
||||
"""
|
||||
|
||||
def __init__(self, vault_base: str, domain: str, media_base_url: str = '/media'):
|
||||
self.domain = domain.lower()
|
||||
self.base_path = Path(vault_base)
|
||||
self.path = get_filevault_path(self.base_path, self.domain)
|
||||
self.crawl_log_path = self.path / 'crawl_log.json'
|
||||
self.media_base_url = media_base_url.rstrip('/')
|
||||
self._git = GitRepo(self.path, use_lfs=False)
|
||||
self._initialized = False
|
||||
|
||||
async def init(self) -> bool:
|
||||
"""Initialize vault. Returns True if newly created."""
|
||||
is_new = await self._git.init()
|
||||
|
||||
if not await asyncio.to_thread(self.crawl_log_path.exists):
|
||||
await self._save_crawl_log({'crawls': []})
|
||||
is_new = True
|
||||
|
||||
self._initialized = True
|
||||
return is_new
|
||||
|
||||
async def _load_crawl_log(self) -> Dict[str, Any]:
|
||||
if not await asyncio.to_thread(self.crawl_log_path.exists):
|
||||
return {'crawls': []}
|
||||
async with aiofiles.open(self.crawl_log_path, 'r') as f:
|
||||
return json.loads(await f.read())
|
||||
|
||||
async def _save_crawl_log(self, log: Dict[str, Any]):
|
||||
async with aiofiles.open(self.crawl_log_path, 'w') as f:
|
||||
await f.write(json.dumps(log, indent=2))
|
||||
|
||||
def get_media_vault_url(self, media_url: str) -> str:
|
||||
"""
|
||||
Convert original media URL to neopig media vault path.
|
||||
|
||||
Example: https://example.com/images/logo.png
|
||||
-> /media/{9-layers}/{salted_hash}/images/logo.png
|
||||
"""
|
||||
parsed = urlparse(media_url)
|
||||
media_domain = parsed.netloc.lower()
|
||||
media_path = parsed.path.strip('/')
|
||||
|
||||
# Get salted hash for media domain (same across all vaults)
|
||||
h = domain_hash(media_domain, salted=True)
|
||||
layers = '/'.join([h[i:i+2] for i in range(0, 18, 2)])
|
||||
|
||||
return f"{self.media_base_url}/{layers}/{h}/{media_path}"
|
||||
|
||||
def rewrite_media_urls(self, html: str, base_url: str, media_mappings: Dict[str, str]) -> str:
|
||||
"""
|
||||
Rewrite media URLs in HTML to point to neopig media vault.
|
||||
|
||||
media_mappings: {original_url: neopig_vault_path}
|
||||
"""
|
||||
result = html
|
||||
for original, neopig_path in media_mappings.items():
|
||||
# Escape for regex
|
||||
escaped = re.escape(original)
|
||||
result = re.sub(escaped, neopig_path, result)
|
||||
return result
|
||||
|
||||
async def archive_page(
|
||||
self,
|
||||
url: str,
|
||||
html: str,
|
||||
media_mappings: Dict[str, str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Archive a page's HTML.
|
||||
|
||||
Saves both original (.html.og) and rewritten (.html) versions.
|
||||
Returns (is_changed, content_hash).
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
filepath = url_to_filepath(url)
|
||||
og_path = self.path / f"{filepath}.og"
|
||||
html_path = self.path / filepath
|
||||
|
||||
# Create directories
|
||||
await asyncio.to_thread(og_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
# Check if content changed
|
||||
chash = content_hash(html.encode('utf-8'))
|
||||
is_changed = True
|
||||
if await asyncio.to_thread(og_path.exists):
|
||||
async with aiofiles.open(og_path, 'r', encoding='utf-8') as f:
|
||||
existing = await f.read()
|
||||
if content_hash(existing.encode('utf-8')) == chash:
|
||||
is_changed = False
|
||||
|
||||
# Save original HTML
|
||||
async with aiofiles.open(og_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(html)
|
||||
|
||||
# Save rewritten HTML
|
||||
rewritten = html
|
||||
if media_mappings:
|
||||
rewritten = self.rewrite_media_urls(html, url, media_mappings)
|
||||
async with aiofiles.open(html_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(rewritten)
|
||||
|
||||
return is_changed, chash
|
||||
|
||||
async def get_page(self, url: str, original: bool = False) -> Optional[str]:
|
||||
"""Get archived page HTML. Set original=True for .html.og version."""
|
||||
filepath = url_to_filepath(url)
|
||||
if original:
|
||||
filepath = f"{filepath}.og"
|
||||
full_path = self.path / filepath
|
||||
|
||||
if not await asyncio.to_thread(full_path.exists):
|
||||
return None
|
||||
|
||||
async with aiofiles.open(full_path, 'r', encoding='utf-8') as f:
|
||||
return await f.read()
|
||||
|
||||
async def finish_crawl(self, stats: Dict[str, Any]) -> Optional[str]:
|
||||
"""Finish crawl, log it, and commit if changes."""
|
||||
log = await self._load_crawl_log()
|
||||
log['crawls'].append({
|
||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'stats': stats,
|
||||
})
|
||||
await self._save_crawl_log(log)
|
||||
|
||||
pages_changed = stats.get('pages_changed', 0)
|
||||
if pages_changed > 0:
|
||||
return await self._git.commit(f"Crawl: {pages_changed} pages changed")
|
||||
return None
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get vault statistics."""
|
||||
log = await self._load_crawl_log()
|
||||
commits = await self._git.log(5)
|
||||
|
||||
# Count HTML files (run sync rglob in thread pool)
|
||||
def _count_pages():
|
||||
return sum(1 for _ in self.path.rglob('*.html') if not str(_).endswith('.og'))
|
||||
|
||||
page_count = await asyncio.to_thread(_count_pages)
|
||||
|
||||
return {
|
||||
'domain': self.domain,
|
||||
'path': str(self.path),
|
||||
'pages_count': page_count,
|
||||
'crawls_count': len(log.get('crawls', [])),
|
||||
'recent_commits': commits,
|
||||
'last_crawl': log['crawls'][-1] if log.get('crawls') else None,
|
||||
}
|
||||
|
||||
|
||||
class DomainMediaVault:
|
||||
"""
|
||||
Git LFS-tracked media vault for a single domain.
|
||||
|
||||
Stores media files in a tree structure mirroring the original domain.
|
||||
"""
|
||||
|
||||
def __init__(self, vault_base: str, domain: str):
|
||||
self.domain = domain.lower()
|
||||
self.base_path = Path(vault_base)
|
||||
self.path = get_filevault_path(self.base_path, self.domain)
|
||||
self.index_path = self.path / 'index.json'
|
||||
self._git = GitRepo(self.path, use_lfs=True)
|
||||
self._initialized = False
|
||||
|
||||
async def init(self) -> bool:
|
||||
"""Initialize vault. Returns True if newly created."""
|
||||
is_new = await self._git.init()
|
||||
|
||||
if not await asyncio.to_thread(self.index_path.exists):
|
||||
await self._save_index({'media': {}, 'created': datetime.now(timezone.utc).isoformat()})
|
||||
is_new = True
|
||||
|
||||
self._initialized = True
|
||||
return is_new
|
||||
|
||||
async def _load_index(self) -> Dict[str, Any]:
|
||||
if not await asyncio.to_thread(self.index_path.exists):
|
||||
return {'media': {}}
|
||||
async with aiofiles.open(self.index_path, 'r') as f:
|
||||
return json.loads(await f.read())
|
||||
|
||||
async def _save_index(self, index: Dict[str, Any]):
|
||||
async with aiofiles.open(self.index_path, 'w') as f:
|
||||
await f.write(json.dumps(index, indent=2))
|
||||
|
||||
async def archive_media(
|
||||
self,
|
||||
url: str,
|
||||
content: bytes,
|
||||
page_url: str = '',
|
||||
) -> Tuple[bool, str, Path]:
|
||||
"""
|
||||
Archive media content.
|
||||
|
||||
Returns (is_new, content_hash, file_path).
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
# Use URL path as file path
|
||||
parsed = urlparse(url)
|
||||
filepath = parsed.path.strip('/')
|
||||
if not filepath:
|
||||
# Fallback to content hash
|
||||
filepath = f"{content_hash(content)}.bin"
|
||||
|
||||
file_path = self.path / filepath
|
||||
await asyncio.to_thread(file_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
# Check if new or changed
|
||||
chash = content_hash(content)
|
||||
exists = await asyncio.to_thread(file_path.exists)
|
||||
is_new = not exists
|
||||
if exists:
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
existing_hash = content_hash(await f.read())
|
||||
is_new = existing_hash != chash
|
||||
|
||||
# Save media
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
|
||||
# Update index
|
||||
index = await self._load_index()
|
||||
index['media'][filepath] = {
|
||||
'url': url,
|
||||
'hash': chash,
|
||||
'size': len(content),
|
||||
'page_url': page_url,
|
||||
'updated': datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await self._save_index(index)
|
||||
|
||||
return is_new, chash, file_path
|
||||
|
||||
async def get_media(self, url: str) -> Optional[Tuple[bytes, Dict[str, Any]]]:
|
||||
"""Get archived media content and metadata."""
|
||||
parsed = urlparse(url)
|
||||
filepath = parsed.path.strip('/')
|
||||
file_path = self.path / filepath
|
||||
|
||||
if not await asyncio.to_thread(file_path.exists):
|
||||
return None
|
||||
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
content = await f.read()
|
||||
|
||||
index = await self._load_index()
|
||||
meta = index.get('media', {}).get(filepath, {})
|
||||
|
||||
return content, meta
|
||||
|
||||
async def finish_crawl(self, stats: Dict[str, Any]) -> Optional[str]:
|
||||
"""Finish crawl and commit if changes."""
|
||||
media_new = stats.get('media_new', 0)
|
||||
if media_new > 0:
|
||||
return await self._git.commit(f"Crawl: {media_new} media files")
|
||||
return None
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get vault statistics."""
|
||||
index = await self._load_index()
|
||||
commits = await self._git.log(5)
|
||||
|
||||
return {
|
||||
'domain': self.domain,
|
||||
'path': str(self.path),
|
||||
'media_count': len(index.get('media', {})),
|
||||
'recent_commits': commits,
|
||||
}
|
||||
|
||||
|
||||
class DomainLinkpeekVault:
|
||||
"""
|
||||
Git LFS-tracked screenshot vault for a single domain.
|
||||
|
||||
Stores page screenshots in a tree structure mirroring the original domain.
|
||||
One screenshot per page, named after the URL path.
|
||||
"""
|
||||
|
||||
def __init__(self, vault_base: str, domain: str):
|
||||
self.domain = domain.lower()
|
||||
self.base_path = Path(vault_base)
|
||||
self.path = get_filevault_path(self.base_path, self.domain)
|
||||
self.index_path = self.path / 'index.json'
|
||||
self._git = GitRepo(self.path, use_lfs=True)
|
||||
self._initialized = False
|
||||
|
||||
async def init(self) -> bool:
|
||||
"""Initialize vault. Returns True if newly created."""
|
||||
is_new = await self._git.init()
|
||||
|
||||
if not await asyncio.to_thread(self.index_path.exists):
|
||||
await self._save_index({'screenshots': {}, 'created': datetime.now(timezone.utc).isoformat()})
|
||||
is_new = True
|
||||
|
||||
self._initialized = True
|
||||
return is_new
|
||||
|
||||
async def _load_index(self) -> Dict[str, Any]:
|
||||
if not await asyncio.to_thread(self.index_path.exists):
|
||||
return {'screenshots': {}}
|
||||
async with aiofiles.open(self.index_path, 'r') as f:
|
||||
return json.loads(await f.read())
|
||||
|
||||
async def _save_index(self, index: Dict[str, Any]):
|
||||
async with aiofiles.open(self.index_path, 'w') as f:
|
||||
await f.write(json.dumps(index, indent=2))
|
||||
|
||||
def url_to_screenshot_path(self, url: str) -> str:
|
||||
"""
|
||||
Convert URL to screenshot filepath.
|
||||
|
||||
Examples:
|
||||
https://example.com/ -> index.png
|
||||
https://example.com/about -> about/index.png
|
||||
https://example.com/blog/post.html -> blog/post.png
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path.strip('/')
|
||||
|
||||
if not path:
|
||||
return 'index.png'
|
||||
|
||||
# Replace extension with .png or add /index.png for directories
|
||||
if '.' in path.split('/')[-1]:
|
||||
# Has extension, replace with .png
|
||||
base = path.rsplit('.', 1)[0]
|
||||
return f"{base}.png"
|
||||
else:
|
||||
# Directory-style URL
|
||||
return f"{path}/index.png"
|
||||
|
||||
async def archive_screenshot(
|
||||
self,
|
||||
url: str,
|
||||
screenshot_data: bytes,
|
||||
) -> Tuple[bool, str, Path]:
|
||||
"""
|
||||
Archive a page screenshot.
|
||||
|
||||
Returns (is_new, content_hash, file_path).
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
filepath = self.url_to_screenshot_path(url)
|
||||
file_path = self.path / filepath
|
||||
await asyncio.to_thread(file_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
# Check if new or changed
|
||||
chash = content_hash(screenshot_data)
|
||||
exists = await asyncio.to_thread(file_path.exists)
|
||||
is_new = not exists
|
||||
if exists:
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
existing_hash = content_hash(await f.read())
|
||||
is_new = existing_hash != chash
|
||||
|
||||
# Save screenshot
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(screenshot_data)
|
||||
|
||||
# Update index
|
||||
index = await self._load_index()
|
||||
index['screenshots'][filepath] = {
|
||||
'url': url,
|
||||
'hash': chash,
|
||||
'size': len(screenshot_data),
|
||||
'updated': datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await self._save_index(index)
|
||||
|
||||
return is_new, chash, file_path
|
||||
|
||||
async def get_screenshot(self, url: str) -> Optional[Tuple[bytes, Dict[str, Any]]]:
|
||||
"""Get archived screenshot and metadata."""
|
||||
filepath = self.url_to_screenshot_path(url)
|
||||
file_path = self.path / filepath
|
||||
|
||||
if not await asyncio.to_thread(file_path.exists):
|
||||
return None
|
||||
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
content = await f.read()
|
||||
|
||||
index = await self._load_index()
|
||||
meta = index.get('screenshots', {}).get(filepath, {})
|
||||
|
||||
return content, meta
|
||||
|
||||
async def finish_crawl(self, stats: Dict[str, Any]) -> Optional[str]:
|
||||
"""Finish crawl and commit if changes."""
|
||||
screenshots_new = stats.get('screenshots_new', 0)
|
||||
if screenshots_new > 0:
|
||||
return await self._git.commit(f"Crawl: {screenshots_new} screenshots")
|
||||
return None
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get vault statistics."""
|
||||
index = await self._load_index()
|
||||
commits = await self._git.log(5)
|
||||
|
||||
return {
|
||||
'domain': self.domain,
|
||||
'path': str(self.path),
|
||||
'screenshots_count': len(index.get('screenshots', {})),
|
||||
'recent_commits': commits,
|
||||
}
|
||||
|
||||
|
||||
class VaultManager:
|
||||
"""
|
||||
Manages HTML, Media, and Linkpeek (screenshot) vaults for multiple domains.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
html_vault_base: str = 'html_vault',
|
||||
media_vault_base: str = 'media_vault',
|
||||
linkpeek_vault_base: str = 'linkpeek_vault',
|
||||
media_base_url: str = '/media',
|
||||
linkpeek_base_url: str = '/linkpeek',
|
||||
):
|
||||
self.html_base = Path(html_vault_base)
|
||||
self.media_base = Path(media_vault_base)
|
||||
self.linkpeek_base = Path(linkpeek_vault_base)
|
||||
self.media_base_url = media_base_url
|
||||
self.linkpeek_base_url = linkpeek_base_url
|
||||
self._html_vaults: Dict[str, DomainHtmlVault] = {}
|
||||
self._media_vaults: Dict[str, DomainMediaVault] = {}
|
||||
self._linkpeek_vaults: Dict[str, DomainLinkpeekVault] = {}
|
||||
|
||||
def get_html_vault(self, domain: str) -> DomainHtmlVault:
|
||||
"""Get or create HTML vault for domain."""
|
||||
domain = domain.lower()
|
||||
if domain not in self._html_vaults:
|
||||
self._html_vaults[domain] = DomainHtmlVault(
|
||||
str(self.html_base), domain, self.media_base_url
|
||||
)
|
||||
return self._html_vaults[domain]
|
||||
|
||||
def get_media_vault(self, domain: str) -> DomainMediaVault:
|
||||
"""Get or create media vault for domain."""
|
||||
domain = domain.lower()
|
||||
if domain not in self._media_vaults:
|
||||
self._media_vaults[domain] = DomainMediaVault(str(self.media_base), domain)
|
||||
return self._media_vaults[domain]
|
||||
|
||||
def get_linkpeek_vault(self, domain: str) -> DomainLinkpeekVault:
|
||||
"""Get or create linkpeek (screenshot) vault for domain."""
|
||||
domain = domain.lower()
|
||||
if domain not in self._linkpeek_vaults:
|
||||
self._linkpeek_vaults[domain] = DomainLinkpeekVault(str(self.linkpeek_base), domain)
|
||||
return self._linkpeek_vaults[domain]
|
||||
|
||||
def get_vaults_for_url(self, url: str) -> Tuple[DomainHtmlVault, DomainMediaVault, DomainLinkpeekVault]:
|
||||
"""Get HTML, media, and linkpeek vaults for URL's domain."""
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc or parsed.path.split('/')[0]
|
||||
return self.get_html_vault(domain), self.get_media_vault(domain), self.get_linkpeek_vault(domain)
|
||||
|
||||
def get_linkpeek_url(self, url: str) -> str:
|
||||
"""
|
||||
Get linkpeek URL for a page screenshot.
|
||||
|
||||
Example: https://example.com/about
|
||||
-> /linkpeek/{9-layers}/{salted_hash}/about/index.png
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc.lower()
|
||||
|
||||
# Get screenshot path
|
||||
vault = self.get_linkpeek_vault(domain)
|
||||
screenshot_path = vault.url_to_screenshot_path(url)
|
||||
|
||||
# Get salted hash for domain (same across all vaults)
|
||||
h = domain_hash(domain, salted=True)
|
||||
layers = '/'.join([h[i:i+2] for i in range(0, 18, 2)])
|
||||
|
||||
return f"{self.linkpeek_base_url}/{layers}/{h}/{screenshot_path}"
|
||||
|
||||
async def list_domains(self, vault_type: str = 'html') -> List[str]:
|
||||
"""List all domains with vaults."""
|
||||
if vault_type == 'html':
|
||||
base = self.html_base
|
||||
elif vault_type == 'media':
|
||||
base = self.media_base
|
||||
else: # linkpeek
|
||||
base = self.linkpeek_base
|
||||
|
||||
def _walk_domains():
|
||||
domains = []
|
||||
if not base.exists():
|
||||
return domains
|
||||
|
||||
for root, dirs, files in os.walk(base):
|
||||
if '.git' in dirs:
|
||||
rel_path = Path(root).relative_to(base)
|
||||
parts = list(rel_path.parts)
|
||||
if len(parts) >= 10: # 9 hash layers + domain
|
||||
domains.append(parts[-1])
|
||||
|
||||
return sorted(set(domains))
|
||||
|
||||
return await asyncio.to_thread(_walk_domains)
|
||||
|
||||
async def get_all_stats(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get stats for all domains."""
|
||||
html_domains = await self.list_domains('html')
|
||||
media_domains = await self.list_domains('media')
|
||||
linkpeek_domains = await self.list_domains('linkpeek')
|
||||
|
||||
html_stats = []
|
||||
for domain in html_domains:
|
||||
vault = self.get_html_vault(domain)
|
||||
await vault.init()
|
||||
html_stats.append(await vault.get_stats())
|
||||
|
||||
media_stats = []
|
||||
for domain in media_domains:
|
||||
vault = self.get_media_vault(domain)
|
||||
await vault.init()
|
||||
media_stats.append(await vault.get_stats())
|
||||
|
||||
linkpeek_stats = []
|
||||
for domain in linkpeek_domains:
|
||||
vault = self.get_linkpeek_vault(domain)
|
||||
await vault.init()
|
||||
linkpeek_stats.append(await vault.get_stats())
|
||||
|
||||
return {
|
||||
'html_vaults': html_stats,
|
||||
'media_vaults': media_stats,
|
||||
'linkpeek_vaults': linkpeek_stats,
|
||||
}
|
||||
545
filevault.py
Normal file
545
filevault.py
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
"""
|
||||
FileVault 1.1.0 - Python 2/3 compatible hash-based file storage system
|
||||
|
||||
A Vault manages a hash directory tree of files on a filesystem.
|
||||
|
||||
Features:
|
||||
- Create hash directory trees of custom depth
|
||||
- Spread out files to keep CLI snappy when traversing the tree
|
||||
- Scale to hundreds of thousands of files
|
||||
- Obfuscate directory paths and filenames
|
||||
- Compatible with Python 2.7+ and Python 3.x
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, print_function
|
||||
|
||||
import sys
|
||||
import json
|
||||
import fcntl
|
||||
import contextlib
|
||||
import tempfile
|
||||
from os import path, makedirs, rename
|
||||
from uuid import uuid4
|
||||
from hashlib import sha256
|
||||
from itertools import permutations
|
||||
|
||||
# Python 2/3 compatibility
|
||||
if sys.version_info[0] == 3:
|
||||
string_types = str
|
||||
|
||||
def ensure_bytes(s):
|
||||
if isinstance(s, str):
|
||||
return s.encode("utf-8")
|
||||
return s
|
||||
|
||||
else:
|
||||
string_types = basestring
|
||||
|
||||
def ensure_bytes(s):
|
||||
if isinstance(s, unicode):
|
||||
return s.encode("utf-8")
|
||||
return s
|
||||
|
||||
|
||||
__version__ = "1.1.0"
|
||||
__author__ = "Russell Ballestrini"
|
||||
__email__ = "russell@ballestrini.net"
|
||||
__license__ = "Public Domain"
|
||||
|
||||
HEX = "0123456789abcdef"
|
||||
|
||||
|
||||
class Vault(object):
|
||||
"""
|
||||
Hash-based file storage system with deterministic directory structure.
|
||||
|
||||
Creates a directory tree using hex characters for balanced file distribution.
|
||||
Includes thread-safe file locking and atomic write operations.
|
||||
|
||||
Optional in-memory existence cache to avoid repeated filesystem stat() calls.
|
||||
|
||||
Directory modes:
|
||||
- use_pairs=False (default): Single hex chars per level (16 dirs/level)
|
||||
Example: depth=3 -> a/b/c/hash
|
||||
- use_pairs=True: Hex pairs per level (256 dirs/level, git-style)
|
||||
Example: depth=3 -> ab/cd/ef/hash
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, vaultpath="vault", depth=3, salt="changeme", enable_memory_cache=False,
|
||||
use_pairs=False
|
||||
):
|
||||
"""
|
||||
Initialize a new Vault instance.
|
||||
|
||||
Args:
|
||||
vaultpath (str): Base path for vault storage (default: 'vault')
|
||||
depth (int): Directory tree depth (default: 3)
|
||||
salt (str): Salt for hash generation (default: 'changeme')
|
||||
enable_memory_cache (bool): Enable in-memory file existence cache (default: False)
|
||||
use_pairs (bool): Use hex pairs for directories instead of single chars (default: False)
|
||||
Pairs give 256 subdirs/level vs 16, better for large vaults
|
||||
"""
|
||||
self.vaultpath = vaultpath
|
||||
self.depth = depth
|
||||
self.salt = ensure_bytes(salt)
|
||||
self.enable_memory_cache = enable_memory_cache
|
||||
self.use_pairs = use_pairs
|
||||
|
||||
# In-memory file existence cache (optional performance optimization)
|
||||
self._existence_cache = {} if enable_memory_cache else None
|
||||
|
||||
self.init_vault()
|
||||
|
||||
def init_vault(self):
|
||||
"""Build the vault base directory if it doesn't exist."""
|
||||
if not path.exists(self.vaultpath):
|
||||
try:
|
||||
makedirs(self.vaultpath)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _generate_filename(self, h, ext="", absolute=False):
|
||||
"""
|
||||
Accept a hash, return a valid file path.
|
||||
|
||||
Args:
|
||||
h (str): Hash string
|
||||
ext (str): File extension (optional)
|
||||
absolute (bool): Return absolute path if True
|
||||
|
||||
Returns:
|
||||
str: Generated file path
|
||||
"""
|
||||
if self.use_pairs:
|
||||
# Pairs mode: take 2 chars at a time (256 dirs/level, git-style)
|
||||
dirs = [h[i:i+2] for i in range(0, self.depth * 2, 2)]
|
||||
else:
|
||||
# Original mode: single chars (16 dirs/level)
|
||||
dirs = [h[i] for i in range(self.depth)]
|
||||
if ext and not ext.startswith("."):
|
||||
ext = "." + ext
|
||||
if absolute:
|
||||
return path.join(self.vaultpath, *dirs) + "/" + h + ext
|
||||
return path.join(*dirs) + "/" + h + ext
|
||||
|
||||
def create_filename(self, seed, ext="", absolute=False):
|
||||
"""
|
||||
Create a valid vault filename seeded with input.
|
||||
|
||||
Args:
|
||||
seed (str): Seed string for deterministic hash
|
||||
ext (str): Optional file extension
|
||||
absolute (bool): Return absolute path if True
|
||||
|
||||
Returns:
|
||||
str: Generated filename path
|
||||
"""
|
||||
seed_bytes = ensure_bytes(seed)
|
||||
h = sha256(seed_bytes + self.salt).hexdigest()
|
||||
return self._generate_filename(h, ext, absolute)
|
||||
|
||||
def create_random_filename(self, ext="", absolute=False):
|
||||
"""
|
||||
Create a valid vault filename seeded with random input.
|
||||
|
||||
Args:
|
||||
ext (str): Optional file extension
|
||||
absolute (bool): Return absolute path if True
|
||||
|
||||
Returns:
|
||||
str: Generated random filename path
|
||||
"""
|
||||
random_seed = ensure_bytes(str(uuid4()))
|
||||
h = sha256(random_seed + self.salt).hexdigest()
|
||||
return self._generate_filename(h, ext, absolute)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def file_lock(self, file_path, mode="r", timeout=30):
|
||||
"""
|
||||
Context manager for file locking with automatic release.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to file to lock
|
||||
mode (str): File open mode ('r', 'w', 'a', etc.)
|
||||
timeout (int): Lock timeout in seconds
|
||||
|
||||
Yields:
|
||||
file: Locked file object
|
||||
|
||||
Raises:
|
||||
IOError: If lock cannot be acquired within timeout
|
||||
"""
|
||||
# Ensure directory exists for write operations
|
||||
if "w" in mode or "a" in mode:
|
||||
dir_path = path.dirname(file_path)
|
||||
if dir_path and not path.exists(dir_path):
|
||||
try:
|
||||
makedirs(dir_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Open file and acquire lock
|
||||
with open(file_path, mode) as f:
|
||||
try:
|
||||
# Use exclusive lock for write operations, shared for read
|
||||
lock_type = (
|
||||
fcntl.LOCK_EX if ("w" in mode or "a" in mode) else fcntl.LOCK_SH
|
||||
)
|
||||
fcntl.flock(f.fileno(), lock_type | fcntl.LOCK_NB)
|
||||
yield f
|
||||
except IOError as e:
|
||||
if e.errno == 11 or e.errno == 35: # EAGAIN or EWOULDBLOCK
|
||||
raise IOError(
|
||||
"Could not acquire file lock for: {}".format(file_path)
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
except:
|
||||
pass # Ignore unlock errors
|
||||
|
||||
def atomic_write_json(self, file_path, data, indent=2, aliases=None):
|
||||
"""
|
||||
Atomically write JSON data to a file with proper locking.
|
||||
|
||||
Uses a temporary file + rename for atomic operations and fcntl for locking.
|
||||
|
||||
Args:
|
||||
file_path (str): Target file path
|
||||
data (dict/list): Data to write as JSON
|
||||
indent (int): JSON indentation level
|
||||
aliases (list): Optional list of alias keys that will create symlinks to this file
|
||||
|
||||
Raises:
|
||||
IOError: If file operations fail
|
||||
ValueError: If data cannot be serialized to JSON
|
||||
"""
|
||||
# Ensure target directory exists
|
||||
dir_path = path.dirname(file_path)
|
||||
if dir_path and not path.exists(dir_path):
|
||||
try:
|
||||
makedirs(dir_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Create temporary file in same directory for atomic rename
|
||||
temp_fd, temp_path = tempfile.mkstemp(
|
||||
dir=dir_path or ".",
|
||||
prefix=".tmp_" + path.basename(file_path) + "_",
|
||||
suffix=".json",
|
||||
)
|
||||
|
||||
try:
|
||||
with open(temp_path, "w") as temp_file:
|
||||
# Lock the temporary file
|
||||
fcntl.flock(temp_file.fileno(), fcntl.LOCK_EX)
|
||||
|
||||
# Write JSON data
|
||||
json.dump(data, temp_file, indent=indent)
|
||||
temp_file.flush()
|
||||
|
||||
# Force write to disk
|
||||
import os
|
||||
|
||||
os.fsync(temp_file.fileno())
|
||||
|
||||
# Atomic rename - this is the commit point
|
||||
rename(temp_path, file_path)
|
||||
|
||||
# Mark file as existing in cache after successful write
|
||||
self.mark_file_exists(file_path, True)
|
||||
|
||||
# Create aliases (symlinks) if requested
|
||||
if aliases:
|
||||
for alias_key in aliases:
|
||||
self._create_alias(file_path, alias_key)
|
||||
|
||||
except Exception as e:
|
||||
# Clean up temporary file on any error
|
||||
try:
|
||||
import os
|
||||
|
||||
os.unlink(temp_path)
|
||||
except:
|
||||
pass
|
||||
raise IOError("Failed to write JSON file {}: {}".format(file_path, str(e)))
|
||||
finally:
|
||||
# Close temp file descriptor
|
||||
try:
|
||||
import os
|
||||
|
||||
os.close(temp_fd)
|
||||
except:
|
||||
pass
|
||||
|
||||
def _create_alias(self, target_path, alias_key):
|
||||
"""Create a symlink from alias_key to target_path"""
|
||||
alias_path = self.create_filename(alias_key, ".json", absolute=True)
|
||||
|
||||
# Ensure alias directory exists
|
||||
alias_dir = path.dirname(alias_path)
|
||||
if alias_dir and not path.exists(alias_dir):
|
||||
try:
|
||||
makedirs(alias_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Create or update symlink
|
||||
rel_path = None # Initialize to avoid NameError in exception handler
|
||||
try:
|
||||
# Check if symlink already exists
|
||||
if path.exists(alias_path) or path.islink(alias_path):
|
||||
# Check if it points to the correct target
|
||||
try:
|
||||
if path.realpath(alias_path) == path.realpath(target_path):
|
||||
# Already points to correct file, nothing to do
|
||||
return
|
||||
except:
|
||||
pass
|
||||
# Remove incorrect symlink
|
||||
import os
|
||||
|
||||
os.unlink(alias_path)
|
||||
|
||||
# Create relative symlink so it works across directory structures
|
||||
import os
|
||||
|
||||
# Calculate relative path from alias to target
|
||||
alias_dir = path.dirname(alias_path)
|
||||
rel_path = path.relpath(target_path, alias_dir)
|
||||
os.symlink(rel_path, alias_path)
|
||||
except Exception as e:
|
||||
# Log but don't fail the write operation
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
print(
|
||||
"Warning: Failed to create alias {}: {}".format(alias_key, e),
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("Alias path: {}".format(alias_path), file=sys.stderr)
|
||||
print("Target path: {}".format(target_path), file=sys.stderr)
|
||||
if rel_path is not None:
|
||||
print("Relative path: {}".format(rel_path), file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
"Relative path: (not calculated due to earlier error)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
|
||||
def safe_read_json(self, file_path, default=None):
|
||||
"""
|
||||
Safely read JSON data from a file with proper locking.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to JSON file
|
||||
default (any): Default value if file doesn't exist or is invalid
|
||||
|
||||
Returns:
|
||||
dict/list: Parsed JSON data or default value
|
||||
|
||||
Raises:
|
||||
IOError: If file cannot be read
|
||||
ValueError: If JSON is malformed and no default provided
|
||||
"""
|
||||
if not path.exists(file_path):
|
||||
if default is not None:
|
||||
return default
|
||||
raise IOError("File not found: {}".format(file_path))
|
||||
|
||||
try:
|
||||
with self.file_lock(file_path, "r") as f:
|
||||
return json.load(f)
|
||||
except (IOError, ValueError) as e:
|
||||
if default is not None:
|
||||
return default
|
||||
raise
|
||||
|
||||
def file_exists(self, file_path):
|
||||
"""
|
||||
Check if file exists, using memory cache if enabled.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to check
|
||||
|
||||
Returns:
|
||||
bool: True if file exists, False otherwise
|
||||
"""
|
||||
if self.enable_memory_cache and self._existence_cache is not None:
|
||||
# Check memory cache first
|
||||
if file_path in self._existence_cache:
|
||||
return self._existence_cache[file_path]
|
||||
|
||||
# Cache miss - check filesystem and cache result
|
||||
exists = path.exists(file_path)
|
||||
self._existence_cache[file_path] = exists
|
||||
return exists
|
||||
else:
|
||||
# No cache - direct filesystem check
|
||||
return path.exists(file_path)
|
||||
|
||||
def mark_file_exists(self, file_path, exists=True):
|
||||
"""
|
||||
Mark a file as existing or not existing in the cache.
|
||||
|
||||
This should be called after file operations to keep cache consistent.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to mark
|
||||
exists (bool): Whether file exists (default: True)
|
||||
"""
|
||||
if self.enable_memory_cache and self._existence_cache is not None:
|
||||
self._existence_cache[file_path] = exists
|
||||
|
||||
def clear_existence_cache(self):
|
||||
"""
|
||||
Clear the entire existence cache.
|
||||
|
||||
Useful for testing or when filesystem state may have changed externally.
|
||||
"""
|
||||
if self.enable_memory_cache and self._existence_cache is not None:
|
||||
self._existence_cache.clear()
|
||||
|
||||
def get_cache_stats(self):
|
||||
"""
|
||||
Get statistics about the existence cache.
|
||||
|
||||
Returns:
|
||||
dict: Cache statistics or None if cache disabled
|
||||
"""
|
||||
if self.enable_memory_cache and self._existence_cache is not None:
|
||||
return {
|
||||
"enabled": True,
|
||||
"entries": len(self._existence_cache),
|
||||
"hits": sum(1 for exists in self._existence_cache.values() if exists),
|
||||
"misses": sum(
|
||||
1 for exists in self._existence_cache.values() if not exists
|
||||
),
|
||||
}
|
||||
return {"enabled": False}
|
||||
|
||||
def remove_file(self, file_path):
|
||||
"""
|
||||
Remove a file and update the memory cache.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to file to remove
|
||||
|
||||
Returns:
|
||||
bool: True if file was removed, False if it didn't exist
|
||||
|
||||
Raises:
|
||||
OSError: If file removal fails
|
||||
"""
|
||||
if not path.exists(file_path):
|
||||
# Mark as not existing in cache even if file doesn't exist
|
||||
self.mark_file_exists(file_path, False)
|
||||
return False
|
||||
|
||||
try:
|
||||
import os
|
||||
|
||||
os.remove(file_path)
|
||||
# Mark as not existing in cache after successful removal
|
||||
self.mark_file_exists(file_path, False)
|
||||
return True
|
||||
except OSError as e:
|
||||
# Re-raise the error but don't update cache if removal failed
|
||||
raise OSError("Failed to remove file {}: {}".format(file_path, str(e)))
|
||||
|
||||
def remove_file_if_exists(self, file_path):
|
||||
"""
|
||||
Remove a file if it exists, ignoring errors if file doesn't exist.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to file to remove
|
||||
|
||||
Returns:
|
||||
bool: True if file was removed, False if it didn't exist
|
||||
"""
|
||||
try:
|
||||
return self.remove_file(file_path)
|
||||
except OSError:
|
||||
# File didn't exist or couldn't be removed, mark as not existing
|
||||
self.mark_file_exists(file_path, False)
|
||||
return False
|
||||
|
||||
def purge_cache_for_path_pattern(self, path_pattern):
|
||||
"""
|
||||
Remove all cache entries matching a path pattern.
|
||||
|
||||
Useful for GDPR compliance when removing user data.
|
||||
|
||||
Args:
|
||||
path_pattern (str): Pattern to match (simple string contains check)
|
||||
"""
|
||||
if self.enable_memory_cache and self._existence_cache is not None:
|
||||
# Find all cache keys that contain the pattern
|
||||
keys_to_remove = [
|
||||
key for key in self._existence_cache.keys() if path_pattern in key
|
||||
]
|
||||
|
||||
# Remove matching entries
|
||||
for key in keys_to_remove:
|
||||
del self._existence_cache[key]
|
||||
|
||||
return len(keys_to_remove)
|
||||
return 0
|
||||
|
||||
def write_text_file(self, file_path, content, mode="w"):
|
||||
"""
|
||||
Write text content to a file with proper locking.
|
||||
|
||||
Args:
|
||||
file_path (str): Target file path
|
||||
content (str): Text content to write
|
||||
mode (str): File open mode ('w', 'a', etc.)
|
||||
|
||||
Raises:
|
||||
IOError: If file operations fail
|
||||
"""
|
||||
with self.file_lock(file_path, mode) as f:
|
||||
f.write(content)
|
||||
f.flush()
|
||||
import os
|
||||
|
||||
os.fsync(f.fileno())
|
||||
|
||||
# Mark file as existing in cache after successful write
|
||||
self.mark_file_exists(file_path, True)
|
||||
|
||||
def read_text_file(self, file_path, default=None):
|
||||
"""
|
||||
Read text content from a file with proper locking.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to text file
|
||||
default (str): Default value if file doesn't exist
|
||||
|
||||
Returns:
|
||||
str: File content or default value
|
||||
|
||||
Raises:
|
||||
IOError: If file cannot be read and no default provided
|
||||
"""
|
||||
if not path.exists(file_path):
|
||||
if default is not None:
|
||||
return default
|
||||
raise IOError("File not found: {}".format(file_path))
|
||||
|
||||
try:
|
||||
with self.file_lock(file_path, "r") as f:
|
||||
return f.read()
|
||||
except IOError:
|
||||
if default is not None:
|
||||
return default
|
||||
raise
|
||||
|
||||
|
||||
# For backwards compatibility
|
||||
def create_vault(*args, **kwargs):
|
||||
"""Factory function for creating Vault instances (backwards compatibility)."""
|
||||
return Vault(*args, **kwargs)
|
||||
264
neopig.py
264
neopig.py
|
|
@ -27,6 +27,8 @@ from datetime import datetime, timezone
|
|||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Set
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from async_web_fetcher import (
|
||||
AsyncWebFetcher,
|
||||
CrawlMode,
|
||||
|
|
@ -37,6 +39,8 @@ from async_web_fetcher import (
|
|||
)
|
||||
from storage import ImageVault
|
||||
from database import Database
|
||||
from screenshot import ScreenshotCapture, ScreenshotConfig
|
||||
from domain_vault import VaultManager, DomainHtmlVault, DomainMediaVault, DomainLinkpeekVault, extract_media_urls
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
|
@ -54,23 +58,112 @@ class NeoPig:
|
|||
self,
|
||||
db_path: str = "neopig.db",
|
||||
vault_path: str = "vault",
|
||||
user_agent: str = "neopig/1.0 (ethical image crawler)"
|
||||
user_agent: str = "neopig/1.0 (ethical image crawler)",
|
||||
screenshot_config: ScreenshotConfig = None,
|
||||
):
|
||||
self.db = Database(db_path)
|
||||
self.vault = ImageVault(vault_path)
|
||||
# Triple filevault system: html_vault/, media_vault/, and linkpeek_vault/
|
||||
self.domain_vaults = VaultManager(
|
||||
html_vault_base=f"{vault_path}/html_vault",
|
||||
media_vault_base=f"{vault_path}/media_vault",
|
||||
linkpeek_vault_base=f"{vault_path}/linkpeek_vault",
|
||||
media_base_url='/media',
|
||||
linkpeek_base_url='/linkpeek',
|
||||
)
|
||||
self.fetcher = AsyncWebFetcher(user_agent=user_agent)
|
||||
self.screenshot = ScreenshotCapture(screenshot_config or ScreenshotConfig())
|
||||
self.screenshot_config = screenshot_config or ScreenshotConfig()
|
||||
self.vault_path = vault_path
|
||||
|
||||
# Track stats
|
||||
self.stats = {
|
||||
'pages_crawled': 0,
|
||||
'pages_changed': 0, # Pages with content changes (for git commit)
|
||||
'media_found': 0,
|
||||
'media_downloaded': 0,
|
||||
'media_new': 0, # New media (for git commit)
|
||||
'duplicates_skipped': 0,
|
||||
'screenshots_taken': 0,
|
||||
'errors': 0,
|
||||
}
|
||||
|
||||
# Track seen media URLs to avoid re-processing
|
||||
self.seen_media: Set[str] = set()
|
||||
# Track screenshotted pages to avoid duplicates
|
||||
self.seen_screenshots: Set[str] = set()
|
||||
# Track per-domain stats for vault commits
|
||||
self._domain_stats: Dict[str, Dict[str, int]] = {} # domain -> {pages_changed, media_new, screenshots_new}
|
||||
|
||||
def _get_domain(self, url: str) -> str:
|
||||
"""Extract domain from URL."""
|
||||
parsed = urlparse(url)
|
||||
return parsed.netloc.lower()
|
||||
|
||||
def _track_domain_stat(self, domain: str, stat: str, increment: int = 1):
|
||||
"""Track per-domain stats for vault commits."""
|
||||
if domain not in self._domain_stats:
|
||||
self._domain_stats[domain] = {'pages_changed': 0, 'media_new': 0, 'screenshots_new': 0}
|
||||
self._domain_stats[domain][stat] += increment
|
||||
|
||||
async def _archive_page_to_vault(
|
||||
self,
|
||||
url: str,
|
||||
html: str,
|
||||
media_mappings: Dict[str, str] = None,
|
||||
):
|
||||
"""Archive a page to the HTML vault."""
|
||||
domain = self._get_domain(url)
|
||||
html_vault = self.domain_vaults.get_html_vault(domain)
|
||||
is_changed, _ = await html_vault.archive_page(url, html, media_mappings)
|
||||
if is_changed:
|
||||
self._track_domain_stat(domain, 'pages_changed')
|
||||
self.stats['pages_changed'] += 1
|
||||
|
||||
async def _archive_media_to_vault(
|
||||
self,
|
||||
url: str,
|
||||
content: bytes,
|
||||
page_url: str = '',
|
||||
):
|
||||
"""Archive media to the media vault."""
|
||||
domain = self._get_domain(url)
|
||||
media_vault = self.domain_vaults.get_media_vault(domain)
|
||||
is_new, _, _ = await media_vault.archive_media(url, content, page_url)
|
||||
if is_new:
|
||||
self._track_domain_stat(domain, 'media_new')
|
||||
self.stats['media_new'] += 1
|
||||
|
||||
async def _archive_screenshot_to_vault(
|
||||
self,
|
||||
url: str,
|
||||
screenshot_data: bytes,
|
||||
):
|
||||
"""Archive screenshot to the linkpeek vault."""
|
||||
domain = self._get_domain(url)
|
||||
linkpeek_vault = self.domain_vaults.get_linkpeek_vault(domain)
|
||||
is_new, _, _ = await linkpeek_vault.archive_screenshot(url, screenshot_data)
|
||||
if is_new:
|
||||
self._track_domain_stat(domain, 'screenshots_new')
|
||||
|
||||
async def _finish_domain_vaults(self, keywords: List[str] = None):
|
||||
"""Commit changes to all domain vaults that have diffs."""
|
||||
for domain, stats in self._domain_stats.items():
|
||||
if stats['pages_changed'] > 0 or stats['media_new'] > 0 or stats['screenshots_new'] > 0:
|
||||
html_vault = self.domain_vaults.get_html_vault(domain)
|
||||
media_vault = self.domain_vaults.get_media_vault(domain)
|
||||
linkpeek_vault = self.domain_vaults.get_linkpeek_vault(domain)
|
||||
|
||||
html_commit = await html_vault.finish_crawl(stats)
|
||||
media_commit = await media_vault.finish_crawl(stats)
|
||||
linkpeek_commit = await linkpeek_vault.finish_crawl(stats)
|
||||
|
||||
if html_commit:
|
||||
logger.info(f"Committed HTML vault for {domain}: {html_commit[:8]}")
|
||||
if media_commit:
|
||||
logger.info(f"Committed media vault for {domain}: {media_commit[:8]}")
|
||||
if linkpeek_commit:
|
||||
logger.info(f"Committed linkpeek vault for {domain}: {linkpeek_commit[:8]}")
|
||||
|
||||
async def init(self):
|
||||
"""Initialize database and vault."""
|
||||
|
|
@ -115,6 +208,32 @@ class NeoPig:
|
|||
logger.info(f"Keywords: {keywords}")
|
||||
logger.info(f"Depth: {'unlimited' if depth == -1 else depth}")
|
||||
|
||||
# Track timing for stats
|
||||
start_time = datetime.now(timezone.utc)
|
||||
last_stats_time = start_time
|
||||
stats_running = True
|
||||
|
||||
# Background task to emit stats every 15 seconds
|
||||
async def stats_reporter():
|
||||
nonlocal last_stats_time
|
||||
while stats_running:
|
||||
await asyncio.sleep(15)
|
||||
if not stats_running:
|
||||
break
|
||||
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
rate = self.stats['media_downloaded'] / elapsed * 60 if elapsed > 0 else 0
|
||||
logger.info(f"=== CRAWL STATS ({elapsed:.0f}s) ===")
|
||||
logger.info(f" Pages: {self.stats['pages_crawled']} | "
|
||||
f"Found: {self.stats['media_found']} | "
|
||||
f"Downloaded: {self.stats['media_downloaded']} | "
|
||||
f"Dupes: {self.stats['duplicates_skipped']} | "
|
||||
f"Screenshots: {self.stats['screenshots_taken']} | "
|
||||
f"Errors: {self.stats['errors']}")
|
||||
logger.info(f" Rate: {rate:.1f}/min | "
|
||||
f"Vault size: {len(self.seen_media)}")
|
||||
|
||||
stats_task = asyncio.create_task(stats_reporter())
|
||||
|
||||
# Media callback - called for each discovered media item
|
||||
async def on_media_discovered(item: Dict[str, Any]):
|
||||
url = item.get('url')
|
||||
|
|
@ -126,6 +245,8 @@ class NeoPig:
|
|||
|
||||
if download_media:
|
||||
await self._process_media_item(item, job_id, keywords)
|
||||
# Note: Screenshots are now captured per-page in on_page_fetched,
|
||||
# not per-media-item, to honor crawl delay as a unit
|
||||
|
||||
# Progress callback
|
||||
async def on_progress(msg: str):
|
||||
|
|
@ -135,6 +256,17 @@ class NeoPig:
|
|||
f"{self.stats['media_found']} media found, "
|
||||
f"{self.stats['media_downloaded']} downloaded")
|
||||
|
||||
# Page callback - archive raw HTML to vault and capture screenshot
|
||||
# Screenshot happens here (same crawl delay window as page fetch)
|
||||
async def on_page_fetched(url: str, html: str):
|
||||
# For now, archive without media URL rewriting (we'd need to download media first)
|
||||
# TODO: Build media_mappings after media is downloaded
|
||||
await self._archive_page_to_vault(url, html, media_mappings=None)
|
||||
|
||||
# Capture screenshot for every page (honors crawl delay as a UNIT with page fetch)
|
||||
if self.screenshot_config.enabled:
|
||||
await self._capture_page_screenshot(url, job_id, page_title='')
|
||||
|
||||
# Run the crawl
|
||||
pages = await self.fetcher.fetch_with_depth(
|
||||
start_url=target_uri,
|
||||
|
|
@ -144,19 +276,37 @@ class NeoPig:
|
|||
mode=mode,
|
||||
media_callback=on_media_discovered,
|
||||
progress_callback=on_progress,
|
||||
page_callback=on_page_fetched,
|
||||
)
|
||||
|
||||
self.stats['pages_crawled'] = len(pages)
|
||||
|
||||
# Stop the stats reporter
|
||||
stats_running = False
|
||||
stats_task.cancel()
|
||||
try:
|
||||
await stats_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Calculate final stats
|
||||
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
rate = self.stats['media_downloaded'] / elapsed * 60 if elapsed > 0 else 0
|
||||
|
||||
# Update job status
|
||||
await self.db.complete_crawl_job(job_id, self.stats)
|
||||
|
||||
logger.info(f"Crawl complete!")
|
||||
logger.info(f" Pages crawled: {self.stats['pages_crawled']}")
|
||||
logger.info(f" Media found: {self.stats['media_found']}")
|
||||
logger.info(f" Media downloaded: {self.stats['media_downloaded']}")
|
||||
logger.info(f" Duplicates skipped: {self.stats['duplicates_skipped']}")
|
||||
logger.info(f" Errors: {self.stats['errors']}")
|
||||
# Commit domain vaults if there were changes
|
||||
await self._finish_domain_vaults(keywords)
|
||||
|
||||
logger.info(f"=== CRAWL COMPLETE ({elapsed:.0f}s) ===")
|
||||
logger.info(f" Pages: {self.stats['pages_crawled']} | "
|
||||
f"Found: {self.stats['media_found']} | "
|
||||
f"Downloaded: {self.stats['media_downloaded']} | "
|
||||
f"Dupes: {self.stats['duplicates_skipped']} | "
|
||||
f"Screenshots: {self.stats['screenshots_taken']} | "
|
||||
f"Errors: {self.stats['errors']}")
|
||||
logger.info(f" Rate: {rate:.1f}/min | Total time: {elapsed:.1f}s")
|
||||
|
||||
return self.stats
|
||||
|
||||
|
|
@ -223,6 +373,9 @@ class NeoPig:
|
|||
ext = self._get_extension(media_uri, result.get('mime_type', ''))
|
||||
await self.vault.store(md5_hash, result['data'], ext)
|
||||
|
||||
# Archive to domain media vault (git-tracked)
|
||||
await self._archive_media_to_vault(media_uri, result['data'], page_uri)
|
||||
|
||||
# Record in database with full context
|
||||
await self.db.create_media_record(
|
||||
md5_hash=md5_hash,
|
||||
|
|
@ -246,6 +399,58 @@ class NeoPig:
|
|||
logger.warning(f"Failed to process {media_uri}: {e}")
|
||||
self.stats['errors'] += 1
|
||||
|
||||
async def _capture_page_screenshot(
|
||||
self,
|
||||
page_uri: str,
|
||||
job_id: int,
|
||||
page_title: str = '',
|
||||
):
|
||||
"""Capture and store a screenshot of a page."""
|
||||
if not self.screenshot_config.enabled:
|
||||
return
|
||||
|
||||
if page_uri in self.seen_screenshots:
|
||||
return
|
||||
|
||||
self.seen_screenshots.add(page_uri)
|
||||
|
||||
try:
|
||||
result = await self.screenshot.capture(page_uri)
|
||||
if not result:
|
||||
return
|
||||
|
||||
md5_hash = result['md5_hash']
|
||||
screenshot_data = result['data']
|
||||
|
||||
# Store in MD5 vault (for deduplication)
|
||||
if not await self.vault.exists(md5_hash):
|
||||
await self.vault.store(md5_hash, screenshot_data, 'png')
|
||||
|
||||
# Archive to linkpeek vault (git-tracked by URL path)
|
||||
await self._archive_screenshot_to_vault(page_uri, screenshot_data)
|
||||
|
||||
# Record in database as screenshot type
|
||||
await self.db.create_media_record(
|
||||
md5_hash=md5_hash,
|
||||
media_uri=f"screenshot:{page_uri}",
|
||||
page_uri=page_uri,
|
||||
crawl_job_id=job_id,
|
||||
media_type='screenshot',
|
||||
mime_type='image/png',
|
||||
file_size=result.get('size', 0),
|
||||
page_title=page_title,
|
||||
page_description='',
|
||||
page_keywords='',
|
||||
alt_text=f"Screenshot of {page_uri}",
|
||||
link_text='',
|
||||
)
|
||||
|
||||
self.stats['screenshots_taken'] += 1
|
||||
logger.debug(f"Screenshot captured: {page_uri} -> {md5_hash}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Screenshot failed for {page_uri}: {e}")
|
||||
|
||||
def _get_extension(self, url: str, mime_type: str) -> str:
|
||||
"""Determine file extension from URL or MIME type."""
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -338,6 +543,34 @@ async def main():
|
|||
help="Verbose output"
|
||||
)
|
||||
|
||||
# Screenshot options (all off by default)
|
||||
parser.add_argument(
|
||||
"--screenshot",
|
||||
action="store_true",
|
||||
help="Enable page screenshots (requires uri2png)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--screenshot-width",
|
||||
type=int,
|
||||
default=1280,
|
||||
help="Screenshot viewport width in pixels (default: 1280)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--screenshot-height",
|
||||
type=int,
|
||||
default=1024,
|
||||
help="Screenshot viewport height in pixels (default: 1024)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--screenshot-delay",
|
||||
type=int,
|
||||
default=1000,
|
||||
help="Delay after page load in ms (default: 1000)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
|
|
@ -353,8 +586,23 @@ async def main():
|
|||
}
|
||||
mode = mode_map[args.mode]
|
||||
|
||||
# Create screenshot config from args
|
||||
screenshot_config = ScreenshotConfig(
|
||||
enabled=args.screenshot,
|
||||
width=args.screenshot_width,
|
||||
height=args.screenshot_height,
|
||||
delay=args.screenshot_delay,
|
||||
)
|
||||
|
||||
if args.screenshot:
|
||||
logger.info(f"Screenshots enabled: {screenshot_config.width}x{screenshot_config.height}, delay={screenshot_config.delay}ms")
|
||||
|
||||
# Initialize and run
|
||||
pig = NeoPig(db_path=args.db, vault_path=args.vault)
|
||||
pig = NeoPig(
|
||||
db_path=args.db,
|
||||
vault_path=args.vault,
|
||||
screenshot_config=screenshot_config,
|
||||
)
|
||||
await pig.init()
|
||||
|
||||
# Load previously crawled media URIs to enable resume
|
||||
|
|
|
|||
182
screenshot.py
Normal file
182
screenshot.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Screenshot capture module for neopig.
|
||||
|
||||
Wraps uri2png for async-compatible page screenshots.
|
||||
Screenshots are stored in vault with MD5 hash like other media.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenshotConfig:
|
||||
"""Screenshot capture configuration."""
|
||||
enabled: bool = False
|
||||
width: int = 1280
|
||||
height: int = 1024
|
||||
delay: int = 1000 # ms after DOM load
|
||||
user_agent: Optional[str] = None
|
||||
|
||||
|
||||
class ScreenshotCapture:
|
||||
"""
|
||||
Async screenshot capture using uri2png.
|
||||
|
||||
Since uri2png uses GTK main loop, we run it in a subprocess
|
||||
to avoid blocking the async event loop.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ScreenshotConfig = None):
|
||||
self.config = config or ScreenshotConfig()
|
||||
self._uri2png_available = None
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
"""Check if uri2png is installed and available."""
|
||||
if self._uri2png_available is not None:
|
||||
return self._uri2png_available
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
'python', '-c', 'from uri2png import Uri2Png',
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
self._uri2png_available = proc.returncode == 0
|
||||
except Exception:
|
||||
self._uri2png_available = False
|
||||
|
||||
if not self._uri2png_available:
|
||||
logger.warning("uri2png not available - screenshots disabled")
|
||||
|
||||
return self._uri2png_available
|
||||
|
||||
async def capture(self, uri: str) -> Optional[dict]:
|
||||
"""
|
||||
Capture screenshot of a URI.
|
||||
|
||||
Returns:
|
||||
Dict with 'data', 'md5_hash', 'mime_type' or None on failure
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return None
|
||||
|
||||
if not await self.is_available():
|
||||
return None
|
||||
|
||||
# Create temp file for screenshot
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
# Build uri2png command
|
||||
cmd = [
|
||||
'python', '-c',
|
||||
f'''
|
||||
from uri2png import Uri2Png
|
||||
Uri2Png(
|
||||
uri="{uri}",
|
||||
filepath="{tmp_path}",
|
||||
width={self.config.width},
|
||||
height={self.config.height},
|
||||
delay={self.config.delay},
|
||||
user_agent={repr(self.config.user_agent)},
|
||||
).capture()
|
||||
'''
|
||||
]
|
||||
|
||||
# Run with timeout (uri2png can hang on bad URLs)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=30.0 # 30 second timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
logger.warning(f"Screenshot timeout: {uri}")
|
||||
return None
|
||||
|
||||
if proc.returncode != 0:
|
||||
logger.warning(f"Screenshot failed ({proc.returncode}): {uri}")
|
||||
if stderr:
|
||||
logger.debug(f"stderr: {stderr.decode()}")
|
||||
return None
|
||||
|
||||
# Read screenshot data
|
||||
path = Path(tmp_path)
|
||||
|
||||
def _check_file():
|
||||
if not path.exists():
|
||||
return None
|
||||
size = path.stat().st_size
|
||||
if size == 0:
|
||||
return None
|
||||
return path.read_bytes()
|
||||
|
||||
data = await asyncio.to_thread(_check_file)
|
||||
if data is None:
|
||||
logger.warning(f"Screenshot empty: {uri}")
|
||||
return None
|
||||
md5_hash = hashlib.md5(data).hexdigest()
|
||||
|
||||
logger.debug(f"Screenshot captured: {uri} -> {md5_hash}")
|
||||
|
||||
return {
|
||||
'data': data,
|
||||
'md5_hash': md5_hash,
|
||||
'mime_type': 'image/png',
|
||||
'size': len(data),
|
||||
'source_uri': uri,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Screenshot error for {uri}: {e}")
|
||||
return None
|
||||
|
||||
finally:
|
||||
# Cleanup temp file (sync unlink is fine in finally - small operation)
|
||||
def _cleanup():
|
||||
try:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_cleanup)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def capture_to_file(self, uri: str, output_path: str) -> bool:
|
||||
"""
|
||||
Capture screenshot directly to a file.
|
||||
|
||||
Returns:
|
||||
True on success, False on failure
|
||||
"""
|
||||
result = await self.capture(uri)
|
||||
if result is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(Path(output_path).write_bytes, result['data'])
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write screenshot: {e}")
|
||||
return False
|
||||
258
serp.py
258
serp.py
|
|
@ -34,7 +34,15 @@ import uvicorn
|
|||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="neopig SERP", description="Search hydrated media")
|
||||
app = FastAPI(title="neopig", description="Media crawler + SERP + Screenshot service")
|
||||
|
||||
# Try to include uri2png screenshot router (optional dependency)
|
||||
try:
|
||||
from uri2png import get_screenshot_router
|
||||
app.include_router(get_screenshot_router())
|
||||
logger.info("Screenshot router loaded from uri2png")
|
||||
except ImportError:
|
||||
logger.warning("uri2png not installed - screenshot endpoints not available")
|
||||
|
||||
# Config - set via startup
|
||||
DB_PATH = "neopig.db"
|
||||
|
|
@ -44,9 +52,83 @@ VAULT_PATH = Path("vault")
|
|||
ACTIVE_CRAWLS: Dict[int, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
async def init_database():
|
||||
"""Initialize database schema if needed."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
# Crawl jobs table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS crawl_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_uri TEXT NOT NULL,
|
||||
keywords TEXT,
|
||||
mode TEXT DEFAULT 'images',
|
||||
status TEXT DEFAULT 'running',
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
stats TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Media records table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
md5_hash TEXT PRIMARY KEY,
|
||||
media_type TEXT,
|
||||
mime_type TEXT,
|
||||
file_size INTEGER,
|
||||
keywords TEXT,
|
||||
alt_text TEXT,
|
||||
title TEXT,
|
||||
first_seen_at TEXT NOT NULL,
|
||||
analysis_status TEXT DEFAULT 'pending',
|
||||
analysis_result TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Media sources table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS media_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
md5_hash TEXT NOT NULL,
|
||||
media_uri TEXT NOT NULL,
|
||||
page_uri TEXT,
|
||||
page_title TEXT,
|
||||
page_description TEXT,
|
||||
page_keywords TEXT,
|
||||
alt_text TEXT,
|
||||
link_text TEXT,
|
||||
crawl_job_id INTEGER,
|
||||
discovered_at TEXT NOT NULL,
|
||||
FOREIGN KEY (md5_hash) REFERENCES media(md5_hash),
|
||||
FOREIGN KEY (crawl_job_id) REFERENCES crawl_jobs(id),
|
||||
UNIQUE(md5_hash, media_uri, page_uri)
|
||||
)
|
||||
""")
|
||||
|
||||
# Indexes
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_media_type ON media(media_type)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_media_analysis ON media(analysis_status)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_sources_hash ON media_sources(md5_hash)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_sources_job ON media_sources(crawl_job_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_sources_media_uri ON media_sources(media_uri)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_sources_page_uri ON media_sources(page_uri)")
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Database initialized: {DB_PATH}")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize database on startup."""
|
||||
await init_database()
|
||||
VAULT_PATH.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Vault directory ready: {VAULT_PATH}")
|
||||
|
||||
|
||||
class CrawlRequest(BaseModel):
|
||||
"""Request to start a new crawl."""
|
||||
target_uri: str
|
||||
targets: List[str] = [] # Multiple target URIs
|
||||
target_uri: str = "" # Deprecated: single target (for backwards compat)
|
||||
keywords: List[str] = []
|
||||
mode: str = "images" # text, images, videos, media, all
|
||||
depth: int = -1 # -1 = unlimited
|
||||
|
|
@ -459,8 +541,8 @@ CRAWL_HTML = """
|
|||
|
||||
<form id="crawl-form" onsubmit="startCrawl(event)">
|
||||
<div class="form-group">
|
||||
<label>Target URI</label>
|
||||
<input type="text" id="target" placeholder="https://example.com" required>
|
||||
<label>Target URIs (space, comma, or newline separated)</label>
|
||||
<textarea id="target" placeholder="https://example.com https://another.com" rows="2" required style="width:100%;padding:10px;border:1px solid #333;border-radius:4px;background:#1a1a1a;color:#e0e0e0;font-size:14px;resize:vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -480,12 +562,12 @@ CRAWL_HTML = """
|
|||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Depth (-1 = unlimited)</label>
|
||||
<input type="number" id="depth" value="-1" min="-1">
|
||||
<label>Depth (5 recommended, max 15)</label>
|
||||
<input type="number" id="depth" value="5" min="1" max="15">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max Pages (-1 = unlimited)</label>
|
||||
<input type="number" id="max_pages" value="-1" min="-1">
|
||||
<input type="number" id="max_pages" value="500" min="-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -516,8 +598,22 @@ CRAWL_HTML = """
|
|||
.map(k => k.trim())
|
||||
.filter(k => k.length > 0);
|
||||
|
||||
// Parse multiple target URIs (space, comma, or newline separated)
|
||||
const targetsRaw = document.getElementById('target').value;
|
||||
const targets = targetsRaw
|
||||
.split(/[,\\s\\n]+/)
|
||||
.map(t => t.trim())
|
||||
.filter(t => t.length > 0 && t.startsWith('http'));
|
||||
|
||||
if (targets.length === 0) {
|
||||
alert('Please enter at least one valid URI (must start with http)');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Start Crawl';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
target_uri: document.getElementById('target').value,
|
||||
targets: targets,
|
||||
keywords: keywords,
|
||||
mode: document.getElementById('mode').value,
|
||||
depth: parseInt(document.getElementById('depth').value),
|
||||
|
|
@ -536,8 +632,9 @@ CRAWL_HTML = """
|
|||
const err = await res.json();
|
||||
alert('Error: ' + (err.detail || 'Failed to start crawl'));
|
||||
} else {
|
||||
const job = await res.json();
|
||||
alert('Crawl started! Job ID: ' + job.job_id);
|
||||
const result = await res.json();
|
||||
const jobIds = result.job_ids || [result.job_id];
|
||||
alert('Crawl started! Job IDs: ' + jobIds.join(', '));
|
||||
loadJobs();
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -966,6 +1063,26 @@ async def view_media_page(md5_hash: str):
|
|||
"""
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
has_screenshot = False
|
||||
try:
|
||||
from uri2png import get_available_engines
|
||||
has_screenshot = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"features": {
|
||||
"search": True,
|
||||
"crawl": True,
|
||||
"screenshot": has_screenshot
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def get_stats():
|
||||
"""Get database statistics."""
|
||||
|
|
@ -1158,9 +1275,10 @@ async def get_crawl_job(job_id: int):
|
|||
@app.post("/api/crawl")
|
||||
async def start_crawl(request: CrawlRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Start a new crawl job.
|
||||
Start new crawl job(s).
|
||||
|
||||
The crawl runs in the background. Poll /api/crawl/jobs/{id} for status.
|
||||
Supports multiple targets - creates one job per target.
|
||||
The crawls run in the background. Poll /api/crawl/jobs/{id} for status.
|
||||
"""
|
||||
# Import neopig here to avoid circular imports
|
||||
from neopig import NeoPig
|
||||
|
|
@ -1176,65 +1294,79 @@ async def start_crawl(request: CrawlRequest, background_tasks: BackgroundTasks):
|
|||
}
|
||||
mode = mode_map.get(request.mode, CrawlMode.IMAGES)
|
||||
|
||||
# Create job record first
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO crawl_jobs (target_uri, keywords, mode, status, started_at)
|
||||
VALUES (?, ?, ?, 'running', datetime('now'))
|
||||
""",
|
||||
(request.target_uri, json.dumps(request.keywords), request.mode)
|
||||
)
|
||||
await db.commit()
|
||||
job_id = cursor.lastrowid
|
||||
# Get targets (support both new 'targets' array and old 'target_uri' single value)
|
||||
targets = request.targets if request.targets else [request.target_uri] if request.target_uri else []
|
||||
if not targets:
|
||||
raise HTTPException(status_code=400, detail="No target URIs provided")
|
||||
|
||||
# Run crawl in background
|
||||
async def run_crawl():
|
||||
try:
|
||||
pig = NeoPig(db_path=DB_PATH, vault_path=str(VAULT_PATH))
|
||||
await pig.init()
|
||||
job_ids = []
|
||||
|
||||
# Override job_id since we already created it
|
||||
pig.db = None # We'll update directly
|
||||
|
||||
stats = await pig.crawl(
|
||||
target_uri=request.target_uri,
|
||||
keywords=request.keywords,
|
||||
mode=mode,
|
||||
depth=request.depth,
|
||||
max_pages=request.max_pages,
|
||||
download_media=request.download_media,
|
||||
# Create a job for each target
|
||||
for target_uri in targets:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO crawl_jobs (target_uri, keywords, mode, status, started_at)
|
||||
VALUES (?, ?, ?, 'running', datetime('now'))
|
||||
""",
|
||||
(target_uri, json.dumps(request.keywords), request.mode)
|
||||
)
|
||||
await db.commit()
|
||||
job_id = cursor.lastrowid
|
||||
job_ids.append(job_id)
|
||||
|
||||
# Update job as completed
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE crawl_jobs
|
||||
SET status = 'completed', completed_at = datetime('now'), stats = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(json.dumps(stats), job_id)
|
||||
# Run crawl in background (closure captures job_id and target_uri)
|
||||
async def run_crawl(jid=job_id, uri=target_uri):
|
||||
try:
|
||||
pig = NeoPig(db_path=DB_PATH, vault_path=str(VAULT_PATH))
|
||||
await pig.init()
|
||||
|
||||
# Load existing seen media for resume capability
|
||||
crawled_media = await pig.db.get_crawled_media_uris()
|
||||
if crawled_media:
|
||||
pig.seen_media = crawled_media
|
||||
|
||||
# Cap depth at 15 (convert -1 or values > 15 to 15)
|
||||
depth = request.depth if 1 <= request.depth <= 15 else 15
|
||||
|
||||
stats = await pig.crawl(
|
||||
target_uri=uri,
|
||||
keywords=request.keywords,
|
||||
mode=mode,
|
||||
depth=depth,
|
||||
max_pages=request.max_pages,
|
||||
download_media=request.download_media,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Crawl job {job_id} failed: {e}")
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE crawl_jobs
|
||||
SET status = 'failed', completed_at = datetime('now'), stats = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(json.dumps({"error": str(e)}), job_id)
|
||||
)
|
||||
await db.commit()
|
||||
# Update job as completed
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE crawl_jobs
|
||||
SET status = 'completed', completed_at = datetime('now'), stats = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(json.dumps(stats), jid)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Schedule background task
|
||||
background_tasks.add_task(asyncio.create_task, run_crawl())
|
||||
except Exception as e:
|
||||
logger.error(f"Crawl job {jid} failed: {e}")
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE crawl_jobs
|
||||
SET status = 'failed', completed_at = datetime('now'), stats = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(json.dumps({"error": str(e)}), jid)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"job_id": job_id, "status": "running"}
|
||||
# Schedule background task
|
||||
background_tasks.add_task(asyncio.create_task, run_crawl())
|
||||
|
||||
return {"job_ids": job_ids, "status": "running", "count": len(job_ids)}
|
||||
|
||||
|
||||
def main():
|
||||
|
|
|
|||
138
storage.py
138
storage.py
|
|
@ -50,11 +50,7 @@ class ImageVault:
|
|||
return
|
||||
|
||||
if HAS_FILEVAULT:
|
||||
loop = asyncio.get_event_loop()
|
||||
self.filevault = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: FileVault(str(self.vault_path))
|
||||
)
|
||||
self.filevault = await asyncio.to_thread(FileVault, str(self.vault_path))
|
||||
else:
|
||||
# Fallback: create directory structure
|
||||
await aiofiles.os.makedirs(self.vault_path, exist_ok=True)
|
||||
|
|
@ -79,12 +75,7 @@ class ImageVault:
|
|||
"""
|
||||
if HAS_FILEVAULT and self.filevault:
|
||||
# filevault stores by content hash automatically
|
||||
loop = asyncio.get_event_loop()
|
||||
key = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.filevault.put(data)
|
||||
)
|
||||
return key
|
||||
return await asyncio.to_thread(self.filevault.put, data)
|
||||
else:
|
||||
# Fallback: store in subdirectory by first 2 chars
|
||||
subdir = md5_hash[:2]
|
||||
|
|
@ -99,102 +90,109 @@ class ImageVault:
|
|||
async def exists(self, md5_hash: str) -> bool:
|
||||
"""Check if a file exists by MD5 hash."""
|
||||
if HAS_FILEVAULT and self.filevault:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.filevault.exists(md5_hash)
|
||||
)
|
||||
return await asyncio.to_thread(self.filevault.exists, md5_hash)
|
||||
else:
|
||||
# Check for any file starting with this hash
|
||||
subdir = self.vault_path / md5_hash[:2]
|
||||
if not subdir.exists():
|
||||
|
||||
def _check_exists():
|
||||
if not subdir.exists():
|
||||
return False
|
||||
for f in subdir.iterdir():
|
||||
if f.name.startswith(md5_hash):
|
||||
return True
|
||||
return False
|
||||
|
||||
for f in subdir.iterdir():
|
||||
if f.name.startswith(md5_hash):
|
||||
return True
|
||||
return False
|
||||
return await asyncio.to_thread(_check_exists)
|
||||
|
||||
async def get(self, md5_hash: str) -> Optional[bytes]:
|
||||
"""Retrieve data by MD5 hash."""
|
||||
if HAS_FILEVAULT and self.filevault:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.filevault.get(md5_hash)
|
||||
)
|
||||
return await asyncio.to_thread(self.filevault.get, md5_hash)
|
||||
else:
|
||||
subdir = self.vault_path / md5_hash[:2]
|
||||
if not subdir.exists():
|
||||
|
||||
def _find_file():
|
||||
if not subdir.exists():
|
||||
return None
|
||||
for f in subdir.iterdir():
|
||||
if f.name.startswith(md5_hash):
|
||||
return f
|
||||
return None
|
||||
|
||||
for f in subdir.iterdir():
|
||||
if f.name.startswith(md5_hash):
|
||||
async with aiofiles.open(f, 'rb') as file:
|
||||
return await file.read()
|
||||
return None
|
||||
file_path = await asyncio.to_thread(_find_file)
|
||||
if file_path is None:
|
||||
return None
|
||||
|
||||
async with aiofiles.open(file_path, 'rb') as file:
|
||||
return await file.read()
|
||||
|
||||
async def get_path(self, md5_hash: str) -> Optional[Path]:
|
||||
"""Get the filesystem path for a stored file."""
|
||||
if HAS_FILEVAULT and self.filevault:
|
||||
# filevault may not expose paths directly
|
||||
loop = asyncio.get_event_loop()
|
||||
path = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.filevault.path(md5_hash) if hasattr(self.filevault, 'path') else None
|
||||
)
|
||||
def _get_path():
|
||||
return self.filevault.path(md5_hash) if hasattr(self.filevault, 'path') else None
|
||||
|
||||
path = await asyncio.to_thread(_get_path)
|
||||
return Path(path) if path else None
|
||||
else:
|
||||
subdir = self.vault_path / md5_hash[:2]
|
||||
if not subdir.exists():
|
||||
|
||||
def _find_file():
|
||||
if not subdir.exists():
|
||||
return None
|
||||
for f in subdir.iterdir():
|
||||
if f.name.startswith(md5_hash):
|
||||
return f
|
||||
return None
|
||||
|
||||
for f in subdir.iterdir():
|
||||
if f.name.startswith(md5_hash):
|
||||
return f
|
||||
return None
|
||||
return await asyncio.to_thread(_find_file)
|
||||
|
||||
async def delete(self, md5_hash: str) -> bool:
|
||||
"""Delete a file by MD5 hash."""
|
||||
if HAS_FILEVAULT and self.filevault:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.filevault.delete(md5_hash)
|
||||
)
|
||||
return await asyncio.to_thread(self.filevault.delete, md5_hash)
|
||||
else:
|
||||
subdir = self.vault_path / md5_hash[:2]
|
||||
if not subdir.exists():
|
||||
|
||||
def _find_file():
|
||||
if not subdir.exists():
|
||||
return None
|
||||
for f in subdir.iterdir():
|
||||
if f.name.startswith(md5_hash):
|
||||
return f
|
||||
return None
|
||||
|
||||
file_path = await asyncio.to_thread(_find_file)
|
||||
if file_path is None:
|
||||
return False
|
||||
|
||||
for f in subdir.iterdir():
|
||||
if f.name.startswith(md5_hash):
|
||||
await aiofiles.os.remove(f)
|
||||
return True
|
||||
return False
|
||||
await aiofiles.os.remove(file_path)
|
||||
return True
|
||||
|
||||
async def stats(self) -> dict:
|
||||
"""Get vault statistics."""
|
||||
if HAS_FILEVAULT and self.filevault:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: {
|
||||
def _get_stats():
|
||||
return {
|
||||
'backend': 'filevault',
|
||||
'count': len(self.filevault) if hasattr(self.filevault, '__len__') else -1
|
||||
}
|
||||
)
|
||||
return await asyncio.to_thread(_get_stats)
|
||||
else:
|
||||
count = 0
|
||||
total_size = 0
|
||||
for subdir in self.vault_path.iterdir():
|
||||
if subdir.is_dir():
|
||||
for f in subdir.iterdir():
|
||||
count += 1
|
||||
total_size += f.stat().st_size
|
||||
def _compute_stats():
|
||||
count = 0
|
||||
total_size = 0
|
||||
for subdir in self.vault_path.iterdir():
|
||||
if subdir.is_dir():
|
||||
for f in subdir.iterdir():
|
||||
count += 1
|
||||
total_size += f.stat().st_size
|
||||
return {
|
||||
'backend': 'directory',
|
||||
'count': count,
|
||||
'total_size_bytes': total_size
|
||||
}
|
||||
|
||||
return {
|
||||
'backend': 'directory',
|
||||
'count': count,
|
||||
'total_size_bytes': total_size
|
||||
}
|
||||
return await asyncio.to_thread(_compute_stats)
|
||||
|
|
|
|||
409
tests/unit/test_async_filevault.py
Normal file
409
tests/unit/test_async_filevault.py
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
"""
|
||||
Unit tests for AsyncFileVault
|
||||
Tests async wrapper functionality
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
import asyncio
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from async_filevault import AsyncVault, create_async_vault
|
||||
|
||||
|
||||
class TestAsyncVaultBasics:
|
||||
"""Test basic AsyncVault functionality"""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.vault_path = os.path.join(self.temp_dir, "test_vault")
|
||||
|
||||
def teardown_method(self):
|
||||
if os.path.exists(self.temp_dir):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_async_vault_initialization(self):
|
||||
"""Test AsyncVault initializes correctly"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path, use_pairs=True)
|
||||
|
||||
assert vault.vaultpath == self.vault_path
|
||||
assert vault.use_pairs is True
|
||||
assert vault.depth == 3
|
||||
|
||||
def test_sync_methods_work(self):
|
||||
"""Test that sync methods (filename generation) work without await"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
# These should work synchronously
|
||||
filename = vault.create_filename("test_seed", ".json", absolute=True)
|
||||
assert filename.startswith(self.vault_path)
|
||||
assert filename.endswith(".json")
|
||||
|
||||
random_filename = vault.create_random_filename(".txt")
|
||||
assert random_filename.endswith(".txt")
|
||||
|
||||
def test_factory_function(self):
|
||||
"""Test create_async_vault factory function"""
|
||||
vault = create_async_vault(vaultpath=self.vault_path, salt="test")
|
||||
assert isinstance(vault, AsyncVault)
|
||||
|
||||
|
||||
class TestAsyncJSONOperations:
|
||||
"""Test async JSON operations"""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.vault_path = os.path.join(self.temp_dir, "test_vault")
|
||||
|
||||
def teardown_method(self):
|
||||
if os.path.exists(self.temp_dir):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_atomic_write_json(self):
|
||||
"""Test async atomic JSON write"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("async_json", ".json", absolute=True)
|
||||
test_data = {"key": "value", "number": 42}
|
||||
|
||||
await vault.atomic_write_json(test_file, test_data)
|
||||
|
||||
assert os.path.exists(test_file)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_safe_read_json(self):
|
||||
"""Test async safe JSON read"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("async_read", ".json", absolute=True)
|
||||
test_data = {"async": True, "data": [1, 2, 3]}
|
||||
|
||||
await vault.atomic_write_json(test_file, test_data)
|
||||
read_data = await vault.safe_read_json(test_file)
|
||||
|
||||
assert read_data == test_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_safe_read_json_with_default(self):
|
||||
"""Test async safe JSON read with default"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("nonexistent", ".json", absolute=True)
|
||||
|
||||
result = await vault.safe_read_json(test_file, default={"default": True})
|
||||
assert result == {"default": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_write_with_aliases(self):
|
||||
"""Test async write with aliases"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("primary", ".json", absolute=True)
|
||||
alias_file = vault.create_filename("alias", ".json", absolute=True)
|
||||
|
||||
await vault.atomic_write_json(test_file, {"id": 1}, aliases=["alias"])
|
||||
|
||||
# Read through alias
|
||||
data = await vault.safe_read_json(alias_file)
|
||||
assert data == {"id": 1}
|
||||
|
||||
|
||||
class TestAsyncTextOperations:
|
||||
"""Test async text file operations"""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.vault_path = os.path.join(self.temp_dir, "test_vault")
|
||||
|
||||
def teardown_method(self):
|
||||
if os.path.exists(self.temp_dir):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_write_text_file(self):
|
||||
"""Test async text file write"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("async_text", ".txt", absolute=True)
|
||||
test_content = "Async content\nLine 2"
|
||||
|
||||
await vault.write_text_file(test_file, test_content)
|
||||
|
||||
assert os.path.exists(test_file)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_read_text_file(self):
|
||||
"""Test async text file read"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("async_read_text", ".txt", absolute=True)
|
||||
test_content = "Test async read"
|
||||
|
||||
await vault.write_text_file(test_file, test_content)
|
||||
read_content = await vault.read_text_file(test_file)
|
||||
|
||||
assert read_content == test_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_read_text_file_with_default(self):
|
||||
"""Test async text read with default"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("nonexistent", ".txt", absolute=True)
|
||||
|
||||
result = await vault.read_text_file(test_file, default="default content")
|
||||
assert result == "default content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_append_text_file(self):
|
||||
"""Test async text file append"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("async_append", ".txt", absolute=True)
|
||||
|
||||
await vault.write_text_file(test_file, "Line 1\n")
|
||||
await vault.write_text_file(test_file, "Line 2\n", mode="a")
|
||||
|
||||
content = await vault.read_text_file(test_file)
|
||||
assert content == "Line 1\nLine 2\n"
|
||||
|
||||
|
||||
class TestAsyncFileOperations:
|
||||
"""Test async file existence and removal"""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.vault_path = os.path.join(self.temp_dir, "test_vault")
|
||||
|
||||
def teardown_method(self):
|
||||
if os.path.exists(self.temp_dir):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_file_exists(self):
|
||||
"""Test async file existence check"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("exists_test", ".txt", absolute=True)
|
||||
|
||||
# File doesn't exist yet
|
||||
exists_before = await vault.file_exists(test_file)
|
||||
assert exists_before is False
|
||||
|
||||
# Create file
|
||||
await vault.write_text_file(test_file, "test")
|
||||
|
||||
# Now it exists
|
||||
exists_after = await vault.file_exists(test_file)
|
||||
assert exists_after is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_file_exists_with_cache(self):
|
||||
"""Test async file_exists uses cache when available"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path, enable_memory_cache=True)
|
||||
|
||||
test_file = vault.create_filename("cached", ".txt", absolute=True)
|
||||
|
||||
# Manually set cache
|
||||
vault.mark_file_exists(test_file, True)
|
||||
|
||||
# Should return from cache without I/O
|
||||
exists = await vault.file_exists(test_file)
|
||||
assert exists is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_remove_file(self):
|
||||
"""Test async file removal"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("to_remove", ".txt", absolute=True)
|
||||
|
||||
await vault.write_text_file(test_file, "test")
|
||||
assert os.path.exists(test_file)
|
||||
|
||||
result = await vault.remove_file(test_file)
|
||||
assert result is True
|
||||
assert not os.path.exists(test_file)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_remove_file_nonexistent(self):
|
||||
"""Test async remove of non-existent file"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("nonexistent", ".txt", absolute=True)
|
||||
|
||||
result = await vault.remove_file(test_file)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_remove_file_if_exists(self):
|
||||
"""Test async remove_file_if_exists"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
test_file = vault.create_filename("maybe", ".txt", absolute=True)
|
||||
|
||||
# Should not raise for non-existent
|
||||
result1 = await vault.remove_file_if_exists(test_file)
|
||||
assert result1 is False
|
||||
|
||||
# Create and remove
|
||||
await vault.write_text_file(test_file, "test")
|
||||
result2 = await vault.remove_file_if_exists(test_file)
|
||||
assert result2 is True
|
||||
|
||||
|
||||
class TestAsyncConcurrency:
|
||||
"""Test async concurrent operations"""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.vault_path = os.path.join(self.temp_dir, "test_vault")
|
||||
|
||||
def teardown_method(self):
|
||||
if os.path.exists(self.temp_dir):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_writes(self):
|
||||
"""Test multiple concurrent async writes"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path, use_pairs=True)
|
||||
|
||||
async def write_item(i):
|
||||
filename = vault.create_filename(f"item_{i}", ".json", absolute=True)
|
||||
await vault.atomic_write_json(filename, {"index": i})
|
||||
return i
|
||||
|
||||
# Run 10 concurrent writes
|
||||
results = await asyncio.gather(*[write_item(i) for i in range(10)])
|
||||
|
||||
assert len(results) == 10
|
||||
assert set(results) == set(range(10))
|
||||
|
||||
# Verify all files exist
|
||||
for i in range(10):
|
||||
filename = vault.create_filename(f"item_{i}", ".json", absolute=True)
|
||||
data = await vault.safe_read_json(filename)
|
||||
assert data["index"] == i
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_reads(self):
|
||||
"""Test multiple concurrent async reads"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path)
|
||||
|
||||
# Write files first
|
||||
for i in range(5):
|
||||
filename = vault.create_filename(f"read_{i}", ".json", absolute=True)
|
||||
await vault.atomic_write_json(filename, {"value": i * 10})
|
||||
|
||||
async def read_item(i):
|
||||
filename = vault.create_filename(f"read_{i}", ".json", absolute=True)
|
||||
return await vault.safe_read_json(filename)
|
||||
|
||||
# Concurrent reads
|
||||
results = await asyncio.gather(*[read_item(i) for i in range(5)])
|
||||
|
||||
assert len(results) == 5
|
||||
for i, result in enumerate(results):
|
||||
assert result["value"] == i * 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_operations(self):
|
||||
"""Test mixed concurrent operations"""
|
||||
vault = AsyncVault(vaultpath=self.vault_path, enable_memory_cache=True)
|
||||
|
||||
async def operation(i):
|
||||
filename = vault.create_filename(f"mixed_{i}", ".json", absolute=True)
|
||||
|
||||
# Write
|
||||
await vault.atomic_write_json(filename, {"step": 1, "i": i})
|
||||
|
||||
# Read
|
||||
data = await vault.safe_read_json(filename)
|
||||
assert data["step"] == 1
|
||||
|
||||
# Update
|
||||
data["step"] = 2
|
||||
await vault.atomic_write_json(filename, data)
|
||||
|
||||
# Verify
|
||||
final = await vault.safe_read_json(filename)
|
||||
return final["step"]
|
||||
|
||||
results = await asyncio.gather(*[operation(i) for i in range(5)])
|
||||
assert all(r == 2 for r in results)
|
||||
|
||||
|
||||
class TestAsyncIntegration:
|
||||
"""Integration tests for async workflows"""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.vault_path = os.path.join(self.temp_dir, "test_vault")
|
||||
|
||||
def teardown_method(self):
|
||||
if os.path.exists(self.temp_dir):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_async_workflow(self):
|
||||
"""Test complete async CRUD workflow"""
|
||||
vault = AsyncVault(
|
||||
vaultpath=self.vault_path,
|
||||
depth=5,
|
||||
use_pairs=True,
|
||||
enable_memory_cache=True
|
||||
)
|
||||
|
||||
key = "workflow_key"
|
||||
filename = vault.create_filename(key, ".json", absolute=True)
|
||||
|
||||
# Create
|
||||
await vault.atomic_write_json(filename, {"version": 1})
|
||||
assert await vault.file_exists(filename)
|
||||
|
||||
# Read
|
||||
data = await vault.safe_read_json(filename)
|
||||
assert data["version"] == 1
|
||||
|
||||
# Update
|
||||
data["version"] = 2
|
||||
data["updated"] = True
|
||||
await vault.atomic_write_json(filename, data)
|
||||
|
||||
# Verify
|
||||
updated = await vault.safe_read_json(filename)
|
||||
assert updated["version"] == 2
|
||||
assert updated["updated"] is True
|
||||
|
||||
# Delete
|
||||
await vault.remove_file(filename)
|
||||
assert not await vault.file_exists(filename)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_pairs_mode(self):
|
||||
"""Test async with pairs mode (neopig use case)"""
|
||||
vault = AsyncVault(
|
||||
vaultpath=self.vault_path,
|
||||
depth=9,
|
||||
use_pairs=True,
|
||||
salt="test_salt"
|
||||
)
|
||||
|
||||
# Simulate domain-based storage
|
||||
domains = ["example.com", "test.org", "sample.net"]
|
||||
|
||||
for domain in domains:
|
||||
filename = vault.create_filename(domain, ".json", absolute=True)
|
||||
await vault.atomic_write_json(filename, {"domain": domain, "crawled": True})
|
||||
|
||||
# Verify all
|
||||
for domain in domains:
|
||||
filename = vault.create_filename(domain, ".json", absolute=True)
|
||||
data = await vault.safe_read_json(filename)
|
||||
assert data["domain"] == domain
|
||||
assert data["crawled"] is True
|
||||
1031
tests/unit/test_filevault.py
Normal file
1031
tests/unit/test_filevault.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue